Closes #2413 - Building a chrome extension for twenty to store person/company data into a workspace. (#3430)

* build: create a new vite project for chrome extension

* feat: configure theme per the frontend codebase for chrome extension

* feat: inject the add to twenty button into linkedin profile page

* feat: create the api key form ui and render it on the options page

* feat: inject the add to twenty button into linkedin company page

* feat: scrape required data from both the user profile and the company profile

* refactor: move modules into options because it is the only page using react for now

* fix: show add to twenty button without having to reload the single page application

* fix: extract domain of the business website instead of scrapping the industry type

* feat: store api key to local storage and open options page when trying to store data without setting a key

* feat: send data to the backend upon click and store it to the database

* fix: open options page upon clicking the extension icon

* fix: update terminology from user to person to match the codebase convention

* fix: adopt chrome extension to monorepo approach using nx and get the development server working

* fix: update vite config for build command to work per the requirement

* feat: add instructions in the readme file to install the extension for local testing

* fix: move server base url to a dotenv file and replace the hard-coded url

* feat: permit user to configure a custom route for the server from the options page

* fix: fetch api key and route from local storage and display on options page to inform users of their choices

* fix: move front base url to dotenv and replace the hard-coded url

* fix: remove the trailing slash from person and company linkedin username

* fix: improve code commenting to explain implementation somewhat better

* ci: introduce a workflow to build chrome extension to ensure it can be published

* fix: format files to display code in a consistent manner per the prettier configuration in codebase

* fix: improve the commenting significantly to explain important and hard-to-understand parts of the code

* fix: remove unused permissions from the manifest file for publishing to the chrome web store

* Add nx

* Fix vale

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Abdullah 2024-02-12 16:30:23 +05:00 committed by GitHub
parent a15128df36
commit 1265dc74d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 2103 additions and 10 deletions

View File

@ -0,0 +1,66 @@
name: CI Chrome Extension
on:
push:
branches:
- main
pull_request:
jobs:
chrome-extension-yarn-install:
runs-on: ci-8-cores
env:
VITE_SERVER_BASE_URL: http://localhost:3000
VITE_FRONT_BASE_URL: http://localhost:3001
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "18"
- name: Cache chrome extension node modules
uses: actions/cache@v3
with:
path: packages/twenty-chrome-extension/node_modules
key: chrome-extension-node_modules-${{hashFiles('yarn.lock')}}
restore-keys: chrome-extension-node_modules-
- name: Cache root node modules
uses: actions/cache@v3
with:
path: node_modules
key: root-node_modules-${{hashFiles('yarn.lock')}}
restore-keys: root-node_modules-
- name: Chrome Extension / Install Dependencies
run: yarn
chrome-extension-build:
needs: chrome-extension-yarn-install
runs-on: ubuntu-latest
env:
VITE_SERVER_BASE_URL: http://localhost:3000
VITE_FRONT_BASE_URL: http://localhost:3001
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "18"
- name: Cache chrome extension node modules
uses: actions/cache@v3
with:
path: packages/twenty-chrome-extension/node_modules
key: chrome-extension-node_modules-${{hashFiles('yarn.lock')}}
restore-keys: chrome-extension-node_modules-
- name: Cache root node modules
uses: actions/cache@v3
with:
path: node_modules
key: root-node_modules-${{hashFiles('yarn.lock')}}
restore-keys: root-node_modules-
- name: Chrome Extension / Run build
run: yarn nx build twenty-chrome-extension

View File

@ -4,6 +4,10 @@
"name": "ROOT",
"path": "../"
},
{
"name": "packages/twenty-chrome-extension",
"path": "../packages/twenty-chrome-extension"
},
{
"name": "packages/twenty-docker",
"path": "../packages/twenty-docker"

View File

@ -283,6 +283,7 @@
"version": "0.2.1",
"workspaces": {
"packages": [
"packages/twenty-chrome-extension",
"packages/twenty-front",
"packages/twenty-docs",
"packages/twenty-server",

View File

@ -0,0 +1,2 @@
VITE_SERVER_BASE_URL=http://localhost:3000
VITE_FRONT_BASE_URL=http://localhost:3001

View File

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,51 @@
# Twenty Chrome Extension.
This extension allows you to save `company` and `people` information to your twenty workspace directly from LinkedIn.
To install the extension in development mode with hmr (hot module reload), follow these steps.
- STEP 1: Clone the repository and run `yarn install` in the root directory.
- STEP 2: Once the dependencies installation succeeds, create a file with env variables by executing the following command in the root directory.
```
cp ./packages/twenty-chrome-extension/.env.example ./packages/twenty-chrome-extension/.env
```
- STEP 3: Now, execute the following command in the root directory to start up the development server on Port 3002. This will create a `dist` folder in `twenty-chrome-extension`.
```
yarn nx start twenty-chrome-extension
```
- STEP 4: Open Google Chrome and head to the extensions page by typing `chrome://extensions` in the address bar.
<p align="center">
<img src="../twenty-chrome-extension/public/readme-images/01-img-one.png" width="600" />
</p>
- STEP 5: Turn on the `Developer mode` from the top-right corner and click `Load unpacked`.
<p align="center">
<img src="../twenty-chrome-extension/public/readme-images/02-img-two.png" width="600" />
</p>
- STEP 6: Select the `dist` folder from `twenty-chrome-extension`.
<p align="center">
<img src="../twenty-chrome-extension/public/readme-images/03-img-three.png" width="600" />
</p>
- STEP 7: This opens up the `options` page, where you must enter your API key.
<p align="center">
<img src="../twenty-chrome-extension/public/readme-images/04-img-four.png" width="600" />
</p>
- STEP 8: Reload any LinkedIn page that you opened before installing the extension for seamless experience.
- STEP 9: Visit any individual or company profile on LinkedIn and click the `Add to Twenty` button to test.
<p align="center">
<img src="../twenty-chrome-extension/public/readme-images/05-img-five.png" width="600" />
</p>
To install the extension in production mode without hmr (hot module reload), replace the command in STEP THREE with `yarn nx build twenty-chrome-extension`.

View File

@ -0,0 +1,12 @@
<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>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/options/index.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,18 @@
{
"name": "twenty-chrome-extension",
"description": "",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"nx": "NX_DEFAULT_PROJECT=twenty-chrome-extension node ../../node_modules/nx/bin/nx.js",
"start": "vite",
"build": "tsc && vite build"
},
"dependencies": {
"@types/chrome": "^0.0.256"
},
"devDependencies": {
"@crxjs/vite-plugin": "^1.0.14"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,62 @@
import { openOptionsPage } from './utils/openOptionsPage';
console.log('Background Script Works');
// Open options page programmatically in a new tab.
chrome.runtime.onInstalled.addListener(function (details) {
if (details.reason === 'install') {
openOptionsPage();
}
});
// Open options page when extension icon is clicked.
chrome.action.onClicked.addListener(function () {
openOptionsPage();
});
// 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 'getActiveTabUrl': // e.g. "https://linkedin.com/company/twenty/"
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs && tabs[0]) {
const activeTabUrl: string | undefined = tabs[0].url;
sendResponse({ url: activeTabUrl });
}
});
break;
case 'openOptionsPage':
openOptionsPage();
break;
default:
break;
}
return true;
});
// Keep track of the tabs in which the "Add to Twenty" button has already been injected.
// Could be that the content script is executed at "https://linkedin.com/feed/", but is needed at "https://linkedin.com/in/mabdullahabaid/".
// However, since Linkedin is a SPA, the script would not be re-executed when you navigate to "https://linkedin.com/in/mabdullahabaid/" from a user action.
// Therefore, this tracks if the user is on desired route and then re-executes the content script to create the "Add to Twenty" button.
// We use a "Set" to keep track of tab ids because it could be that the "Add to Twenty" button was created at "https://linkedin/com/company/twenty".
// However, when we change to about on the company page, the url becomes "https://www.linkedin.com/company/twenty/about/" and the button is created again.
// This creates a duplicate button, which we want to avoid. So, we instruct the extension to only create the button once for any of the following urls.
// "https://www.linkedin.com/company/twenty/" "https://www.linkedin.com/company/twenty/about/" "https://www.linkedin.com/company/twenty/people/".
const injectedTabs: Set<number> = new Set();
chrome.tabs.onUpdated.addListener((tabId, changeInfo, 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 (isDesiredRoute && !injectedTabs.has(tabId)) {
chrome.tabs.sendMessage(tabId, { action: 'executeContentScript' });
injectedTabs.add(tabId);
} else if (!isDesiredRoute) {
injectedTabs.delete(tabId); // Clear entry if navigated away from LinkedIn company page.
}
}
});

View File

@ -0,0 +1,5 @@
const openOptionsPage = () => {
chrome.runtime.openOptionsPage();
};
export { openOptionsPage };

View File

