mirror of
https://github.com/lingble/twenty.git
synced 2025-10-29 20:02:29 +00:00
feat: replace iframe with chrome sidepanel (#5197)
fixes - #5201 https://github.com/twentyhq/twenty/assets/13139771/871019c6-6456-46b4-95dd-07ffb33eb4fd --------- Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
@@ -7,6 +7,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/options/index.tsx"></script>
|
||||
<script type="module" src="/src/options/page-inaccessible-index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
22
packages/twenty-chrome-extension/sidepanel.html
Normal file
22
packages/twenty-chrome-extension/sidepanel.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/icons/android/android-launchericon-48-48.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Twenty</title>
|
||||
<style>
|
||||
/* Reset margin and padding */
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%; /* Ensure body takes full viewport height */
|
||||
overflow: hidden; /* Prevents scrollbars from appearing */
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/options/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,40 +1,55 @@
|
||||
import Crypto from 'crypto-js';
|
||||
|
||||
import { openOptionsPage } from '~/background/utils/openOptionsPage';
|
||||
import { exchangeAuthorizationCode } from '~/db/auth.db';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
// Open options page programmatically in a new tab.
|
||||
chrome.runtime.onInstalled.addListener((details) => {
|
||||
if (details.reason === 'install') {
|
||||
openOptionsPage();
|
||||
}
|
||||
});
|
||||
// chrome.runtime.onInstalled.addListener((details) => {
|
||||
// if (details.reason === 'install') {
|
||||
// openOptionsPage();
|
||||
// }
|
||||
// });
|
||||
|
||||
// Open options page when extension icon is clicked.
|
||||
chrome.action.onClicked.addListener((tab) => {
|
||||
chrome.tabs.sendMessage(tab.id ?? 0, { action: 'TOGGLE' });
|
||||
});
|
||||
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
|
||||
|
||||
// This listens for an event from other parts of the extension, such as the content script, and performs the required tasks.
|
||||
// The cases themselves are labelled such that their operations are reflected by their names.
|
||||
chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
|
||||
switch (message.action) {
|
||||
case 'getActiveTab': // e.g. "https://linkedin.com/company/twenty/"
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (isDefined(tabs) && isDefined(tabs[0])) {
|
||||
sendResponse({ tab: tabs[0] });
|
||||
case 'getActiveTab': {
|
||||
// e.g. "https://linkedin.com/company/twenty/"
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, ([tab]) => {
|
||||
if (isDefined(tab) && isDefined(tab.id)) {
|
||||
sendResponse({ tab });
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'openOptionsPage':
|
||||
openOptionsPage();
|
||||
break;
|
||||
case 'CONNECT':
|
||||
}
|
||||
case 'launchOAuth': {
|
||||
launchOAuth(({ status, message }) => {
|
||||
sendResponse({ status, message });
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'openSidepanel': {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, ([tab]) => {
|
||||
if (isDefined(tab) && isDefined(tab.id)) {
|
||||
chrome.sidePanel.open({ tabId: tab.id });
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'changeSidepanelUrl': {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, ([tab]) => {
|
||||
if (isDefined(tab) && isDefined(tab.id)) {
|
||||
chrome.tabs.sendMessage(tab.id, {
|
||||
action: 'changeSidepanelUrl',
|
||||
message,
|
||||
});
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -101,13 +116,16 @@ const launchOAuth = (
|
||||
|
||||
callback({ status: true, message: '' });
|
||||
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (isDefined(tabs) && isDefined(tabs[0])) {
|
||||
chrome.tabs.sendMessage(tabs[0].id ?? 0, {
|
||||
action: 'AUTHENTICATED',
|
||||
});
|
||||
}
|
||||
});
|
||||
chrome.tabs.query(
|
||||
{ active: true, currentWindow: true },
|
||||
([tab]) => {
|
||||
if (isDefined(tab) && isDefined(tab.id)) {
|
||||
chrome.tabs.sendMessage(tab.id, {
|
||||
action: 'executeContentScript',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -117,14 +135,22 @@ const launchOAuth = (
|
||||
});
|
||||
};
|
||||
|
||||
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, _, tab) => {
|
||||
const isDesiredRoute =
|
||||
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/company(?:\/\S+)?/) ||
|
||||
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/in(?:\/\S+)?/);
|
||||
|
||||
if (changeInfo.status === 'complete' && tab.active) {
|
||||
if (tab.active === true) {
|
||||
if (isDefined(isDesiredRoute)) {
|
||||
chrome.tabs.sendMessage(tabId, { action: 'executeContentScript' });
|
||||
}
|
||||
}
|
||||
|
||||
await chrome.sidePanel.setOptions({
|
||||
tabId,
|
||||
path: tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com/)
|
||||
? 'sidepanel.html'
|
||||
: 'page-inaccessible.html',
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
const openOptionsPage = () => {
|
||||
chrome.runtime.openOptionsPage();
|
||||
};
|
||||
|
||||
export { openOptionsPage };
|
||||
@@ -1,13 +1,16 @@
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
interface CustomDiv extends HTMLDivElement {
|
||||
onClickHandler: (newHandler: () => void) => void;
|
||||
}
|
||||
|
||||
export const createDefaultButton = (
|
||||
buttonId: string,
|
||||
onClickHandler?: () => void,
|
||||
buttonText = '',
|
||||
) => {
|
||||
const btn = document.getElementById(buttonId);
|
||||
): CustomDiv => {
|
||||
const btn = document.getElementById(buttonId) as CustomDiv;
|
||||
if (isDefined(btn)) return btn;
|
||||
const div = document.createElement('div');
|
||||
const div = document.createElement('div') as CustomDiv;
|
||||
const img = document.createElement('img');
|
||||
const span = document.createElement('span');
|
||||
|
||||
@@ -52,19 +55,18 @@ export const createDefaultButton = (
|
||||
Object.assign(div.style, divStyles);
|
||||
});
|
||||
|
||||
// Handle the click event.
|
||||
div.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const store = await chrome.storage.local.get();
|
||||
div.onClickHandler = (newHandler) => {
|
||||
div.onclick = async () => {
|
||||
const store = await chrome.storage.local.get();
|
||||
|
||||
// If an api key is not set, the options page opens up to allow the user to configure an api key.
|
||||
if (!store.accessToken) {
|
||||
chrome.runtime.sendMessage({ action: 'openOptionsPage' });
|
||||
return;
|
||||
}
|
||||
|
||||
onClickHandler?.();
|
||||
});
|
||||
// If an api key is not set, the options page opens up to allow the user to configure an api key.
|
||||
if (!store.accessToken) {
|
||||
chrome.runtime.sendMessage({ action: 'openSidepanel' });
|
||||
return;
|
||||
}
|
||||
newHandler();
|
||||
};
|
||||
};
|
||||
|
||||
div.id = buttonId;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createDefaultButton } from '~/contentScript/createButton';
|
||||
import changeSidePanelUrl from '~/contentScript/utils/changeSidepanelUrl';
|
||||
import extractCompanyLinkedinLink from '~/contentScript/utils/extractCompanyLinkedinLink';
|
||||
import extractDomain from '~/contentScript/utils/extractDomain';
|
||||
import { createCompany, fetchCompany } from '~/db/company.db';
|
||||
@@ -71,27 +72,19 @@ export const addCompany = async () => {
|
||||
const companyURL = extractCompanyLinkedinLink(activeTab.url);
|
||||
companyInputData.linkedinLink = { url: companyURL, label: companyURL };
|
||||
|
||||
const company = await createCompany(companyInputData);
|
||||
return company;
|
||||
const companyId = await createCompany(companyInputData);
|
||||
|
||||
if (isDefined(companyId)) {
|
||||
await changeSidePanelUrl(
|
||||
`${import.meta.env.VITE_FRONT_BASE_URL}/object/company/${companyId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return companyId;
|
||||
};
|
||||
|
||||
export const insertButtonForCompany = async () => {
|
||||
const companyButtonDiv = createDefaultButton(
|
||||
'twenty-company-btn',
|
||||
async () => {
|
||||
if (isDefined(companyButtonDiv)) {
|
||||
const companyBtnSpan = companyButtonDiv.getElementsByTagName('span')[0];
|
||||
companyBtnSpan.textContent = 'Saving...';
|
||||
const company = await addCompany();
|
||||
if (isDefined(company)) {
|
||||
companyBtnSpan.textContent = 'Saved';
|
||||
Object.assign(companyButtonDiv.style, { pointerEvents: 'none' });
|
||||
} else {
|
||||
companyBtnSpan.textContent = 'Try again';
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
const companyButtonDiv = createDefaultButton('twenty-company-btn');
|
||||
|
||||
const parentDiv: HTMLDivElement | null = document.querySelector(
|
||||
'.org-top-card-primary-actions__inner',
|
||||
@@ -105,13 +98,35 @@ export const insertButtonForCompany = async () => {
|
||||
parentDiv.prepend(companyButtonDiv);
|
||||
}
|
||||
|
||||
const companyBtnSpan = companyButtonDiv.getElementsByTagName('span')[0];
|
||||
const companyButtonSpan = companyButtonDiv.getElementsByTagName('span')[0];
|
||||
const company = await checkIfCompanyExists();
|
||||
|
||||
const openCompanyOnSidePanel = (companyId: string) => {
|
||||
companyButtonSpan.textContent = 'View in Twenty';
|
||||
companyButtonDiv.onClickHandler(async () => {
|
||||
await changeSidePanelUrl(
|
||||
`${import.meta.env.VITE_FRONT_BASE_URL}/object/company/${companyId}`,
|
||||
);
|
||||
chrome.runtime.sendMessage({ action: 'openSidepanel' });
|
||||
});
|
||||
};
|
||||
|
||||
if (isDefined(company)) {
|
||||
companyBtnSpan.textContent = 'Saved';
|
||||
Object.assign(companyButtonDiv.style, { pointerEvents: 'none' });
|
||||
await changeSidePanelUrl(
|
||||
`${import.meta.env.VITE_FRONT_BASE_URL}/object/company/${company.id}`,
|
||||
);
|
||||
if (isDefined(company.id)) openCompanyOnSidePanel(company.id);
|
||||
} else {
|
||||
companyBtnSpan.textContent = 'Add to Twenty';
|
||||
companyButtonSpan.textContent = 'Add to Twenty';
|
||||
|
||||
companyButtonDiv.onClickHandler(async () => {
|
||||
companyButtonSpan.textContent = 'Saving...';
|
||||
const companyId = await addCompany();
|
||||
if (isDefined(companyId)) {
|
||||
openCompanyOnSidePanel(companyId);
|
||||
} else {
|
||||
companyButtonSpan.textContent = 'Try again';
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createDefaultButton } from '~/contentScript/createButton';
|
||||
import changeSidePanelUrl from '~/contentScript/utils/changeSidepanelUrl';
|
||||
import extractFirstAndLastName from '~/contentScript/utils/extractFirstAndLastName';
|
||||
import { createPerson, fetchPerson } from '~/db/person.db';
|
||||
import { PersonInput } from '~/db/types/person.types';
|
||||
@@ -82,44 +83,58 @@ export const addPerson = async () => {
|
||||
}
|
||||
|
||||
personData.linkedinLink = { url: activeTabUrl, label: activeTabUrl };
|
||||
const person = await createPerson(personData);
|
||||
return person;
|
||||
const personId = await createPerson(personData);
|
||||
|
||||
if (isDefined(personId)) {
|
||||
await changeSidePanelUrl(
|
||||
`${import.meta.env.VITE_FRONT_BASE_URL}/object/person/${personId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return personId;
|
||||
};
|
||||
|
||||
export const insertButtonForPerson = async () => {
|
||||
const personButtonDiv = createDefaultButton('twenty-person-btn', async () => {
|
||||
if (isDefined(personButtonDiv)) {
|
||||
const personBtnSpan = personButtonDiv.getElementsByTagName('span')[0];
|
||||
personBtnSpan.textContent = 'Saving...';
|
||||
const person = await addPerson();
|
||||
if (isDefined(person)) {
|
||||
personBtnSpan.textContent = 'Saved';
|
||||
Object.assign(personButtonDiv.style, { pointerEvents: 'none' });
|
||||
} else {
|
||||
personBtnSpan.textContent = 'Try again';
|
||||
}
|
||||
}
|
||||
});
|
||||
const personButtonDiv = createDefaultButton('twenty-person-btn');
|
||||
|
||||
if (isDefined(personButtonDiv)) {
|
||||
const parentDiv: HTMLDivElement | null = document.querySelector(
|
||||
'.pv-top-card-v2-ctas',
|
||||
const addedProfileDiv: HTMLDivElement | null = document.querySelector(
|
||||
'.pv-top-card-v2-ctas__custom',
|
||||
);
|
||||
|
||||
if (isDefined(parentDiv)) {
|
||||
if (isDefined(addedProfileDiv)) {
|
||||
Object.assign(personButtonDiv.style, {
|
||||
marginRight: '.8rem',
|
||||
});
|
||||
parentDiv.prepend(personButtonDiv);
|
||||
addedProfileDiv.prepend(personButtonDiv);
|
||||
}
|
||||
|
||||
const personBtnSpan = personButtonDiv.getElementsByTagName('span')[0];
|
||||
const personButtonSpan = personButtonDiv.getElementsByTagName('span')[0];
|
||||
const person = await checkIfPersonExists();
|
||||
|
||||
const openPersonOnSidePanel = (personId: string) => {
|
||||
personButtonSpan.textContent = 'View in Twenty';
|
||||
personButtonDiv.onClickHandler(async () => {
|
||||
await changeSidePanelUrl(
|
||||
`${import.meta.env.VITE_FRONT_BASE_URL}/object/person/${personId}`,
|
||||
);
|
||||
chrome.runtime.sendMessage({ action: 'openSidepanel' });
|
||||
});
|
||||
};
|
||||
|
||||
if (isDefined(person)) {
|
||||
personBtnSpan.textContent = 'Saved';
|
||||
Object.assign(personButtonDiv.style, { pointerEvents: 'none' });
|
||||
await changeSidePanelUrl(
|
||||
`${import.meta.env.VITE_FRONT_BASE_URL}/object/person/${person.id}`,
|
||||
);
|
||||
if (isDefined(person.id)) openPersonOnSidePanel(person.id);
|
||||
} else {
|
||||
personBtnSpan.textContent = 'Add to Twenty';
|
||||
personButtonSpan.textContent = 'Add to Twenty';
|
||||
personButtonDiv.onClickHandler(async () => {
|
||||
personButtonSpan.textContent = 'Saving...';
|
||||
const personId = await addPerson();
|
||||
if (isDefined(personId)) openPersonOnSidePanel(personId);
|
||||
else personButtonSpan.textContent = 'Try again';
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { insertButtonForCompany } from '~/contentScript/extractCompanyProfile';
|
||||
import { insertButtonForPerson } from '~/contentScript/extractPersonProfile';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
// Inject buttons into the DOM when SPA is reloaded on the resource url.
|
||||
// e.g. reload the page when on https://www.linkedin.com/in/mabdullahabaid/
|
||||
@@ -20,85 +19,5 @@ chrome.runtime.onMessage.addListener(async (message, _, sendResponse) => {
|
||||
await insertButtonForPerson();
|
||||
}
|
||||
|
||||
if (message.action === 'TOGGLE') {
|
||||
await toggle();
|
||||
}
|
||||
|
||||
if (message.action === 'AUTHENTICATED') {
|
||||
await authenticated();
|
||||
}
|
||||
|
||||
sendResponse('Executing!');
|
||||
});
|
||||
|
||||
const IFRAME_WIDTH = '400px';
|
||||
|
||||
const createIframe = () => {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.background = 'lightgrey';
|
||||
iframe.style.height = '100vh';
|
||||
iframe.style.width = IFRAME_WIDTH;
|
||||
iframe.style.position = 'fixed';
|
||||
iframe.style.top = '0px';
|
||||
iframe.style.right = `-${IFRAME_WIDTH}`;
|
||||
iframe.style.zIndex = '9000000000000000000';
|
||||
iframe.style.transition = 'ease-in-out 0.3s';
|
||||
return iframe;
|
||||
};
|
||||
|
||||
const handleContentIframeLoadComplete = () => {
|
||||
//If the pop-out window is already open then we replace loading iframe with our content iframe
|
||||
if (optionsIframe.style.right === '0px') contentIframe.style.right = '0px';
|
||||
optionsIframe.style.display = 'none';
|
||||
contentIframe.style.display = 'block';
|
||||
};
|
||||
|
||||
//Creating one iframe where we are loading our front end in the background
|
||||
const contentIframe = createIframe();
|
||||
contentIframe.style.display = 'none';
|
||||
|
||||
chrome.storage.local.get().then((store) => {
|
||||
if (isDefined(store.loginToken)) {
|
||||
contentIframe.src = `${import.meta.env.VITE_FRONT_BASE_URL}`;
|
||||
contentIframe.onload = handleContentIframeLoadComplete;
|
||||
}
|
||||
});
|
||||
|
||||
const optionsIframe = createIframe();
|
||||
optionsIframe.src = chrome.runtime.getURL('options.html');
|
||||
|
||||
document.body.appendChild(contentIframe);
|
||||
document.body.appendChild(optionsIframe);
|
||||
|
||||
const toggleIframe = (iframe: HTMLIFrameElement) => {
|
||||
if (
|
||||
iframe.style.right === `-${IFRAME_WIDTH}` &&
|
||||
iframe.style.display !== 'none'
|
||||
) {
|
||||
iframe.style.right = '0px';
|
||||
} else if (iframe.style.right === '0px' && iframe.style.display !== 'none') {
|
||||
iframe.style.right = `-${IFRAME_WIDTH}`;
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = async () => {
|
||||
const store = await chrome.storage.local.get();
|
||||
if (isDefined(store.accessToken)) {
|
||||
toggleIframe(contentIframe);
|
||||
} else {
|
||||
toggleIframe(optionsIframe);
|
||||
}
|
||||
};
|
||||
|
||||
const authenticated = async () => {
|
||||
const store = await chrome.storage.local.get();
|
||||
if (isDefined(store.loginToken)) {
|
||||
contentIframe.src = `${
|
||||
import.meta.env.VITE_FRONT_BASE_URL
|
||||
}/verify?loginToken=${store.loginToken.token}`;
|
||||
contentIframe.onload = handleContentIframeLoadComplete;
|
||||
toggleIframe(contentIframe);
|
||||
} else {
|
||||
toggleIframe(optionsIframe);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
const changeSidePanelUrl = async (url: string) => {
|
||||
const { tab: activeTab } = await chrome.runtime.sendMessage({
|
||||
action: 'getActiveTab',
|
||||
});
|
||||
if (isDefined(activeTab) && isDefined(url)) {
|
||||
chrome.storage.local.set({ [`sidepanelUrl_${activeTab.id}`]: url });
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'changeSidepanelUrl',
|
||||
message: { url },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default changeSidePanelUrl;
|
||||
@@ -5,6 +5,7 @@ export const FIND_COMPANY = gql`
|
||||
companies(filter: $filter) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
linkedinLink {
|
||||
url
|
||||
|
||||
@@ -5,6 +5,7 @@ export const FIND_PERSON = gql`
|
||||
people(filter: $filter) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name {
|
||||
firstName
|
||||
lastName
|
||||
|
||||
@@ -26,7 +26,7 @@ export default defineManifest({
|
||||
action: {},
|
||||
|
||||
//TODO: change this to a documenation page
|
||||
options_page: 'options.html',
|
||||
options_page: 'sidepanel.html',
|
||||
|
||||
background: {
|
||||
service_worker: 'src/background/index.ts',
|
||||
@@ -43,12 +43,12 @@ export default defineManifest({
|
||||
|
||||
web_accessible_resources: [
|
||||
{
|
||||
resources: ['options.html'],
|
||||
resources: ['sidepanel.html', 'page-inaccessible.html'],
|
||||
matches: ['https://www.linkedin.com/*'],
|
||||
},
|
||||
],
|
||||
|
||||
permissions: ['activeTab', 'storage', 'identity'],
|
||||
permissions: ['activeTab', 'storage', 'identity', 'sidePanel'],
|
||||
|
||||
host_permissions: host_permissions,
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { Loader } from '@/ui/display/loader/components/Loader';
|
||||
import { MainButton } from '@/ui/input/button/MainButton';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.noisy};
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
width: 400px;
|
||||
height: 350px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledActionContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
width: 300px;
|
||||
`;
|
||||
|
||||
const StyledLabel = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(1)};
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const Options = () => {
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [serverBaseUrl, setServerBaseUrl] = useState(
|
||||
import.meta.env.VITE_SERVER_BASE_URL,
|
||||
);
|
||||
const authenticate = () => {
|
||||
setIsAuthenticating(true);
|
||||
setError('');
|
||||
chrome.runtime.sendMessage({ action: 'CONNECT' }, ({ status, message }) => {
|
||||
if (status === true) {
|
||||
setIsAuthenticated(true);
|
||||
setIsAuthenticating(false);
|
||||
chrome.storage.local.set({ isAuthenticated: true });
|
||||
} else {
|
||||
setError(message);
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const getState = async () => {
|
||||
const store = await chrome.storage.local.get();
|
||||
if (store.serverBaseUrl !== '') {
|
||||
setServerBaseUrl(store.serverBaseUrl);
|
||||
} else {
|
||||
setServerBaseUrl(import.meta.env.VITE_SERVER_BASE_URL);
|
||||
}
|
||||
|
||||
if (store.isAuthenticated === true) setIsAuthenticated(true);
|
||||
};
|
||||
void getState();
|
||||
}, []);
|
||||
|
||||
const handleBaseUrlChange = (value: string) => {
|
||||
setServerBaseUrl(value);
|
||||
setError('');
|
||||
chrome.storage.local.set({ serverBaseUrl: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<StyledContainer>
|
||||
<img src="/logo/32-32.svg" alt="twenty-logo" height={64} width={64} />
|
||||
<StyledActionContainer>
|
||||
<TextInput
|
||||
label="Server URL"
|
||||
value={serverBaseUrl}
|
||||
onChange={handleBaseUrlChange}
|
||||
placeholder="My base server URL"
|
||||
error={error}
|
||||
fullWidth
|
||||
/>
|
||||
{isAuthenticating ? (
|
||||
<Loader />
|
||||
) : isAuthenticated ? (
|
||||
<StyledLabel>Connected!</StyledLabel>
|
||||
) : (
|
||||
<>
|
||||
<MainButton
|
||||
title="Connect your account"
|
||||
onClick={() => authenticate()}
|
||||
fullWidth
|
||||
/>
|
||||
<MainButton
|
||||
title="Sign up"
|
||||
variant="secondary"
|
||||
onClick={() => window.open(`${serverBaseUrl}`, '_blank')}
|
||||
fullWidth
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</StyledActionContainer>
|
||||
</StyledContainer>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Options;
|
||||
@@ -0,0 +1,64 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { MainButton } from '@/ui/input/button/MainButton';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
width: 400px;
|
||||
height: 350px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: ${({ theme }) => theme.spacing(8)};
|
||||
`;
|
||||
|
||||
const StyledTextContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledLargeText = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.lg};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
`;
|
||||
|
||||
const StyledMediumText = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
`;
|
||||
|
||||
const PageInaccessible = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<StyledContainer>
|
||||
<img src="/logo/32-32.svg" alt="twenty-logo" height={40} width={40} />
|
||||
<StyledTextContainer>
|
||||
<StyledLargeText>
|
||||
Extension not available on the website
|
||||
</StyledLargeText>
|
||||
<StyledMediumText>
|
||||
Open LinkedIn to use the extension
|
||||
</StyledMediumText>
|
||||
</StyledTextContainer>
|
||||
<MainButton
|
||||
title="Go to LinkedIn"
|
||||
onClick={() => window.open('https://www.linkedin.com/')}
|
||||
/>
|
||||
</StyledContainer>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageInaccessible;
|
||||
169
packages/twenty-chrome-extension/src/options/Sidepanel.tsx
Normal file
169
packages/twenty-chrome-extension/src/options/Sidepanel.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { Loader } from '@/ui/display/loader/components/Loader';
|
||||
import { MainButton } from '@/ui/input/button/MainButton';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
const StyledIframe = styled.iframe`
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
border: none;
|
||||
`;
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
width: 400px;
|
||||
height: 350px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: ${({ theme }) => theme.spacing(8)};
|
||||
`;
|
||||
|
||||
const StyledActionContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
width: 300px;
|
||||
`;
|
||||
|
||||
const Sidepanel = () => {
|
||||
const [isAuthenticating, setIsAuthenticating] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [iframeSrc, setIframeSrc] = useState(
|
||||
import.meta.env.VITE_FRONT_BASE_URL,
|
||||
);
|
||||
const [error, setError] = useState('');
|
||||
const [serverBaseUrl, setServerBaseUrl] = useState('');
|
||||
const authenticate = () => {
|
||||
setIsAuthenticating(true);
|
||||
setError('');
|
||||
chrome.runtime.sendMessage(
|
||||
{ action: 'launchOAuth' },
|
||||
({ status, message }) => {
|
||||
if (status === true) {
|
||||
setIsAuthenticated(true);
|
||||
setIsAuthenticating(false);
|
||||
chrome.storage.local.set({ isAuthenticated: true });
|
||||
} else {
|
||||
setError(message);
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const getState = async () => {
|
||||
const store = await chrome.storage.local.get();
|
||||
if (isDefined(store.serverBaseUrl)) {
|
||||
setServerBaseUrl(store.serverBaseUrl);
|
||||
} else {
|
||||
setServerBaseUrl(import.meta.env.VITE_SERVER_BASE_URL);
|
||||
}
|
||||
|
||||
if (store.isAuthenticated === true) setIsAuthenticated(true);
|
||||
const { tab: activeTab } = await chrome.runtime.sendMessage({
|
||||
action: 'getActiveTab',
|
||||
});
|
||||
|
||||
if (
|
||||
isDefined(activeTab) &&
|
||||
isDefined(store[`sidepanelUrl_${activeTab.id}`])
|
||||
) {
|
||||
const url = store[`sidepanelUrl_${activeTab.id}`];
|
||||
setIframeSrc(url);
|
||||
}
|
||||
};
|
||||
void getState();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBrowserEvents = ({ action }: { action: string }) => {
|
||||
if (action === 'changeSidepanelUrl') {
|
||||
setIframeSrc('');
|
||||
}
|
||||
};
|
||||
chrome.runtime.onMessage.addListener(handleBrowserEvents);
|
||||
|
||||
return () => {
|
||||
chrome.runtime.onMessage.removeListener(handleBrowserEvents);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const getIframeState = async () => {
|
||||
const store = await chrome.storage.local.get();
|
||||
const { tab: activeTab } = await chrome.runtime.sendMessage({
|
||||
action: 'getActiveTab',
|
||||
});
|
||||
|
||||
if (
|
||||
isDefined(activeTab) &&
|
||||
isDefined(store[`sidepanelUrl_${activeTab.id}`])
|
||||
) {
|
||||
const url = store[`sidepanelUrl_${activeTab.id}`];
|
||||
setIframeSrc(url);
|
||||
}
|
||||
};
|
||||
void getIframeState();
|
||||
}, [iframeSrc]);
|
||||
|
||||
const handleBaseUrlChange = (value: string) => {
|
||||
setServerBaseUrl(value);
|
||||
setError('');
|
||||
chrome.storage.local.set({ serverBaseUrl: value });
|
||||
};
|
||||
|
||||
return isAuthenticated ? (
|
||||
<StyledIframe title="twenty-website" src={iframeSrc}></StyledIframe>
|
||||
) : (
|
||||
<StyledWrapper>
|
||||
<StyledContainer>
|
||||
<img src="/logo/32-32.svg" alt="twenty-logo" height={40} width={40} />
|
||||
{isAuthenticating ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<StyledActionContainer>
|
||||
<TextInput
|
||||
label="Server URL"
|
||||
value={serverBaseUrl}
|
||||
onChange={handleBaseUrlChange}
|
||||
placeholder="My base server URL"
|
||||
error={error}
|
||||
fullWidth
|
||||
/>
|
||||
<MainButton
|
||||
title="Connect your account"
|
||||
onClick={() => authenticate()}
|
||||
fullWidth
|
||||
/>
|
||||
<MainButton
|
||||
title="Sign up"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
window.open(`${import.meta.env.VITE_FRONT_BASE_URL}`, '_blank')
|
||||
}
|
||||
fullWidth
|
||||
/>
|
||||
</StyledActionContainer>
|
||||
)}
|
||||
</StyledContainer>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidepanel;
|
||||
@@ -3,14 +3,14 @@ import ReactDOM from 'react-dom/client';
|
||||
|
||||
import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider';
|
||||
import { ThemeType } from '@/ui/theme/constants/ThemeLight';
|
||||
import Options from '~/options/Options';
|
||||
import Sidepanel from '~/options/Sidepanel';
|
||||
|
||||
import '~/index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render(
|
||||
<AppThemeProvider>
|
||||
<React.StrictMode>
|
||||
<Options />
|
||||
<Sidepanel />
|
||||
</React.StrictMode>
|
||||
</AppThemeProvider>,
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ const StyledButton = styled.button<
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return theme.background.radialGradient;
|
||||
return theme.background.primaryInverted;
|
||||
case 'secondary':
|
||||
return theme.background.primary;
|
||||
default:
|
||||
@@ -37,7 +37,7 @@ const StyledButton = styled.button<
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return theme.background.transparent.light;
|
||||
return theme.background.transparent.strong;
|
||||
case 'secondary':
|
||||
return theme.border.color.medium;
|
||||
default:
|
||||
@@ -59,7 +59,7 @@ const StyledButton = styled.button<
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return theme.grayScale.gray0;
|
||||
return theme.font.color.inverted;
|
||||
case 'secondary':
|
||||
return theme.font.color.primary;
|
||||
default:
|
||||
@@ -88,7 +88,7 @@ const StyledButton = styled.button<
|
||||
default:
|
||||
return `
|
||||
&:hover {
|
||||
background: ${theme.background.radialGradientHover}};
|
||||
background: ${theme.background.primaryInvertedHover}};
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -23,4 +23,6 @@ export const BACKGROUND_DARK = {
|
||||
overlay: RGBA(GRAY_SCALE.gray80, 0.8),
|
||||
radialGradient: `radial-gradient(50% 62.62% at 50% 0%, #505050 0%, ${GRAY_SCALE.gray60} 100%)`,
|
||||
radialGradientHover: `radial-gradient(76.32% 95.59% at 50% 0%, #505050 0%, ${GRAY_SCALE.gray60} 100%)`,
|
||||
primaryInverted: GRAY_SCALE.gray20,
|
||||
primaryInvertedHover: GRAY_SCALE.gray15,
|
||||
};
|
||||
|
||||
@@ -23,4 +23,6 @@ export const BACKGROUND_LIGHT = {
|
||||
overlay: RGBA(GRAY_SCALE.gray80, 0.8),
|
||||
radialGradient: `radial-gradient(50% 62.62% at 50% 0%, #505050 0%, ${GRAY_SCALE.gray60} 100%)`,
|
||||
radialGradientHover: `radial-gradient(76.32% 95.59% at 50% 0%, #505050 0%, ${GRAY_SCALE.gray60} 100%)`,
|
||||
primaryInverted: GRAY_SCALE.gray60,
|
||||
primaryInvertedHover: GRAY_SCALE.gray55,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider';
|
||||
import { ThemeType } from '@/ui/theme/constants/ThemeLight';
|
||||
import PageInaccessible from '~/options/PageInaccessible';
|
||||
|
||||
import '~/index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render(
|
||||
<AppThemeProvider>
|
||||
<React.StrictMode>
|
||||
<PageInaccessible />
|
||||
</React.StrictMode>
|
||||
</AppThemeProvider>,
|
||||
);
|
||||
|
||||
declare module '@emotion/react' {
|
||||
export interface Theme extends ThemeType {}
|
||||
}
|
||||
@@ -38,6 +38,8 @@ export default defineConfig(() => {
|
||||
hmr: { port: 3002 },
|
||||
},
|
||||
|
||||
cacheDir: './node_modules/.vite',
|
||||
|
||||
plugins: [viteManifestHack, crx({ manifest }), react(), tsconfigPaths()],
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user