@ -0,0 +1,57 @@
function createNewButton(
text: string,
onClickHandler: () => void,
): HTMLButtonElement {
const newButton: HTMLButtonElement = document.createElement('button');
newButton.textContent = text;
// Write universal styles for the button
const buttonStyles = {
border: '1px solid black',
borderRadius: '20px',
backgroundColor: 'black',
color: 'white',
fontSize: '1.5rem',
fontWeight: '600',
padding: '0.45em 1em',
width: '15rem',
height: '32px',
};
// Apply common styles to the button.
Object.assign(newButton.style, buttonStyles);
// Apply common styles to specifc states of a button.
newButton.addEventListener('mouseenter', () => {
const hoverStyles = {
backgroundColor: '#5e5e5e',
borderColor: '#5e5e5e',
};
Object.assign(newButton.style, hoverStyles);
});
newButton.addEventListener('mouseleave', () => {
Object.assign(newButton.style, buttonStyles);
});
// Handle the click event.
newButton.addEventListener('click', async () => {
const { apiKey } = await chrome.storage.local.get('apiKey');
// If an api key is not set, the options page opens up to allow the user to configure an api key.
if (!apiKey) {
chrome.runtime.sendMessage({ action: 'openOptionsPage' });
return;
}
// Update content during the resolution of the request.
newButton.textContent = 'Saving...';
// Call the provided onClickHandler function to handle button click logic
onClickHandler();
});
return newButton;
}
export default createNewButton;

View File

@ -0,0 +1,106 @@
import handleQueryParams from '../utils/handleQueryParams';
import requestDb from '../utils/requestDb';
import createNewButton from './createButton';
import extractCompanyLinkedinLink from './utils/extractCompanyLinkedinLink';
import extractDomain from './utils/extractDomain';
function insertButtonForCompany(): void {
// Select the element in which to create the button.
const parentDiv: HTMLDivElement | null = document.querySelector(
'.org-top-card-primary-actions__inner',
);
// Create the button with desired callback funciton to execute upon click.
if (parentDiv) {
const newButtonCompany: HTMLButtonElement = createNewButton(
'Add to Twenty',
async () => {
// Extract company-specific data from the DOM
const companyNameElement = document.querySelector(
'.org-top-card-summary__title',
);
const domainNameElement = document.querySelector(
'.org-top-card-primary-actions__inner a',
);
const addressElement = document.querySelectorAll(
'.org-top-card-summary-info-list__info-item',
)[1];
const employeesNumberElement = document.querySelectorAll(
'.org-top-card-summary-info-list__info-item',
)[3];
// Get the text content or other necessary data from the DOM elements
const companyName = companyNameElement
? companyNameElement.getAttribute('title')
: '';
const domainName = extractDomain(
domainNameElement && domainNameElement.getAttribute('href'),
);
const address = addressElement
? addressElement.textContent?.trim().replace(/\s+/g, ' ')
: '';
const employees = employeesNumberElement
? Number(
employeesNumberElement.textContent
?.trim()
.replace(/\s+/g, ' ')
.split('-')[0],
)
: 0;
// Prepare company data to send to the backend
const companyData = {
name: companyName,
domainName: domainName,
address: address,
employees: employees,
linkedinLink: { url: '', label: '' },
};
// Extract active tab url using chrome API - an event is triggered here and is caught by background script.
const { url: activeTabUrl } = await chrome.runtime.sendMessage({
action: 'getActiveTabUrl',
});
// Convert URLs like https://www.linkedin.com/company/twenty/about/ to https://www.linkedin.com/company/twenty
const companyURL = extractCompanyLinkedinLink(activeTabUrl);
companyData.linkedinLink = { url: companyURL, label: companyURL };
const query = `mutation CreateOneCompany { createCompany(data:{${handleQueryParams(
companyData,
)}}) {id} }`;
const response = await requestDb(query);
if (response.data) {
newButtonCompany.textContent = 'Saved';
newButtonCompany.setAttribute('disabled', 'true');
// Button specific styles once the button is unclickable after successfully sending data to server.
newButtonCompany.addEventListener('mouseenter', () => {
const hoverStyles = {
backgroundColor: 'black',
borderColor: 'black',
cursor: 'default',
};
Object.assign(newButtonCompany.style, hoverStyles);
});
} else {
newButtonCompany.textContent = 'Try Again';
}
},
);
// Include the button in the DOM.
parentDiv.prepend(newButtonCompany);
// Write button specific styles here - common ones can be found in createButton.ts.
const buttonSpecificStyles = {
alignSelf: 'end',
};
Object.assign(newButtonCompany.style, buttonSpecificStyles);
}
}
export default insertButtonForCompany;

View File

@ -0,0 +1,119 @@
import handleQueryParams from '../utils/handleQueryParams';
import requestDb from '../utils/requestDb';
import createNewButton from './createButton';
import extractFirstAndLastName from './utils/extractFirstAndLastName';
function insertButtonForPerson(): void {
// Select the element in which to create the button.
const parentDiv: HTMLDivElement | null = document.querySelector(
'.pv-top-card-v2-ctas',
);
// Create the button with desired callback funciton to execute upon click.
if (parentDiv) {
const newButtonPerson: HTMLButtonElement = createNewButton(
'Add to Twenty',
async () => {
// Extract person-specific data from the DOM.
const personNameElement = document.querySelector(
'.text-heading-xlarge',
);
const separatorElement = document.querySelector(
'.pv-text-details__separator',
);
const personCityElement = separatorElement?.previousElementSibling;
const profilePictureElement = document.querySelector(
'.pv-top-card-profile-picture__image',
);
const firstListItem = document.querySelector(
'div[data-view-name="profile-component-entity"]',
);
const secondDivElement =
firstListItem?.querySelector('div:nth-child(2)');
const ariaHiddenSpan = secondDivElement?.querySelector(
'span[aria-hidden="true"]',
);
// Get the text content or other necessary data from the DOM elements.
const personName = personNameElement
? personNameElement.textContent
: '';
const personCity = personCityElement
? personCityElement.textContent
?.trim()
.replace(/\s+/g, ' ')
.split(',')[0]
: '';
const profilePicture = profilePictureElement
? profilePictureElement?.getAttribute('src')
: '';
const jobTitle = ariaHiddenSpan
? ariaHiddenSpan.textContent?.trim()
: '';
const { firstName, lastName } = extractFirstAndLastName(
String(personName),
);
// Prepare person data to send to the backend.
const personData = {
name: { firstName, lastName },
city: personCity,
avatarUrl: profilePicture,
jobTitle,
linkedinLink: { url: '', label: '' },
};
// Extract active tab url using chrome API - an event is triggered here and is caught by background script.
let { url: activeTabUrl } = await chrome.runtime.sendMessage({
action: 'getActiveTabUrl',
});
// Remove last slash from the URL for consistency when saving usernames.
if (activeTabUrl.endsWith('/')) {
activeTabUrl = activeTabUrl.slice(0, -1);
}
personData.linkedinLink = { url: activeTabUrl, label: activeTabUrl };
const query = `mutation CreateOnePerson { createPerson(data:{${handleQueryParams(
personData,
)}}) {id} }`;
const response = await requestDb(query);
if (response.data) {
newButtonPerson.textContent = 'Saved';
newButtonPerson.setAttribute('disabled', 'true');
// Button specific styles once the button is unclickable after successfully sending data to server.
newButtonPerson.addEventListener('mouseenter', () => {
const hoverStyles = {
backgroundColor: 'black',
borderColor: 'black',
cursor: 'default',
};
Object.assign(newButtonPerson.style, hoverStyles);
});
} else {
newButtonPerson.textContent = 'Try Again';
}
},
);
// Include the button in the DOM.
parentDiv.prepend(newButtonPerson);
// Write button specific styles here - common ones can be found in createButton.ts.
const buttonSpecificStyles = {
marginRight: '0.5em',
};
Object.assign(newButtonPerson.style, buttonSpecificStyles);
}
}
export default insertButtonForPerson;

View File

@ -0,0 +1,20 @@
import insertButtonForPerson from './extractPersonProfile';
import insertButtonForCompany from './extractCompanyProfile';
// 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/
insertButtonForCompany();
insertButtonForPerson();
// The content script gets executed upon load, so the the content script is executed when a user visits https://www.linkedin.com/feed/.
// However, there would never be another reload in a single page application unless triggered manually.
// Therefore, if the user navigates to a person or a company page, we must manually re-execute the content script to create the "Add to Twenty" button.
// e.g. create "Add to Twenty" button when a user navigates to https://www.linkedin.com/in/mabdullahabaid/ from https://www.linkedin.com/feed/
chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
if (message.action === 'executeContentScript') {
insertButtonForCompany();
insertButtonForPerson();
}
sendResponse('Executing!');
});

View File

@ -0,0 +1,19 @@
// Extract "https://www.linkedin.com/company/twenty/" from any of the following urls, which the user can visit while on the company page.
// "https://www.linkedin.com/company/twenty/" "https://www.linkedin.com/company/twenty/about/" "https://www.linkedin.com/company/twenty/people/".
const extractCompanyLinkedinLink = (activeTabUrl: string) => {
// Regular expression to match the company ID
const regex = /\/company\/([^/]*)/;
// Extract the company ID using the regex
const match = activeTabUrl.match(regex);
if (match && match[1]) {
const companyID = match[1];
const cleanCompanyURL = `https://www.linkedin.com/company/${companyID}`;
return cleanCompanyURL;
}
return '';
};
export default extractCompanyLinkedinLink;

View File

@ -0,0 +1,15 @@
function extractDomain(url: string | null) {
if (!url) return '';
const hostname = new URL(url).hostname;
let domain = hostname.replace('www.', '');
const parts = domain.split('.');
if (parts.length > 2) {
domain = parts.slice(1).join('.');
}
return domain;
}
export default extractDomain;

View File

@ -0,0 +1,9 @@
// Separate first name and last name from a full name.
const extractFirstAndLastName = (fullName: string) => {
const spaceIndex = fullName.lastIndexOf(' ');
const firstName = fullName.substring(0, spaceIndex);
const lastName = fullName.substring(spaceIndex + 1);
return { firstName, lastName };
};
export default extractFirstAndLastName;

View File

@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;

View File

@ -0,0 +1,11 @@
* {
margin: 0;
box-sizing: border-box;
font-family: 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html {
font-size: 13px;
}

View File

@ -0,0 +1,36 @@
import { defineManifest } from '@crxjs/vite-plugin';
import packageData from '../package.json';
export default defineManifest({
manifest_version: 3,
name: 'Twenty',
description: packageData.description,
version: packageData.version,
icons: {
16: 'logo/32-32.png',
32: 'logo/32-32.png',
48: 'logo/32-32.png',
},
action: {},
options_page: 'options.html',
background: {
service_worker: 'src/background/index.ts',
type: 'module',
},
content_scripts: [
{
matches: ['https://www.linkedin.com/*'],
js: ['src/contentScript/index.ts'],
run_at: 'document_end',
},
],
permissions: ['activeTab', 'storage'],
host_permissions: ['https://www.linkedin.com/*'],
});

View File

@ -0,0 +1,21 @@
import styled from '@emotion/styled';
import { ApiKeyForm } from './modules/api-key/components/ApiKeyForm';
const StyledContainer = styled.div`
background: ${({ theme }) => theme.background.noisy};
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
`;
const Options = () => {
return (
<StyledContainer>
<ApiKeyForm />
</StyledContainer>
);
};
export default Options;

View File

@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './Options';
import '../index.css';
import { AppThemeProvider } from './modules/ui/theme/components/AppThemeProvider';
import { ThemeType } from './modules/ui/theme/constants/theme';
ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render(
<AppThemeProvider>
<React.StrictMode>
<App />
</React.StrictMode>
</AppThemeProvider>,
);
declare module '@emotion/react' {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Theme extends ThemeType {}
}

View File

@ -0,0 +1,146 @@
import styled from '@emotion/styled';
import { H2Title } from '../../ui/display/typography/components/H2Title';
import { useEffect, useState } from 'react';
import { TextInput } from '../../ui/input/components/TextInput';
import { Button } from '../../ui/input/button/Button';
import { Toggle } from '../../ui/input/components/Toggle';
const StyledContainer = styled.div<{ isToggleOn: boolean }>`
width: 400px;
margin: 0 auto;
background-color: ${({ theme }) => theme.background.primary};
padding: ${({ theme }) => theme.spacing(10)};
overflow: hidden;
transition: height 0.3s ease;
height: ${({ isToggleOn }) => (isToggleOn ? '450px' : '390px')};
max-height: ${({ isToggleOn }) => (isToggleOn ? '450px' : '390px')};
`;
const StyledHeader = styled.header`
text-align: center;
margin-bottom: ${({ theme }) => theme.spacing(8)};
`;
const StyledImg = styled.img``;
const StyledMain = styled.main`
margin-bottom: ${({ theme }) => theme.spacing(8)};
`;
const StyledFooter = styled.footer`
display: flex;
`;
const StyledTitleContainer = styled.div`
flex: 0 0 80%;
`;
const StyledToggleContainer = styled.div`
flex: 0 0 20%;
display: flex;
justify-content: flex-end;
`;
const StyledSection = styled.div<{ showSection: boolean }>`
transition:
max-height 0.3s ease,
opacity 0.3s ease;
overflow: hidden;
max-height: ${({ showSection }) => (showSection ? '200px' : '0')};
`;
export const ApiKeyForm = () => {
const [apiKey, setApiKey] = useState('');
const [route, setRoute] = useState('');
const [showSection, setShowSection] = useState(false);
useEffect(() => {
const getState = async () => {
const localStorage = await chrome.storage.local.get();
if (localStorage.apiKey) {
setApiKey(localStorage.apiKey);
}
if (localStorage.serverBaseUrl) {
setRoute(localStorage.serverBaseUrl);
}
};
void getState();
}, []);
useEffect(() => {
chrome.storage.local.set({ apiKey });
}, [apiKey]);
useEffect(() => {
chrome.storage.local.set({ serverBaseUrl: route });
}, [route]);
const handleGenerateClick = () => {
window.open(
`${import.meta.env.VITE_FRONT_BASE_URL}/settings/developers/api-keys`,
);
};
const handleToggle = () => {
setShowSection(!showSection);
};
return (
<StyledContainer isToggleOn={showSection}>
<StyledHeader>
<StyledImg src="/logo/32-32.png" alt="Twenty Logo" />
</StyledHeader>
<StyledMain>
<H2Title
title="Connect your account"
description="Input your key to link the extension to your workspace."
/>
<TextInput
label="Api key"
value={apiKey}
onChange={setApiKey}
placeholder="My API key"
/>
<Button
title="Generate a key"
fullWidth={false}
variant="primary"
accent="default"
size="small"
position="standalone"
soon={false}
disabled={false}
onClick={handleGenerateClick}
/>
</StyledMain>
<StyledFooter>
<StyledTitleContainer>
<H2Title
title="Custom route"
description="For developers interested in self-hosting or local testing of the extension."
/>
</StyledTitleContainer>
<StyledToggleContainer>
<Toggle value={showSection} onChange={handleToggle} />
</StyledToggleContainer>
</StyledFooter>
<StyledSection showSection={showSection}>
{showSection && (
<TextInput
label="Route"
value={route}
onChange={setRoute}
placeholder="My Route"
/>
)}
</StyledSection>
</StyledContainer>
);
};

View File

@ -0,0 +1,44 @@
import styled from '@emotion/styled';
type H2TitleProps = {
title: string;
description?: string;
addornment?: React.ReactNode;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledTitleContainer = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
`;
const StyledTitle = styled.h2`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin: 0;
`;
const StyledDescription = styled.h3`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin: 0;
margin-top: ${({ theme }) => theme.spacing(3)};
`;
export const H2Title = ({ title, description, addornment }: H2TitleProps) => (
<StyledContainer>
<StyledTitleContainer>
<StyledTitle>{title}</StyledTitle>
{addornment}
</StyledTitleContainer>
{description && <StyledDescription>{description}</StyledDescription>}
</StyledContainer>
);

View File

@ -0,0 +1,85 @@
import React from 'react';
import styled from '@emotion/styled';
export type ButtonSize = 'medium' | 'small';
export type ButtonPosition = 'standalone' | 'left' | 'middle' | 'right';
export type ButtonVariant = 'primary' | 'secondary' | 'tertiary';
export type ButtonAccent = 'default' | 'blue' | 'danger';
export type ButtonProps = {
className?: string;
Icon?: React.ReactNode;
title?: string;
fullWidth?: boolean;
variant?: ButtonVariant;
size?: ButtonSize;
position?: ButtonPosition;
accent?: ButtonAccent;
soon?: boolean;
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
};
const StyledButton = styled.button<ButtonProps>`
border: 1px solid transparent;
border-radius: ${({ position, theme }) => {
switch (position) {
case 'left':
return `${theme.border.radius.sm} 0px 0px ${theme.border.radius.sm}`;
case 'right':
return `0px ${theme.border.radius.sm} ${theme.border.radius.sm} 0px`;
case 'middle':
return '0px';
case 'standalone':
return theme.border.radius.sm;
default:
return theme.border.radius.sm;
}
}};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: inline-flex;
align-items: center;
font-family: ${({ theme }) => theme.font.family};
font-weight: 500;
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ size }) => (size === 'small' ? '24px' : '32px')};
padding: 0 ${({ theme }) => theme.spacing(2)};
white-space: nowrap;
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
&:hover {
border-color: transparent;
filter: brightness(0.9);
}
&:focus {
outline: none;
}
`;
export const Button = ({
className,
Icon,
title,
fullWidth = false,
variant = 'primary',
size = 'medium',
position = 'standalone',
soon = false,
disabled = false,
onClick,
}: ButtonProps) => (
<StyledButton
fullWidth={fullWidth}
variant={variant}
size={size}
position={position}
disabled={soon || disabled}
className={className}
onClick={onClick}
>
{Icon && Icon}
{title}
{soon && 'Soon'}
</StyledButton>
);

View File

@ -0,0 +1,85 @@
import React from 'react';
import styled from '@emotion/styled';
interface TextInputProps {
label?: string;
value: string;
onChange: (value: string) => void;
fullWidth?: boolean;
error?: string;
placeholder?: string;
icon?: React.ReactNode;
}
const StyledContainer = styled.div<{ fullWidth?: boolean }>`
display: flex;
flex-direction: column;
width: ${({ fullWidth }) => (fullWidth ? `100%` : 'auto')};
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
text-transform: uppercase;
`;
const StyledInputContainer = styled.div`
display: flex;
align-items: center;
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px;
`;
const StyledIcon = styled.span`
margin-right: 8px;
`;
const StyledInput = styled.input`
flex: 1;
border: none;
outline: none;
font-family: Arial, sans-serif;
font-size: 14px;
&::placeholder {
color: #aaa;
}
`;
const StyledErrorHelper = styled.div`
color: #ff0000;
font-size: 12px;
padding: 5px 0;
`;
const TextInput: React.FC<TextInputProps> = ({
label,
value,
onChange,
fullWidth,
error,
placeholder,
icon,
}) => {
return (
<StyledContainer fullWidth={fullWidth}>
{label && <StyledLabel>{label}</StyledLabel>}
<StyledInputContainer>
{icon && <StyledIcon>{icon}</StyledIcon>}
<StyledInput
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
/>
</StyledInputContainer>
{error && <StyledErrorHelper>{error}</StyledErrorHelper>}
</StyledContainer>
);
};
export { TextInput };

View File

@ -0,0 +1,83 @@
import { useEffect, useState } from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
export type ToggleSize = 'small' | 'medium';
type ContainerProps = {
isOn: boolean;
color?: string;
toggleSize: ToggleSize;
};
const StyledContainer = styled.div<ContainerProps>`
align-items: center;
background-color: ${({ theme, isOn, color }) =>
isOn ? color ?? theme.color.blue : theme.background.quaternary};
border-radius: 10px;
cursor: pointer;
display: flex;
height: ${({ toggleSize }) => (toggleSize === 'small' ? 16 : 20)}px;
transition: background-color 0.3s ease;
width: ${({ toggleSize }) => (toggleSize === 'small' ? 24 : 32)}px;
`;
const StyledCircle = styled(motion.div)<{
toggleSize: ToggleSize;
}>`
background-color: ${({ theme }) => theme.background.primary};
border-radius: 50%;
height: ${({ toggleSize }) => (toggleSize === 'small' ? 12 : 16)}px;
width: ${({ toggleSize }) => (toggleSize === 'small' ? 12 : 16)}px;
`;
export type ToggleProps = {
value?: boolean;
onChange?: (value: boolean) => void;
color?: string;
toggleSize?: ToggleSize;
};
export const Toggle = ({
value,
onChange,
color,
toggleSize = 'medium',
}: ToggleProps) => {
const [isOn, setIsOn] = useState(value ?? false);
const circleVariants = {
on: { x: toggleSize === 'small' ? 10 : 14 },
off: { x: 2 },
};
const handleChange = () => {
setIsOn(!isOn);
if (onChange) {
onChange(!isOn);
}
};
useEffect(() => {
if (value !== isOn) {
setIsOn(value ?? false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return (
<StyledContainer
onClick={handleChange}
isOn={isOn}
color={color}
toggleSize={toggleSize}
>
<StyledCircle
animate={isOn ? 'on' : 'off'}
variants={circleVariants}
toggleSize={toggleSize}
/>
</StyledContainer>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1,15 @@
import { ThemeProvider } from '@emotion/react';
import { lightTheme } from '../constants/theme';
type AppThemeProviderProps = {
children: JSX.Element;
};
const AppThemeProvider: React.FC<AppThemeProviderProps> = ({ children }) => {
const theme = lightTheme;
return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
};
export { AppThemeProvider };

View File

@ -0,0 +1,19 @@
import { color } from './colors';
export const accentLight = {
primary: color.blueAccent25,
secondary: color.blueAccent20,
tertiary: color.blueAccent15,
quaternary: color.blueAccent10,
accent3570: color.blueAccent35,
accent4060: color.blueAccent40,
};
export const accentDark = {
primary: color.blueAccent75,
secondary: color.blueAccent80,
tertiary: color.blueAccent85,
quaternary: color.blueAccent90,
accent3570: color.blueAccent70,
accent4060: color.blueAccent60,
};

View File

@ -0,0 +1,9 @@
export const animation = {
duration: {
instant: 0.075,
fast: 0.15,
normal: 0.3,
},
};
export type AnimationDuration = 'instant' | 'fast' | 'normal';

View File

@ -0,0 +1,47 @@
/* eslint-disable twenty/no-hardcoded-colors */
import DarkNoise from '../assets/dark-noise.jpg';
import LightNoise from '../assets/light-noise.png';
import { color, grayScale, rgba } from './colors';
export const backgroundLight = {
noisy: `url(${LightNoise.toString()});`,
primary: grayScale.gray0,
secondary: grayScale.gray10,
tertiary: grayScale.gray15,
quaternary: grayScale.gray20,
danger: color.red10,
transparent: {
primary: rgba(grayScale.gray0, 0.8),
secondary: rgba(grayScale.gray10, 0.8),
strong: rgba(grayScale.gray100, 0.16),
medium: rgba(grayScale.gray100, 0.08),
light: rgba(grayScale.gray100, 0.04),
lighter: rgba(grayScale.gray100, 0.02),
danger: rgba(color.red, 0.08),
},
overlay: rgba(grayScale.gray80, 0.8),
radialGradient: `radial-gradient(50% 62.62% at 50% 0%, #505050 0%, ${grayScale.gray60} 100%)`,
radialGradientHover: `radial-gradient(76.32% 95.59% at 50% 0%, #505050 0%, ${grayScale.gray60} 100%)`,
};
export const backgroundDark = {
noisy: `url(${DarkNoise.toString()});`,
primary: grayScale.gray85,
secondary: grayScale.gray80,
tertiary: grayScale.gray75,
quaternary: grayScale.gray70,
danger: color.red80,
transparent: {
primary: rgba(grayScale.gray85, 0.8),
secondary: rgba(grayScale.gray80, 0.8),
strong: rgba(grayScale.gray0, 0.14),
medium: rgba(grayScale.gray0, 0.1),
light: rgba(grayScale.gray0, 0.06),
lighter: rgba(grayScale.gray0, 0.03),
danger: rgba(color.red, 0.08),
},
overlay: rgba(grayScale.gray80, 0.8),
radialGradient: `radial-gradient(50% 62.62% at 50% 0%, #505050 0%, ${grayScale.gray60} 100%)`,
radialGradientHover: `radial-gradient(76.32% 95.59% at 50% 0%, #505050 0%, ${grayScale.gray60} 100%)`,
};

View File

@ -0,0 +1,4 @@
export const blur = {
light: 'blur(6px)',
strong: 'blur(20px)',
};

View File

@ -0,0 +1,34 @@
import { color, grayScale } from './colors';
const common = {
radius: {
xs: '2px',
sm: '4px',
md: '8px',
rounded: '100%',
},
};
export const borderLight = {
color: {
strong: grayScale.gray25,
medium: grayScale.gray20,
light: grayScale.gray15,
secondaryInverted: grayScale.gray50,
inverted: grayScale.gray60,
danger: color.red20,
},
...common,
};
export const borderDark = {
color: {
strong: grayScale.gray55,
medium: grayScale.gray65,
light: grayScale.gray70,
secondaryInverted: grayScale.gray35,
inverted: grayScale.gray20,
danger: color.red70,
},
...common,
};

View File

@ -0,0 +1,27 @@
import { grayScale, rgba } from './colors';
export const boxShadowLight = {
extraLight: `0px 1px 0px 0px ${rgba(grayScale.gray100, 0.04)}`,
light: `0px 2px 4px 0px ${rgba(
grayScale.gray100,
0.04,
)}, 0px 0px 4px 0px ${rgba(grayScale.gray100, 0.08)}`,
strong: `2px 4px 16px 0px ${rgba(
grayScale.gray100,
0.12,
)}, 0px 2px 4px 0px ${rgba(grayScale.gray100, 0.04)}`,
underline: `0px 1px 0px 0px ${rgba(grayScale.gray100, 0.32)}`,
};
export const boxShadowDark = {
extraLight: `0px 1px 0px 0px ${rgba(grayScale.gray100, 0.04)}`,
light: `0px 2px 4px 0px ${rgba(
grayScale.gray100,
0.04,
)}, 0px 0px 4px 0px ${rgba(grayScale.gray100, 0.08)}`,
strong: `2px 4px 16px 0px ${rgba(
grayScale.gray100,
0.16,
)}, 0px 2px 4px 0px ${rgba(grayScale.gray100, 0.08)}`,
underline: `0px 1px 0px 0px ${rgba(grayScale.gray100, 0.32)}`,
};

View File

@ -0,0 +1,153 @@
/* eslint-disable twenty/no-hardcoded-colors */
import hexRgb from 'hex-rgb';
export const grayScale = {
gray100: '#000000',
gray90: '#141414',
gray85: '#171717',
gray80: '#1b1b1b',
gray75: '#1d1d1d',
gray70: '#222222',
gray65: '#292929',
gray60: '#333333',
gray55: '#4c4c4c',
gray50: '#666666',
gray45: '#818181',
gray40: '#999999',
gray35: '#b3b3b3',
gray30: '#cccccc',
gray25: '#d6d6d6',
gray20: '#ebebeb',
gray15: '#f1f1f1',
gray10: '#fcfcfc',
gray0: '#ffffff',
};
export const mainColors = {
yellow: '#ffd338',
green: '#55ef3c',
turquoise: '#15de8f',
sky: '#00e0ff',
blue: '#1961ed',
purple: '#915ffd',
pink: '#f54bd0',
red: '#f83e3e',
orange: '#ff7222',
gray: grayScale.gray30,
};
export type ThemeColor = keyof typeof mainColors;
export const secondaryColors = {
yellow80: '#2e2a1a',
yellow70: '#453d1e',
yellow60: '#746224',
yellow50: '#b99b2e',
yellow40: '#ffe074',
yellow30: '#ffedaf',
yellow20: '#fff6d7',
yellow10: '#fffbeb',
green80: '#1d2d1b',
green70: '#23421e',
green60: '#2a5822',
green50: '#42ae31',
green40: '#88f477',
green30: '#ccfac5',
green20: '#ddfcd8',
green10: '#eefdec',
turquoise80: '#172b23',
turquoise70: '#173f2f',
turquoise60: '#166747',
turquoise50: '#16a26b',
turquoise40: '#5be8b1',
turquoise30: '#a1f2d2',
turquoise20: '#d0f8e9',
turquoise10: '#e8fcf4',
sky80: '#152b2e',
sky70: '#123f45',
sky60: '#0e6874',
sky50: '#07a4b9',
sky40: '#4de9ff',
sky30: '#99f3ff',
sky20: '#ccf9ff',
sky10: '#e5fcff',
blue80: '#171e2c',
blue70: '#172642',
blue60: '#18356d',
blue50: '#184bad',
blue40: '#5e90f2',
blue30: '#a3c0f8',
blue20: '#d1dffb',
blue10: '#e8effd',
purple80: '#231e2e',
purple70: '#2f2545',
purple60: '#483473',
purple50: '#6c49b8',
purple40: '#b28ffe',
purple30: '#d3bffe',
purple20: '#e9dfff',
purple10: '#f4efff',
pink80: '#2d1c29',
pink70: '#43213c',
pink60: '#702c61',
pink50: '#b23b98',
pink40: '#f881de',
pink30: '#fbb7ec',
pink20: '#fddbf6',
pink10: '#feedfa',
red80: '#2d1b1b',
red70: '#441f1f',
red60: '#712727',
red50: '#b43232',
red40: '#fa7878',
red30: '#fcb2b2',
red20: '#fed8d8',
red10: '#feecec',
orange80: '#2e2018',
orange70: '#452919',
orange60: '#743b1b',
orange50: '#b9571f',
orange40: '#ff9c64',
orange30: '#ffc7a7',
orange20: '#ffe3d3',
orange10: '#fff1e9',
gray80: grayScale.gray70,
gray70: grayScale.gray65,
gray60: grayScale.gray55,
gray50: grayScale.gray40,
gray40: grayScale.gray25,
gray30: grayScale.gray20,
gray20: grayScale.gray15,
gray10: grayScale.gray10,
blueAccent90: '#141a25',
blueAccent85: '#151d2e',
blueAccent80: '#152037',
blueAccent75: '#16233f',
blueAccent70: '#17294a',
blueAccent60: '#18356d',
blueAccent40: '#a3c0f8',
blueAccent35: '#c8d9fb',
blueAccent25: '#dae6fc',
blueAccent20: '#e2ecfd',
blueAccent15: '#edf2fe',
blueAccent10: '#f5f9fd',
};
export const color = {
...mainColors,
...secondaryColors,
};
export const rgba = (hex: string, alpha: number) => {
const rgb = hexRgb(hex, { format: 'array' }).slice(0, -1).join(',');
return `rgba(${rgb},${alpha})`;
};

View File

@ -0,0 +1,37 @@
import { css } from '@emotion/react';
import { ThemeType } from './theme';
export const overlayBackground = (props: { theme: ThemeType }) =>
css`
backdrop-filter: blur(8px);
background: ${props.theme.background.transparent.secondary};
box-shadow: ${props.theme.boxShadow.strong};
`;
export const textInputStyle = (props: { theme: ThemeType }) =>
css`
background-color: transparent;
border: none;
color: ${props.theme.font.color.primary};
font-family: ${props.theme.font.family};
font-size: inherit;
font-weight: inherit;
outline: none;
padding: ${props.theme.spacing(0)} ${props.theme.spacing(2)};
&::placeholder,
&::-webkit-input-placeholder {
color: ${props.theme.font.color.light};
font-family: ${props.theme.font.family};
font-weight: ${props.theme.font.weight.medium};
}
`;
export const hoverBackground = (props: any) =>
css`
transition: background 0.1s ease;
&:hover {
background: ${props.theme.background.transparent.light};
}
`;

View File

@ -0,0 +1,45 @@
import { color, grayScale } from './colors';
const common = {
size: {
xxs: '0.625rem',
xs: '0.85rem',
sm: '0.92rem',
md: '1rem',
lg: '1.23rem',
xl: '1.54rem',
xxl: '1.85rem',
},
weight: {
regular: 400,
medium: 500,
semiBold: 600,
},
family: 'Inter, sans-serif',
};
export const fontLight = {
color: {
primary: grayScale.gray60,
secondary: grayScale.gray50,
tertiary: grayScale.gray40,
light: grayScale.gray35,
extraLight: grayScale.gray30,
inverted: grayScale.gray0,
danger: color.red,
},
...common,
};
export const fontDark = {
color: {
primary: grayScale.gray20,
secondary: grayScale.gray35,
tertiary: grayScale.gray45,
light: grayScale.gray50,
extraLight: grayScale.gray55,
inverted: grayScale.gray100,
danger: color.red,
},
...common,
};

View File

@ -0,0 +1,13 @@
export const icon = {
size: {
sm: 14,
md: 16,
lg: 20,
xl: 40,
},
stroke: {
sm: 1.6,
md: 2,
lg: 2.5,
},
};

View File

@ -0,0 +1,7 @@
export const modal = {
size: {
sm: '300px',
md: '400px',
lg: '53%',
},
};

View File

@ -0,0 +1,55 @@
import { color } from './colors';
export const tagLight: { [key: string]: { [key: string]: string } } = {
text: {
green: color.green60,
turquoise: color.turquoise60,
sky: color.sky60,
blue: color.blue60,
purple: color.purple60,
pink: color.pink60,
red: color.red60,
orange: color.orange60,
yellow: color.yellow60,
gray: color.gray60,
},
background: {
green: color.green20,
turquoise: color.turquoise20,
sky: color.sky20,
blue: color.blue20,
purple: color.purple20,
pink: color.pink20,
red: color.red20,
orange: color.orange20,
yellow: color.yellow20,
gray: color.gray20,
},
};
export const tagDark = {
text: {
green: color.green10,
turquoise: color.turquoise10,
sky: color.sky10,
blue: color.blue10,
purple: color.purple10,
pink: color.pink10,
red: color.red10,
orange: color.orange10,
yellow: color.yellow10,
gray: color.gray10,
},
background: {
green: color.green60,
turquoise: color.turquoise60,
sky: color.sky60,
blue: color.blue60,
purple: color.purple60,
pink: color.pink60,
red: color.red60,
orange: color.orange60,
yellow: color.yellow60,
gray: color.gray60,
},
};

View File

@ -0,0 +1,13 @@
export const text = {
lineHeight: {
lg: 1.5,
md: 1.2,
},
iconSizeMedium: 16,
iconSizeSmall: 14,
iconStrikeLight: 1.6,
iconStrikeMedium: 2,
iconStrikeBold: 2.5,
};

View File

@ -0,0 +1,76 @@
/* eslint-disable twenty/no-hardcoded-colors */
import { accentDark, accentLight } from './accent';
import { animation } from './animation';
import { backgroundDark, backgroundLight } from './background';
import { blur } from './blur';
import { borderDark, borderLight } from './border';
import { boxShadowDark, boxShadowLight } from './boxShadow';
import { color, grayScale } from './colors';
import { fontDark, fontLight } from './font';
import { icon } from './icon';
import { modal } from './modal';
import { tagDark, tagLight } from './tag';
import { text } from './text';
const common = {
color: color,
grayScale: grayScale,
icon: icon,
modal: modal,
text: text,
blur: blur,
animation: animation,
snackBar: {
success: {
background: '#16A26B',
color: '#D0F8E9',
},
error: {
background: '#B43232',
color: '#FED8D8',
},
info: {
background: color.gray80,
color: grayScale.gray0,
},
},
spacingMultiplicator: 4,
spacing: (multiplicator: number) => `${multiplicator * 4}px`,
betweenSiblingsGap: `2px`,
table: {
horizontalCellMargin: '8px',
checkboxColumnWidth: '32px',
},
rightDrawerWidth: '500px',
clickableElementBackgroundTransition: 'background 0.1s ease',
lastLayerZIndex: 2147483647,
};
export const lightTheme = {
...common,
...{
accent: accentLight,
background: backgroundLight,
border: borderLight,
tag: tagLight,
boxShadow: boxShadowLight,
font: fontLight,
name: 'light',
},
};
export type ThemeType = typeof lightTheme;
export const darkTheme: ThemeType = {
...common,
...{
accent: accentDark,
background: backgroundDark,
border: borderDark,
tag: tagDark,
boxShadow: boxShadowDark,
font: fontDark,
name: 'dark',
},
};
export const MOBILE_VIEWPORT = 768;

View File

@ -0,0 +1,21 @@
// Convert extracted data into a structure that can be sent to the server for storage.
const handleQueryParams = (inputData: { [x: string]: unknown }): string => {
let result = '';
Object.keys(inputData).forEach((key) => {
let quote = '';
if (typeof inputData[key] === 'string') quote = '"';
if (typeof inputData[key] === 'object') {
result = result.concat(
`${key}: {${handleQueryParams(
inputData[key] as { [x: string]: unknown },
)}}, `,
);
} else {
result = result.concat(`${key}: ${quote}${inputData[key]}${quote}, `);
}
});
if (result.length) result = result.slice(0, -2); // Remove the last ', '
return result;
};
export default handleQueryParams;

View File

@ -0,0 +1,29 @@
const requestDb = async (query: string) => {
const { apiKey } = await chrome.storage.local.get('apiKey');
const { serverBaseUrl } = await chrome.storage.local.get('serverBaseUrl');
const options = {
method: 'POST',
body: JSON.stringify({ query }),
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${apiKey}`,
},
};
const response = await fetch(
`${
serverBaseUrl ? serverBaseUrl : import.meta.env.VITE_SERVER_BASE_URL
}/graphql`,
options,
);
if (!response.ok) {
console.error(response);
}
return await response.json();
};
export default requestDb;

View File

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SERVER_BASE_URL: string;
readonly VITE_FRONT_BASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts", "package.json", "src/*"]
}

View File

@ -0,0 +1,38 @@
import { defineConfig, Plugin } from 'vite';
import { crx } from '@crxjs/vite-plugin';
import react from '@vitejs/plugin-react';
import manifest from './src/manifest';
const viteManifestHack: Plugin & {
renderCrxManifest: (manifest: unknown, bundle: unknown) => void;
} = {
// Workaround from https://github.com/crxjs/chrome-extension-tools/issues/846#issuecomment-1861880919.
name: 'manifestHack',
renderCrxManifest(_manifest, bundle) {
bundle['manifest.json'] = bundle['.vite/manifest.json'];
bundle['manifest.json'].fileName = 'manifest.json';
delete bundle['.vite/manifest.json'];
},
};
export default defineConfig(() => {
return {
build: {
emptyOutDir: true,
outDir: 'dist',
rollupOptions: {
output: { chunkFileNames: 'assets/chunk-[hash].js' },
},
},
// Adding this to fix websocket connection error.
server: {
port: 3002,
strictPort: true,
hmr: { port: 3002 },
},
plugins: [viteManifestHack, crx({ manifest }), react()],
};
});

284
yarn.lock
View File

@ -1544,6 +1544,29 @@ __metadata:
languageName: node
linkType: hard
"@babel/core@npm:^7.23.5":
version: 7.23.9
resolution: "@babel/core@npm:7.23.9"
dependencies:
"@ampproject/remapping": "npm:^2.2.0"
"@babel/code-frame": "npm:^7.23.5"
"@babel/generator": "npm:^7.23.6"
"@babel/helper-compilation-targets": "npm:^7.23.6"
"@babel/helper-module-transforms": "npm:^7.23.3"
"@babel/helpers": "npm:^7.23.9"
"@babel/parser": "npm:^7.23.9"
"@babel/template": "npm:^7.23.9"
"@babel/traverse": "npm:^7.23.9"
"@babel/types": "npm:^7.23.9"
convert-source-map: "npm:^2.0.0"
debug: "npm:^4.1.0"
gensync: "npm:^1.0.0-beta.2"
json5: "npm:^2.2.3"
semver: "npm:^6.3.1"
checksum: 03883300bf1252ab4c9ba5b52f161232dd52873dbe5cde9289bb2bb26e935c42682493acbac9194a59a3b6cbd17f4c4c84030db8d6d482588afe64531532ff9b
languageName: node
linkType: hard
"@babel/generator@npm:^7.14.0, @babel/generator@npm:^7.18.13, @babel/generator@npm:^7.22.5, @babel/generator@npm:^7.23.0, @babel/generator@npm:^7.23.3, @babel/generator@npm:^7.23.6, @babel/generator@npm:^7.7.2":
version: 7.23.6
resolution: "@babel/generator@npm:7.23.6"
@ -1858,6 +1881,17 @@ __metadata:
languageName: node
linkType: hard
"@babel/helpers@npm:^7.23.9":
version: 7.23.9
resolution: "@babel/helpers@npm:7.23.9"
dependencies:
"@babel/template": "npm:^7.23.9"
"@babel/traverse": "npm:^7.23.9"
"@babel/types": "npm:^7.23.9"
checksum: f69fd0aca96a6fb8bd6dd044cd8a5c0f1851072d4ce23355345b9493c4032e76d1217f86b70df795e127553cf7f3fcd1587ede9d1b03b95e8b62681ca2165b87
languageName: node
linkType: hard
"@babel/highlight@npm:^7.23.4":
version: 7.23.4
resolution: "@babel/highlight@npm:7.23.4"
@ -1887,6 +1921,15 @@ __metadata:
languageName: node
linkType: hard
"@babel/parser@npm:^7.23.9":
version: 7.23.9
resolution: "@babel/parser@npm:7.23.9"
bin:
parser: ./bin/babel-parser.js
checksum: 7df97386431366d4810538db4b9ec538f4377096f720c0591c7587a16f6810e62747e9fbbfa1ff99257fd4330035e4fb1b5b77c7bd3b97ce0d2e3780a6618975
languageName: node
linkType: hard
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:^7.23.3":
version: 7.23.3
resolution: "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:7.23.3"
@ -2769,7 +2812,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/plugin-transform-react-jsx-self@npm:^7.18.6":
"@babel/plugin-transform-react-jsx-self@npm:^7.18.6, @babel/plugin-transform-react-jsx-self@npm:^7.23.3":
version: 7.23.3
resolution: "@babel/plugin-transform-react-jsx-self@npm:7.23.3"
dependencies:
@ -2780,7 +2823,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/plugin-transform-react-jsx-source@npm:^7.19.6":
"@babel/plugin-transform-react-jsx-source@npm:^7.19.6, @babel/plugin-transform-react-jsx-source@npm:^7.23.3":
version: 7.23.3
resolution: "@babel/plugin-transform-react-jsx-source@npm:7.23.3"
dependencies:
@ -3281,6 +3324,17 @@ __metadata:
languageName: node
linkType: hard
"@babel/template@npm:^7.23.9":
version: 7.23.9
resolution: "@babel/template@npm:7.23.9"
dependencies:
"@babel/code-frame": "npm:^7.23.5"
"@babel/parser": "npm:^7.23.9"
"@babel/types": "npm:^7.23.9"
checksum: 0e8b60119433787742bc08ae762bbd8d6755611c4cabbcb7627b292ec901a55af65d93d1c88572326069efb64136ef151ec91ffb74b2df7689bbab237030833a
languageName: node
linkType: hard
"@babel/traverse@npm:^7.14.0, @babel/traverse@npm:^7.16.8, @babel/traverse@npm:^7.18.9, @babel/traverse@npm:^7.22.8, @babel/traverse@npm:^7.23.2, @babel/traverse@npm:^7.23.5":
version: 7.23.5
resolution: "@babel/traverse@npm:7.23.5"
@ -3335,6 +3389,24 @@ __metadata:
languageName: node
linkType: hard
"@babel/traverse@npm:^7.23.9":
version: 7.23.9
resolution: "@babel/traverse@npm:7.23.9"
dependencies:
"@babel/code-frame": "npm:^7.23.5"
"@babel/generator": "npm:^7.23.6"
"@babel/helper-environment-visitor": "npm:^7.22.20"
"@babel/helper-function-name": "npm:^7.23.0"
"@babel/helper-hoist-variables": "npm:^7.22.5"
"@babel/helper-split-export-declaration": "npm:^7.22.6"
"@babel/parser": "npm:^7.23.9"
"@babel/types": "npm:^7.23.9"
debug: "npm:^4.3.1"
globals: "npm:^11.1.0"
checksum: d1615d1d02f04d47111a7ea4446a1a6275668ca39082f31d51f08380de9502e19862be434eaa34b022ce9a17dbb8f9e2b73a746c654d9575f3a680a7ffdf5630
languageName: node
linkType: hard
"@babel/types@npm:^7.0.0, @babel/types@npm:^7.16.8, @babel/types@npm:^7.18.13, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.22.15, @babel/types@npm:^7.22.19, @babel/types@npm:^7.22.5, @babel/types@npm:^7.23.0, @babel/types@npm:^7.23.4, @babel/types@npm:^7.23.6, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4, @babel/types@npm:^7.8.3":
version: 7.23.6
resolution: "@babel/types@npm:7.23.6"
@ -3357,6 +3429,17 @@ __metadata:
languageName: node
linkType: hard
"@babel/types@npm:^7.23.9":
version: 7.23.9
resolution: "@babel/types@npm:7.23.9"
dependencies:
"@babel/helper-string-parser": "npm:^7.23.4"
"@babel/helper-validator-identifier": "npm:^7.22.20"
to-fast-properties: "npm:^2.0.0"
checksum: edc7bb180ce7e4d2aea10c6972fb10474341ac39ba8fdc4a27ffb328368dfdfbf40fca18e441bbe7c483774500d5c05e222cec276c242e952853dcaf4eb884f7
languageName: node
linkType: hard
"@base2/pretty-print-object@npm:1.0.1":
version: 1.0.1
resolution: "@base2/pretty-print-object@npm:1.0.1"
@ -3873,6 +3956,34 @@ __metadata:
languageName: node
linkType: hard
"@crxjs/vite-plugin@npm:^1.0.14":
version: 1.0.14
resolution: "@crxjs/vite-plugin@npm:1.0.14"
dependencies:
"@rollup/pluginutils": "npm:^4.1.2"
"@vitejs/plugin-react": "npm:>=1.2.0"
"@webcomponents/custom-elements": "npm:^1.5.0"
acorn-walk: "npm:^8.2.0"
cheerio: "npm:^1.0.0-rc.10"
connect-injector: "npm:^0.4.4"
debug: "npm:^4.3.3"
es-module-lexer: "npm:^0.10.0"
fast-glob: "npm:^3.2.11"
fs-extra: "npm:^10.0.1"
jsesc: "npm:^3.0.2"
magic-string: "npm:^0.26.0"
picocolors: "npm:^1.0.0"
react-refresh: "npm:^0.13.0"
rollup: "npm:^2.70.2"
peerDependencies:
vite: ^2.9.0
dependenciesMeta:
"@vitejs/plugin-react":
optional: true
checksum: 19e203ddcfc3110973999bcc5224a0e8846b985a720d37ed55a945ed7ef726115bbb2ae06e5d30328c5c6338877acd1c77f27b35870fd92d2493b9bee65421f5
languageName: node
linkType: hard
"@cspotcode/source-map-support@npm:^0.8.0":
version: 0.8.1
resolution: "@cspotcode/source-map-support@npm:0.8.1"
@ -10786,6 +10897,16 @@ __metadata:
languageName: node
linkType: hard
"@rollup/pluginutils@npm:^4.1.2":
version: 4.2.1
resolution: "@rollup/pluginutils@npm:4.2.1"
dependencies:
estree-walker: "npm:^2.0.1"
picomatch: "npm:^2.2.2"
checksum: 3ee56b2c8f1ed8dfd0a92631da1af3a2dfdd0321948f089b3752b4de1b54dc5076701eadd0e5fc18bd191b77af594ac1db6279e83951238ba16bf8a414c64c48
languageName: node
linkType: hard
"@rollup/pluginutils@npm:^5.0.2, @rollup/pluginutils@npm:^5.0.5":
version: 5.1.0
resolution: "@rollup/pluginutils@npm:5.1.0"
@ -14530,7 +14651,7 @@ __metadata:
languageName: node
linkType: hard
"@types/babel__core@npm:^7.0.0, @types/babel__core@npm:^7.1.14, @types/babel__core@npm:^7.18.0":
"@types/babel__core@npm:^7.0.0, @types/babel__core@npm:^7.1.14, @types/babel__core@npm:^7.18.0, @types/babel__core@npm:^7.20.5":
version: 7.20.5
resolution: "@types/babel__core@npm:7.20.5"
dependencies:
@ -14644,6 +14765,16 @@ __metadata:
languageName: node
linkType: hard
"@types/chrome@npm:^0.0.256":
version: 0.0.256
resolution: "@types/chrome@npm:0.0.256"
dependencies:
"@types/filesystem": "npm:*"
"@types/har-format": "npm:*"
checksum: 35b3d2c92a3888cc14e5961421233003407a95078bf9b2f30c52a90470dae02588560bff1733ed3e7a8e9f12a1d0c5a6bae0ca30b6acdb3d723e1c2f29c8e861
languageName: node
linkType: hard
"@types/codemirror@npm:^0.0.90":
version: 0.0.90
resolution: "@types/codemirror@npm:0.0.90"
@ -14959,6 +15090,22 @@ __metadata:
languageName: node
linkType: hard
"@types/filesystem@npm:*":
version: 0.0.35
resolution: "@types/filesystem@npm:0.0.35"
dependencies:
"@types/filewriter": "npm:*"
checksum: 16a380e9774c5a9e1358f3ee28a3d85a93488443f235d160da3969aae7858dc6c6148cb3ff6b7e814f1c43f17940da0941f004373566d4fe7f75d9fda5efe246
languageName: node
linkType: hard
"@types/filewriter@npm:*":
version: 0.0.32
resolution: "@types/filewriter@npm:0.0.32"
checksum: 4feea7890d7945059f8eec0f89b1a4fe4f0522156c9345d9123c3498c6dba4584a17bd886daa4392a2e19bd9d16ee82aff9a0e1b837af507b612bcc6bd4c4305
languageName: node
linkType: hard
"@types/find-cache-dir@npm:^3.2.1":
version: 3.2.1
resolution: "@types/find-cache-dir@npm:3.2.1"
@ -15013,7 +15160,7 @@ __metadata:
languageName: node
linkType: hard
"@types/har-format@npm:^1.2.10":
"@types/har-format@npm:*, @types/har-format@npm:^1.2.10":
version: 1.2.15
resolution: "@types/har-format@npm:1.2.15"
checksum: 43ede55c3947e5cf59561f165930dc2eb3ae983fd162980cdc7274be1e7f528a6f77e65fee9a02a20d91b09bde10bed832b0470724f5c744ef6669015d00564e
@ -16361,6 +16508,21 @@ __metadata:
languageName: node
linkType: hard
"@vitejs/plugin-react@npm:>=1.2.0":
version: 4.2.1
resolution: "@vitejs/plugin-react@npm:4.2.1"
dependencies:
"@babel/core": "npm:^7.23.5"
"@babel/plugin-transform-react-jsx-self": "npm:^7.23.3"
"@babel/plugin-transform-react-jsx-source": "npm:^7.23.3"
"@types/babel__core": "npm:^7.20.5"
react-refresh: "npm:^0.14.0"
peerDependencies:
vite: ^4.2.0 || ^5.0.0
checksum: de1eec44d703f32e5b58e776328ca20793657fe991835d15b290230b19a2a08be5d31501d424279ae13ecfed28044c117b69d746891c8d9b92c69e8a8907e989
languageName: node
linkType: hard
"@vitejs/plugin-react@npm:^3.0.1":
version: 3.1.0
resolution: "@vitejs/plugin-react@npm:3.1.0"
@ -16558,6 +16720,13 @@ __metadata:
languageName: node
linkType: hard
"@webcomponents/custom-elements@npm:^1.5.0":
version: 1.6.0
resolution: "@webcomponents/custom-elements@npm:1.6.0"
checksum: 8c3c3b0250ad7b063fe92b550fb725cc6074c8c5caea4a80901f9d9a93cdacf6dc0c73f715fa7b16f86e2ca1630e43cd80499bbf80e3a9b5c6ec042e074d22b4
languageName: node
linkType: hard
"@whatwg-node/events@npm:^0.0.3":
version: 0.0.3
resolution: "@whatwg-node/events@npm:0.0.3"
@ -16854,7 +17023,7 @@ __metadata:
languageName: node
linkType: hard
"acorn-walk@npm:^8.0.0, acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1":
"acorn-walk@npm:^8.0.0, acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1, acorn-walk@npm:^8.2.0":
version: 8.3.1
resolution: "acorn-walk@npm:8.3.1"
checksum: a23d2f7c6b6cad617f4c77f14dfeb062a239208d61753e9ba808d916c550add92b39535467d2e6028280761ac4f5a904cc9df21530b84d3f834e3edef74ddde5
@ -20273,7 +20442,7 @@ __metadata:
languageName: node
linkType: hard
"cheerio@npm:^1.0.0-rc.12":
"cheerio@npm:^1.0.0-rc.10, cheerio@npm:^1.0.0-rc.12":
version: 1.0.0-rc.12
resolution: "cheerio@npm:1.0.0-rc.12"
dependencies:
@ -21230,6 +21399,18 @@ __metadata:
languageName: node
linkType: hard
"connect-injector@npm:^0.4.4":
version: 0.4.4
resolution: "connect-injector@npm:0.4.4"
dependencies:
debug: "npm:^2.0.0"
q: "npm:^1.0.1"
stream-buffers: "npm:^0.2.3"
uberproto: "npm:^1.1.0"
checksum: 6186a21285db6e989d610e7e2223305f59f1f11d4977bbf62db21680eb6b2f9b6080d5f03171d98d346496388c0cdb3f38bcec9bfebd3a58bc258ce12186ea1c
languageName: node
linkType: hard
"consola@npm:^2.15.0, consola@npm:^2.15.3":
version: 2.15.3
resolution: "consola@npm:2.15.3"
@ -22314,7 +22495,7 @@ __metadata:
languageName: node
linkType: hard
"debug@npm:2.6.9, debug@npm:^2.6.0, debug@npm:^2.6.8, debug@npm:^2.6.9":
"debug@npm:2.6.9, debug@npm:^2.0.0, debug@npm:^2.6.0, debug@npm:^2.6.8, debug@npm:^2.6.9":
version: 2.6.9
resolution: "debug@npm:2.6.9"
dependencies:
@ -23497,6 +23678,13 @@ __metadata:
languageName: node
linkType: hard
"es-module-lexer@npm:^0.10.0":
version: 0.10.5
resolution: "es-module-lexer@npm:0.10.5"
checksum: 5a199242971341fefe12ce5984602698d8f9c477e207f847aaed0f70519cf2c68ddbd22dd92b2cc4669a9d421a3b89a67d371994b64604ea24da21d35c42089e
languageName: node
linkType: hard
"es-module-lexer@npm:^0.9.3":
version: 0.9.3
resolution: "es-module-lexer@npm:0.9.3"
@ -24339,7 +24527,7 @@ __metadata:
languageName: node
linkType: hard
"estree-walker@npm:^2.0.2":
"estree-walker@npm:^2.0.1, estree-walker@npm:^2.0.2":
version: 2.0.2
resolution: "estree-walker@npm:2.0.2"
checksum: 53a6c54e2019b8c914dc395890153ffdc2322781acf4bd7d1a32d7aedc1710807bdcd866ac133903d5629ec601fbb50abe8c2e5553c7f5a0afdd9b6af6c945af
@ -25549,7 +25737,7 @@ __metadata:
languageName: node
linkType: hard
"fs-extra@npm:^10.0.0, fs-extra@npm:^10.1.0":
"fs-extra@npm:^10.0.0, fs-extra@npm:^10.0.1, fs-extra@npm:^10.1.0":
version: 10.1.0
resolution: "fs-extra@npm:10.1.0"
dependencies:
@ -30290,6 +30478,15 @@ __metadata:
languageName: node
linkType: hard
"jsesc@npm:^3.0.2":
version: 3.0.2
resolution: "jsesc@npm:3.0.2"
bin:
jsesc: bin/jsesc
checksum: ef22148f9e793180b14d8a145ee6f9f60f301abf443288117b4b6c53d0ecd58354898dc506ccbb553a5f7827965cd38bc5fb726575aae93c5e8915e2de8290e1
languageName: node
linkType: hard
"jsesc@npm:~0.5.0":
version: 0.5.0
resolution: "jsesc@npm:0.5.0"
@ -31538,6 +31735,15 @@ __metadata:
languageName: node
linkType: hard
"magic-string@npm:^0.26.0":
version: 0.26.7
resolution: "magic-string@npm:0.26.7"
dependencies:
sourcemap-codec: "npm:^1.4.8"
checksum: 950035b344fe2a8163668980bc4a215a0b225086e6e22100fd947e7647053c6ba6b4f11a04de83a97a276526ccb602ef53b173725dbb1971fb146cff5a5e14f6
languageName: node
linkType: hard
"magic-string@npm:^0.27.0":
version: 0.27.0
resolution: "magic-string@npm:0.27.0"
@ -36647,7 +36853,7 @@ __metadata:
languageName: node
linkType: hard
"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.3, picomatch@npm:^2.3.0, picomatch@npm:^2.3.1":
"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.2, picomatch@npm:^2.2.3, picomatch@npm:^2.3.0, picomatch@npm:^2.3.1":
version: 2.3.1
resolution: "picomatch@npm:2.3.1"
checksum: 26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be
@ -38017,6 +38223,13 @@ __metadata:
languageName: node
linkType: hard
"q@npm:^1.0.1":
version: 1.5.1
resolution: "q@npm:1.5.1"
checksum: 7855fbdba126cb7e92ef3a16b47ba998c0786ec7fface236e3eb0135b65df36429d91a86b1fff3ab0927b4ac4ee88a2c44527c7c3b8e2a37efbec9fe34803df4
languageName: node
linkType: hard
"qs@npm:6.11.0":
version: 6.11.0
resolution: "qs@npm:6.11.0"
@ -38622,6 +38835,13 @@ __metadata:
languageName: node
linkType: hard
"react-refresh@npm:^0.13.0":
version: 0.13.0
resolution: "react-refresh@npm:0.13.0"
checksum: cb9f324d471485e569628854dc08d1550c0798cde57f1bfb8d954e006659de1da0bdccaf7d5d2ac0d3d53df1aae7b740b2a36128789afb8aff0f7ec01b128587
languageName: node
linkType: hard
"react-refresh@npm:^0.14.0":
version: 0.14.0
resolution: "react-refresh@npm:0.14.0"
@ -40100,6 +40320,20 @@ __metadata:
languageName: node
linkType: hard
"rollup@npm:^2.70.2":
version: 2.79.1
resolution: "rollup@npm:2.79.1"
dependencies:
fsevents: "npm:~2.3.2"
dependenciesMeta:
fsevents:
optional: true
bin:
rollup: dist/bin/rollup
checksum: 421418687f5dcd7324f4387f203c6bfc7118b7ace789e30f5da022471c43e037a76f5fd93837052754eeeae798a4fb266ac05ccee1e594406d912a59af98dde9
languageName: node
linkType: hard
"rollup@npm:^4.0.2":
version: 4.9.2
resolution: "rollup@npm:4.9.2"
@ -41177,6 +41411,13 @@ __metadata:
languageName: node
linkType: hard
"sourcemap-codec@npm:^1.4.8":
version: 1.4.8
resolution: "sourcemap-codec@npm:1.4.8"
checksum: f099279fdaae070ff156df7414bbe39aad69cdd615454947ed3e19136bfdfcb4544952685ee73f56e17038f4578091e12b17b283ed8ac013882916594d95b9e6
languageName: node
linkType: hard
"space-separated-tokens@npm:^1.0.0":
version: 1.1.5
resolution: "space-separated-tokens@npm:1.1.5"
@ -41541,6 +41782,13 @@ __metadata:
languageName: node
linkType: hard
"stream-buffers@npm:^0.2.3":
version: 0.2.6
resolution: "stream-buffers@npm:0.2.6"
checksum: 8d685a5f98e0b392802fc07617f31e6ae63652ed2fff7fe7df309222ffb06502f47b31ab35c2cf9b4de0320f657ed3aa6d697641f0f72f5c6f3a703ba8d7b594
languageName: node
linkType: hard
"stream-combiner2@npm:^1.1.1":
version: 1.1.1
resolution: "stream-combiner2@npm:1.1.1"
@ -43179,6 +43427,15 @@ __metadata:
languageName: node
linkType: hard
"twenty-chrome-extension@workspace:packages/twenty-chrome-extension":
version: 0.0.0-use.local
resolution: "twenty-chrome-extension@workspace:packages/twenty-chrome-extension"
dependencies:
"@crxjs/vite-plugin": "npm:^1.0.14"
"@types/chrome": "npm:^0.0.256"
languageName: unknown
linkType: soft
"twenty-docs@workspace:packages/twenty-docs":
version: 0.0.0-use.local
resolution: "twenty-docs@workspace:packages/twenty-docs"
@ -43893,6 +44150,13 @@ __metadata:
languageName: node
linkType: hard
"uberproto@npm:^1.1.0":
version: 1.2.0
resolution: "uberproto@npm:1.2.0"
checksum: 0071dbc7b3b71b4fedd4de5c914a6851df8ce11f6d98cf84ef8a1973afd8562027d111db97c047e2e42894bd5f99b24c6d07058d338d3204b3aea2c3c75421d2
languageName: node
linkType: hard
"uc.micro@npm:^1.0.1, uc.micro@npm:^1.0.5":
version: 1.0.6
resolution: "uc.micro@npm:1.0.6"