From 44c39430f7a5f84024c57cf8358a502f29264ddb Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommmu Date: Thu, 28 Aug 2025 19:00:59 -0700 Subject: feat: browser extension --- apps/browser-extension/.gitignore | 26 + apps/browser-extension/README.md | 1 + apps/browser-extension/entrypoints/background.ts | 198 ++++++ apps/browser-extension/entrypoints/content.ts | 463 ++++++++++++++ apps/browser-extension/entrypoints/popup/App.css | 708 +++++++++++++++++++++ apps/browser-extension/entrypoints/popup/App.tsx | 394 ++++++++++++ .../browser-extension/entrypoints/popup/index.html | 13 + apps/browser-extension/entrypoints/popup/main.tsx | 17 + apps/browser-extension/entrypoints/popup/style.css | 70 ++ .../entrypoints/welcome/Welcome.tsx | 95 +++ .../entrypoints/welcome/index.html | 13 + .../browser-extension/entrypoints/welcome/main.tsx | 17 + .../entrypoints/welcome/welcome.css | 275 ++++++++ apps/browser-extension/fonts/SpaceGrotesk-Bold.ttf | Bin 0 -> 86516 bytes .../browser-extension/fonts/SpaceGrotesk-Light.ttf | Bin 0 -> 86612 bytes .../fonts/SpaceGrotesk-Medium.ttf | Bin 0 -> 86612 bytes .../fonts/SpaceGrotesk-Regular.ttf | Bin 0 -> 86588 bytes .../fonts/SpaceGrotesk-SemiBold.ttf | Bin 0 -> 86572 bytes .../fonts/SpaceGrotesk-VariableFont_wght.ttf | Bin 0 -> 134112 bytes apps/browser-extension/package.json | 30 + apps/browser-extension/public/icon-128.png | Bin 0 -> 229436 bytes apps/browser-extension/public/icon-16.png | Bin 0 -> 33814 bytes apps/browser-extension/public/icon-48.png | Bin 0 -> 110647 bytes apps/browser-extension/tsconfig.json | 8 + apps/browser-extension/utils/api.ts | 156 +++++ apps/browser-extension/utils/constants.ts | 81 +++ apps/browser-extension/utils/query-client.ts | 24 + apps/browser-extension/utils/query-hooks.ts | 64 ++ apps/browser-extension/utils/twitter-auth.ts | 101 +++ apps/browser-extension/utils/twitter-import.ts | 192 ++++++ apps/browser-extension/utils/twitter-utils.ts | 377 +++++++++++ apps/browser-extension/utils/types.ts | 149 +++++ apps/browser-extension/utils/ui-components.ts | 449 +++++++++++++ apps/browser-extension/wxt.config.ts | 35 + 34 files changed, 3956 insertions(+) create mode 100644 apps/browser-extension/.gitignore create mode 100644 apps/browser-extension/README.md create mode 100644 apps/browser-extension/entrypoints/background.ts create mode 100644 apps/browser-extension/entrypoints/content.ts create mode 100644 apps/browser-extension/entrypoints/popup/App.css create mode 100644 apps/browser-extension/entrypoints/popup/App.tsx create mode 100644 apps/browser-extension/entrypoints/popup/index.html create mode 100644 apps/browser-extension/entrypoints/popup/main.tsx create mode 100644 apps/browser-extension/entrypoints/popup/style.css create mode 100644 apps/browser-extension/entrypoints/welcome/Welcome.tsx create mode 100644 apps/browser-extension/entrypoints/welcome/index.html create mode 100644 apps/browser-extension/entrypoints/welcome/main.tsx create mode 100644 apps/browser-extension/entrypoints/welcome/welcome.css create mode 100644 apps/browser-extension/fonts/SpaceGrotesk-Bold.ttf create mode 100644 apps/browser-extension/fonts/SpaceGrotesk-Light.ttf create mode 100644 apps/browser-extension/fonts/SpaceGrotesk-Medium.ttf create mode 100644 apps/browser-extension/fonts/SpaceGrotesk-Regular.ttf create mode 100644 apps/browser-extension/fonts/SpaceGrotesk-SemiBold.ttf create mode 100644 apps/browser-extension/fonts/SpaceGrotesk-VariableFont_wght.ttf create mode 100644 apps/browser-extension/package.json create mode 100644 apps/browser-extension/public/icon-128.png create mode 100644 apps/browser-extension/public/icon-16.png create mode 100644 apps/browser-extension/public/icon-48.png create mode 100644 apps/browser-extension/tsconfig.json create mode 100644 apps/browser-extension/utils/api.ts create mode 100644 apps/browser-extension/utils/constants.ts create mode 100644 apps/browser-extension/utils/query-client.ts create mode 100644 apps/browser-extension/utils/query-hooks.ts create mode 100644 apps/browser-extension/utils/twitter-auth.ts create mode 100644 apps/browser-extension/utils/twitter-import.ts create mode 100644 apps/browser-extension/utils/twitter-utils.ts create mode 100644 apps/browser-extension/utils/types.ts create mode 100644 apps/browser-extension/utils/ui-components.ts create mode 100644 apps/browser-extension/wxt.config.ts (limited to 'apps') diff --git a/apps/browser-extension/.gitignore b/apps/browser-extension/.gitignore new file mode 100644 index 00000000..a2569538 --- /dev/null +++ b/apps/browser-extension/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.output +stats.html +stats-*.json +.wxt +web-ext.config.ts + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/browser-extension/README.md b/apps/browser-extension/README.md new file mode 100644 index 00000000..4fb6e6e8 --- /dev/null +++ b/apps/browser-extension/README.md @@ -0,0 +1 @@ +## supermemory Browser Extension \ No newline at end of file diff --git a/apps/browser-extension/entrypoints/background.ts b/apps/browser-extension/entrypoints/background.ts new file mode 100644 index 00000000..11b56757 --- /dev/null +++ b/apps/browser-extension/entrypoints/background.ts @@ -0,0 +1,198 @@ +import { getDefaultProject, saveMemory, searchMemories } from "../utils/api" +import { + CONTAINER_TAGS, + CONTEXT_MENU_IDS, + MESSAGE_TYPES, +} from "../utils/constants" +import { captureTwitterTokens } from "../utils/twitter-auth" +import { + type TwitterImportConfig, + TwitterImporter, +} from "../utils/twitter-import" +import type { + ExtensionMessage, + MemoryData, + MemoryPayload, +} from "../utils/types" + +export default defineBackground(() => { + let twitterImporter: TwitterImporter | null = null + + browser.runtime.onInstalled.addListener((details) => { + browser.contextMenus.create({ + id: CONTEXT_MENU_IDS.SAVE_TO_SUPERMEMORY, + title: "Save to supermemory", + contexts: ["selection", "page", "link"], + }) + + // Open welcome tab on first install + if (details.reason === "install") { + browser.tabs.create({ + url: browser.runtime.getURL("/welcome.html"), + }) + } + }) + + // Intercept Twitter requests to capture authentication headers. + browser.webRequest.onBeforeSendHeaders.addListener( + (details) => { + captureTwitterTokens(details) + return {} + }, + { urls: ["*://x.com/*", "*://twitter.com/*"] }, + ["requestHeaders", "extraHeaders"], + ) + + // Handle context menu clicks. + browser.contextMenus.onClicked.addListener(async (info, tab) => { + if (info.menuItemId === CONTEXT_MENU_IDS.SAVE_TO_SUPERMEMORY) { + if (tab?.id) { + try { + await browser.tabs.sendMessage(tab.id, { + action: MESSAGE_TYPES.SAVE_MEMORY, + }) + } catch (error) { + console.error("Failed to send message to content script:", error) + } + } + } + }) + + // Send message to current active tab. + const sendMessageToCurrentTab = async (message: string) => { + const tabs = await browser.tabs.query({ + active: true, + currentWindow: true, + }) + if (tabs.length > 0 && tabs[0].id) { + await browser.tabs.sendMessage(tabs[0].id, { + type: MESSAGE_TYPES.IMPORT_UPDATE, + importedMessage: message, + }) + } + } + + /** + * Send import completion message + */ + const sendImportDoneMessage = async (totalImported: number) => { + const tabs = await browser.tabs.query({ + active: true, + currentWindow: true, + }) + if (tabs.length > 0 && tabs[0].id) { + await browser.tabs.sendMessage(tabs[0].id, { + type: MESSAGE_TYPES.IMPORT_DONE, + totalImported, + }) + } + } + + /** + * Save memory to supermemory API + */ + const saveMemoryToSupermemory = async ( + data: MemoryData, + ): Promise<{ success: boolean; data?: unknown; error?: string }> => { + try { + let containerTag: string = CONTAINER_TAGS.DEFAULT_PROJECT + try { + const defaultProject = await getDefaultProject() + if (defaultProject?.containerTag) { + containerTag = defaultProject.containerTag + } + } catch (error) { + console.warn("Failed to get default project, using fallback:", error) + } + + const payload: MemoryPayload = { + containerTags: [containerTag], + content: `${data.highlightedText}\n\n${data.html}\n\n${data?.url}`, + metadata: { sm_source: "consumer" }, + } + + const responseData = await saveMemory(payload) + return { success: true, data: responseData } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + } + } + } + + const getRelatedMemories = async ( + data: string, + ): Promise<{ success: boolean; data?: unknown; error?: string }> => { + try { + const responseData = await searchMemories(data) + const response = responseData as { + results?: Array<{ chunks?: Array<{ content?: string }> }> + } + const content = response.results?.[0]?.chunks?.[0]?.content + console.log("Content:", content) + return { success: true, data: content } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + } + } + } + + /** + * Handle extension messages + */ + browser.runtime.onMessage.addListener( + (message: ExtensionMessage, _sender, sendResponse) => { + // Handle Twitter import request + if (message.type === MESSAGE_TYPES.BATCH_IMPORT_ALL) { + const importConfig: TwitterImportConfig = { + onProgress: sendMessageToCurrentTab, + onComplete: sendImportDoneMessage, + onError: async (error: Error) => { + await sendMessageToCurrentTab(`Error: ${error.message}`) + }, + } + + twitterImporter = new TwitterImporter(importConfig) + twitterImporter.startImport().catch(console.error) + sendResponse({ success: true }) + return true + } + + // Handle regular memory save request + if (message.action === MESSAGE_TYPES.SAVE_MEMORY) { + ;(async () => { + try { + const result = await saveMemoryToSupermemory( + message.data as MemoryData, + ) + sendResponse(result) + } catch (error) { + sendResponse({ + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }) + } + })() + return true + } + + if (message.action === MESSAGE_TYPES.GET_RELATED_MEMORIES) { + ;(async () => { + try { + const result = await getRelatedMemories(message.data as string) + sendResponse(result) + } catch (error) { + sendResponse({ + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }) + } + })() + return true + } + }, + ) +}) diff --git a/apps/browser-extension/entrypoints/content.ts b/apps/browser-extension/entrypoints/content.ts new file mode 100644 index 00000000..97f5fc44 --- /dev/null +++ b/apps/browser-extension/entrypoints/content.ts @@ -0,0 +1,463 @@ +import { + DOMAINS, + ELEMENT_IDS, + MESSAGE_TYPES, + STORAGE_KEYS, +} from "../utils/constants" +import { + createChatGPTInputBarElement, + createSaveTweetElement, + createTwitterImportButton, + createTwitterImportUI, + DOMUtils, +} from "../utils/ui-components" + +export default defineContentScript({ + matches: [""], + main() { + let twitterImportUI: HTMLElement | null = null + let isTwitterImportOpen = false + + browser.runtime.onMessage.addListener(async (message) => { + if (message.action === MESSAGE_TYPES.SHOW_TOAST) { + DOMUtils.showToast(message.state) + } else if (message.action === MESSAGE_TYPES.SAVE_MEMORY) { + await saveMemory() + } else if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) { + updateTwitterImportUI(message) + } else if (message.type === MESSAGE_TYPES.IMPORT_DONE) { + updateTwitterImportUI(message) + } + }) + + const observeForMemoriesDialog = () => { + const observer = new MutationObserver(() => { + if (DOMUtils.isOnDomain(DOMAINS.CHATGPT)) { + addSupermemoryButtonToMemoriesDialog() + addSaveChatGPTElementBeforeComposerBtn() + } + if (DOMUtils.isOnDomain(DOMAINS.TWITTER)) { + addTwitterImportButton() + //addSaveTweetElement(); + } + }) + + observer.observe(document.body, { + childList: true, + subtree: true, + }) + + if ( + window.location.hostname === "chatgpt.com" || + window.location.hostname === "chat.openai.com" + ) { + addSupermemoryButtonToMemoriesDialog() + addSaveChatGPTElementBeforeComposerBtn() + } + if ( + window.location.hostname === "x.com" || + window.location.hostname === "twitter.com" + ) { + addTwitterImportButton() + //addSaveTweetElement(); + } + } + + if (DOMUtils.isOnDomain(DOMAINS.TWITTER)) { + setTimeout(() => { + addTwitterImportButton() // Wait 2 seconds for page to load + //addSaveTweetElement(); + }, 2000) + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", observeForMemoriesDialog) + } else { + observeForMemoriesDialog() + } + + async function saveMemory() { + try { + DOMUtils.showToast("loading") + + const highlightedText = window.getSelection()?.toString() || "" + + const url = window.location.href + + const html = document.documentElement.outerHTML + + const response = await browser.runtime.sendMessage({ + action: MESSAGE_TYPES.SAVE_MEMORY, + data: { + html, + highlightedText, + url, + }, + }) + + console.log("Response from enxtension:", response) + if (response.success) { + DOMUtils.showToast("success") + } else { + DOMUtils.showToast("error") + } + } catch (error) { + console.error("Error saving memory:", error) + DOMUtils.showToast("error") + } + } + + async function getRelatedMemories() { + try { + const userQuery = + document.getElementById("prompt-textarea")?.textContent || "" + + const response = await browser.runtime.sendMessage({ + action: MESSAGE_TYPES.GET_RELATED_MEMORIES, + data: userQuery, + }) + + if (response.success && response.data) { + const promptElement = document.getElementById("prompt-textarea") + if (promptElement) { + const currentContent = promptElement.innerHTML + promptElement.innerHTML = `${currentContent}
Supermemories: ${response.data}` + } + } + } catch (error) { + console.error("Error getting related memories:", error) + } + } + + function addSupermemoryButtonToMemoriesDialog() { + const dialogs = document.querySelectorAll('[role="dialog"]') + let memoriesDialog: HTMLElement | null = null + + for (const dialog of dialogs) { + const headerText = dialog.querySelector("h2") + if (headerText?.textContent?.includes("Saved memories")) { + memoriesDialog = dialog as HTMLElement + break + } + } + + if (!memoriesDialog) return + + if (memoriesDialog.querySelector("#supermemory-save-button")) return + + const deleteAllContainer = memoriesDialog.querySelector( + ".mt-5.flex.justify-end", + ) + if (!deleteAllContainer) return + + const supermemoryButton = document.createElement("button") + supermemoryButton.id = "supermemory-save-button" + supermemoryButton.className = "btn relative btn-primary-outline mr-2" + + const iconUrl = browser.runtime.getURL("/icon-16.png") + + supermemoryButton.innerHTML = ` +
+ supermemory + Save to supermemory +
+ ` + + supermemoryButton.style.cssText = ` + background: #1C2026 !important; + color: white !important; + border: 1px solid #1C2026 !important; + border-radius: 9999px !important; + padding: 10px 16px !important; + font-weight: 500 !important; + font-size: 14px !important; + margin-right: 8px !important; + cursor: pointer !important; + ` + + supermemoryButton.addEventListener("mouseenter", () => { + supermemoryButton.style.backgroundColor = "#2B2E33" + }) + + supermemoryButton.addEventListener("mouseleave", () => { + supermemoryButton.style.backgroundColor = "#1C2026" + }) + + supermemoryButton.addEventListener("click", async () => { + await saveMemoriesToSupermemory() + }) + + deleteAllContainer.insertBefore( + supermemoryButton, + deleteAllContainer.firstChild, + ) + } + + async function saveMemoriesToSupermemory() { + try { + DOMUtils.showToast("loading") + + const memoriesTable = document.querySelector( + '[role="dialog"] table tbody', + ) + if (!memoriesTable) { + DOMUtils.showToast("error") + return + } + + const memoryRows = memoriesTable.querySelectorAll("tr") + const memories: string[] = [] + + memoryRows.forEach((row) => { + const memoryCell = row.querySelector("td .py-2.whitespace-pre-wrap") + if (memoryCell?.textContent) { + memories.push(memoryCell.textContent.trim()) + } + }) + + console.log("Memories:", memories) + + if (memories.length === 0) { + DOMUtils.showToast("error") + return + } + + const combinedContent = `ChatGPT Saved Memories:\n\n${memories.map((memory, index) => `${index + 1}. ${memory}`).join("\n\n")}` + + const response = await browser.runtime.sendMessage({ + action: "saveMemory", + data: { + html: combinedContent, + }, + }) + + if (response.success) { + DOMUtils.showToast("success") + } else { + DOMUtils.showToast("error") + } + } catch (error) { + console.error("Error saving memories to supermemory:", error) + DOMUtils.showToast("error") + } + } + + function addTwitterImportButton() { + if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) { + return + } + + if (DOMUtils.elementExists(ELEMENT_IDS.TWITTER_IMPORT_BUTTON)) { + return + } + + const button = createTwitterImportButton(() => { + showTwitterImportUI() + }) + + document.body.appendChild(button) + } + + function showTwitterImportUI() { + if (twitterImportUI) { + twitterImportUI.remove() + } + + isTwitterImportOpen = true + + // Check if user is authenticated + browser.storage.local.get([STORAGE_KEYS.BEARER_TOKEN], (result) => { + const isAuthenticated = !!result[STORAGE_KEYS.BEARER_TOKEN] + + twitterImportUI = createTwitterImportUI( + hideTwitterImportUI, + async () => { + try { + await browser.runtime.sendMessage({ + type: MESSAGE_TYPES.BATCH_IMPORT_ALL, + }) + } catch (error) { + console.error("Error starting import:", error) + } + }, + isAuthenticated, + ) + + document.body.appendChild(twitterImportUI) + }) + } + + function hideTwitterImportUI() { + if (twitterImportUI) { + twitterImportUI.remove() + twitterImportUI = null + } + isTwitterImportOpen = false + } + + function updateTwitterImportUI(message: { + type: string + importedMessage?: string + totalImported?: number + }) { + if (!isTwitterImportOpen || !twitterImportUI) return + + const statusDiv = twitterImportUI.querySelector(`#${ELEMENT_IDS.TWITTER_IMPORT_STATUS}`) + const button = twitterImportUI.querySelector(`#${ELEMENT_IDS.TWITTER_IMPORT_BTN}`) + + if (message.type === MESSAGE_TYPES.IMPORT_UPDATE) { + if (statusDiv) { + statusDiv.innerHTML = ` +
+
+ ${message.importedMessage} +
+ ` + } + if (button) { + ;(button as HTMLButtonElement).disabled = true + ;(button as HTMLButtonElement).textContent = "Importing..." + } + } + + if (message.type === MESSAGE_TYPES.IMPORT_DONE) { + if (statusDiv) { + statusDiv.innerHTML = ` +
+ + Successfully imported ${message.totalImported} tweets! +
+ ` + } + + setTimeout(() => { + hideTwitterImportUI() + }, 3000) + } + } + + function addSaveChatGPTElementBeforeComposerBtn() { + if (!DOMUtils.isOnDomain(DOMAINS.CHATGPT)) { + return + } + + const composerButtons = document.querySelectorAll("button.composer-btn") + + composerButtons.forEach((button) => { + if (button.hasAttribute("data-supermemory-icon-added-before")) { + return + } + + const parent = button.parentElement + if (!parent) return + + const parentSiblings = parent.parentElement?.children + if (!parentSiblings) return + + let hasSpeechButtonSibling = false + for (const sibling of parentSiblings) { + if ( + sibling.getAttribute("data-testid") === + "composer-speech-button-container" + ) { + hasSpeechButtonSibling = true + break + } + } + + if (!hasSpeechButtonSibling) return + + const grandParent = parent.parentElement + if (!grandParent) return + + const existingIcon = grandParent.querySelector( + `#${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer`, + ) + if (existingIcon) { + button.setAttribute("data-supermemory-icon-added-before", "true") + return + } + + const saveChatGPTElement = createChatGPTInputBarElement(async () => { + await getRelatedMemories() + }) + + saveChatGPTElement.id = `${ELEMENT_IDS.CHATGPT_INPUT_BAR_ELEMENT}-before-composer-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` + + button.setAttribute("data-supermemory-icon-added-before", "true") + + grandParent.insertBefore(saveChatGPTElement, parent) + }) + } + + // TODO: Add Tweet Capture Functionality + function _addSaveTweetElement() { + if (!DOMUtils.isOnDomain(DOMAINS.TWITTER)) { + return + } + + const targetDivs = document.querySelectorAll( + "div.css-175oi2r.r-18u37iz.r-1h0z5md.r-1wron08", + ) + + targetDivs.forEach((targetDiv) => { + if (targetDiv.hasAttribute("data-supermemory-icon-added")) { + return + } + + const previousElement = targetDiv.previousElementSibling + if (previousElement?.id?.startsWith(ELEMENT_IDS.SAVE_TWEET_ELEMENT)) { + targetDiv.setAttribute("data-supermemory-icon-added", "true") + return + } + + const saveTweetElement = createSaveTweetElement(async () => { + await saveMemory() + }) + + saveTweetElement.id = `${ELEMENT_IDS.SAVE_TWEET_ELEMENT}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` + + targetDiv.setAttribute("data-supermemory-icon-added", "true") + + targetDiv.parentNode?.insertBefore(saveTweetElement, targetDiv) + }) + } + + document.addEventListener("keydown", async (event) => { + if ( + (event.ctrlKey || event.metaKey) && + event.shiftKey && + event.key === "m" + ) { + event.preventDefault() + await saveMemory() + } + }) + + window.addEventListener("message", (event) => { + if (event.source !== window) { + return + } + const bearerToken = event.data.token + + if (bearerToken) { + if ( + !( + window.location.hostname === "localhost" || + window.location.hostname === "supermemory.ai" || + window.location.hostname === "app.supermemory.ai" + ) + ) { + console.log( + "Bearer token is only allowed to be used on localhost or supermemory.ai", + ) + return + } + + chrome.storage.local.set({ + [STORAGE_KEYS.BEARER_TOKEN]: bearerToken, + }, () => {}) + } + }) + }, +}) diff --git a/apps/browser-extension/entrypoints/popup/App.css b/apps/browser-extension/entrypoints/popup/App.css new file mode 100644 index 00000000..4924360e --- /dev/null +++ b/apps/browser-extension/entrypoints/popup/App.css @@ -0,0 +1,708 @@ +/* Custom Font Definitions */ +@font-face { + font-family: "Space Grotesk"; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url("/fonts/SpaceGrotesk-Light.ttf") format("truetype"); +} + +@font-face { + font-family: "Space Grotesk"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("/fonts/SpaceGrotesk-Regular.ttf") format("truetype"); +} + +@font-face { + font-family: "Space Grotesk"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("/fonts/SpaceGrotesk-Medium.ttf") format("truetype"); +} + +@font-face { + font-family: "Space Grotesk"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("/fonts/SpaceGrotesk-SemiBold.ttf") format("truetype"); +} + +@font-face { + font-family: "Space Grotesk"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("/fonts/SpaceGrotesk-Bold.ttf") format("truetype"); +} + +.popup-container { + width: 320px; + padding: 0; + font-family: + "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + sans-serif; + background: #ffffff; + border-radius: 8px; + position: relative; + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px; + border-bottom: 1px solid #e5e7eb; + position: relative; +} + +.header .logo { + width: 32px; + height: 32px; + flex-shrink: 0; +} + +.header h1 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #000000; + flex: 1; +} + +.header-sign-out { + background: none; + border: none; + font-size: 16px; + cursor: pointer; + color: #6c757d; + padding: 4px; + border-radius: 4px; + transition: + color 0.2s ease, + background-color 0.2s ease; +} + +.header-sign-out:hover { + color: #000000; + background-color: #f1f3f4; +} + +.content { + padding: 16px; +} + +.status { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + font-size: 14px; + color: #000000; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.status-indicator.signed-in { + background-color: #000000; +} + +.status-indicator.signed-out { + background-color: #666666; +} + +.sign-out-btn { + width: 100%; + padding: 8px 16px; + background-color: #000000; + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; +} + +.sign-out-btn:hover { + background-color: #333333; +} + +.instruction { + margin: 0; + font-size: 13px; + color: #666666; + line-height: 1.4; +} + +.login-btn { + background: none; + border: none; + color: #1976d2; + cursor: pointer; + text-decoration: underline; + font-size: 13px; + padding: 0; +} + +.login-btn:hover { + color: #1565c0; +} + +.authenticated { + text-align: left; +} + +.unauthenticated { + text-align: center; + padding: 8px 0; +} + +/* Login Screen Styles */ +.login-intro { + margin-bottom: 32px; +} + +.login-title { + margin: 0 0 16px 0; + font-size: 14px; + font-weight: 400; + color: #000000; + line-height: 1.3; +} + +.features-list { + list-style: none; + padding: 0; + margin: 0; + text-align: left; +} + +.features-list li { + padding: 6px 0; + font-size: 14px; + color: #000000; + position: relative; + padding-left: 20px; +} + +.features-list li::before { + content: "•"; + position: absolute; + left: 0; + color: #000000; + font-weight: bold; +} + +.login-actions { + margin-top: 32px; +} + +.login-help { + margin: 0 0 16px 0; + font-size: 14px; + color: #6c757d; +} + +.help-link { + background: none; + border: none; + color: #4285f4; + cursor: pointer; + text-decoration: underline; + font-size: 14px; + padding: 0; +} + +.help-link:hover { + color: #1a73e8; +} + +.login-primary-btn { + width: 100%; + padding: 12px 24px; + background-color: #374151; + color: white; + border: none; + border-radius: 24px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.login-primary-btn:hover:not(:disabled) { + background-color: #1f2937; +} + +.login-primary-btn:disabled { + background-color: #9e9e9e; + cursor: not-allowed; +} + +/* Tab Navigation Styles */ +.tab-navigation { + display: flex; + background-color: #f1f3f4; + border-radius: 8px; + padding: 4px; + margin-bottom: 16px; +} + +.tab-btn { + flex: 1; + padding: 8px 16px; + background: transparent; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + color: #6c757d; + cursor: pointer; + transition: all 0.2s ease; + outline: none; + box-shadow: none; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +.tab-btn:focus { + outline: none; + box-shadow: none; + border: none; +} + +.tab-btn:active { + outline: none; + box-shadow: none; +} + +.tab-btn.active { + background-color: #ffffff; + color: #000000; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.tab-btn:hover:not(.active) { + color: #374151; +} + +/* Tab Content */ +.tab-content { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 200px; +} + +/* Save Action at Bottom */ +.save-action { + margin-top: auto; + padding-top: 16px; +} + +/* Import Actions */ +.import-actions { + display: flex; + flex-direction: column; + gap: 16px; +} + +.import-item { + display: flex; + flex-direction: column; + gap: 8px; +} + +.import-instructions { + margin: 0; + font-size: 12px; + color: #6c757d; + line-height: 1.3; + padding-left: 4px; +} + +/* Save Section Styles */ +.save-section { + margin-bottom: 16px; +} + +.current-page { + margin-bottom: 0; +} + +.page-info { + background-color: #f8f9fa; + padding: 12px; + border-radius: 6px; + border: 1px solid #e9ecef; +} + +.page-title { + margin: 0 0 4px 0; + font-size: 14px; + font-weight: 600; + color: #000000; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.page-url { + margin: 0; + font-size: 12px; + color: #6c757d; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.save-page-btn { + width: 100%; + padding: 12px 16px; + background-color: #1976d2; + color: white; + border: none; + border-radius: 6px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.save-page-btn:hover:not(:disabled) { + background-color: #1565c0; +} + +.save-page-btn:disabled { + background-color: #9e9e9e; + cursor: not-allowed; +} + +.secondary-actions { + margin-top: 16px; +} + +.secondary-btn { + width: 100%; + padding: 8px 12px; + background-color: white; + color: #6c757d; + border: 1px solid #e4e6eb; + border-radius: 6px; + font-size: 13px; + font-weight: 400; + cursor: pointer; + transition: + background-color 0.2s ease, + color 0.2s ease; +} + +.secondary-btn:hover { + background-color: #f8f9fa; + color: #000000; +} + +.actions { + display: flex; + flex-direction: column; + gap: 12px; +} + +.chatgpt-btn { + width: 100%; + padding: 12px 12px; + background-color: white; + color: black; + border: 1px solid #e4e6eb; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s ease; +} + +.chatgpt-btn:hover { + background-color: #f0f0f0; + border-color: #e4e6eb; +} + +.chatgpt-logo { + width: 18px; + height: 18px; + flex-shrink: 0; + margin-right: 8px; +} + +.twitter-btn { + width: 100%; + padding: 12px 12px; + background-color: white; + color: black; + border: 1px solid #e4e6eb; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s ease; + outline: none; + box-shadow: none; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +.twitter-btn:hover { + background-color: #f0f0f0; + border-color: #e4e6eb; +} + +.twitter-btn:focus { + outline: none; + box-shadow: none; +} + +.twitter-logo { + width: 18px; + height: 18px; + flex-shrink: 0; + margin-right: 8px; +} + +/* Project Selection Styles */ +.project-section { + margin-bottom: 0; +} + +.project-selector-btn { + width: 100%; + background: none; + border: none; + padding: 0; + cursor: pointer; + text-align: left; +} + +.project-selector-content { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + background-color: #f8f9fa; + border-radius: 8px; + border: 1px solid #e9ecef; + transition: + background-color 0.2s ease, + border-color 0.2s ease; +} + +.project-selector-btn:hover .project-selector-content { + background-color: #e9ecef; + border-color: #ced4da; +} + +.project-label { + font-size: 14px; + font-weight: 500; + color: #495057; +} + +.project-value { + display: flex; + align-items: center; + gap: 8px; +} + +.project-name { + font-size: 14px; + font-weight: 500; + color: #000000; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + max-width: 120px; +} + +.project-arrow { + color: #6c757d; + flex-shrink: 0; + transition: transform 0.2s ease; +} + +.project-selector-btn:hover .project-arrow { + color: #495057; + transform: translateX(2px); +} + +.project-count { + font-size: 12px; + color: #6c757d; + margin-left: 8px; +} + +.project-none { + font-size: 14px; + color: #6c757d; + font-style: italic; +} + +/* Project Selector Modal */ +.project-selector { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ffffff; + border-radius: 8px; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: flex; + flex-direction: column; +} + +.project-selector-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid #e5e7eb; + font-size: 16px; + font-weight: 600; + color: #000000; + flex-shrink: 0; +} + +.project-header-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.project-logout-btn { + background: none; + border: none; + font-size: 14px; + color: #6c757d; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + transition: + color 0.2s ease, + background-color 0.2s ease; + outline: none; +} + +.project-logout-btn:hover { + color: #dc3545; + background-color: #f8f9fa; +} + +.project-logout-btn:focus { + outline: none; +} + +.project-close-btn { + background: none; + border: none; + font-size: 20px; + cursor: pointer; + color: #6c757d; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.project-close-btn:hover { + color: #000000; +} + +.project-loading { + padding: 32px 16px; + text-align: center; + color: #6c757d; + font-size: 14px; +} + +.project-list { + flex: 1; + overflow-y: auto; + min-height: 0; +} + +.project-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + cursor: pointer; + transition: background-color 0.2s ease; + border-bottom: 1px solid #f1f3f4; + background: none; + border: none; + width: 100%; + text-align: left; +} + +.project-item:hover { + background-color: #f8f9fa; +} + +.project-item:last-child { + border-bottom: none; +} + +.project-item.selected { + background-color: #e3f2fd; +} + +.project-item-info { + display: flex; + flex-direction: column; + flex: 1; + gap: 2px; +} + +.project-item-name { + font-size: 14px; + font-weight: 500; + color: #000000; + word-wrap: break-word; + overflow-wrap: break-word; + hyphens: auto; + line-height: 1.3; +} + +.project-item-count { + font-size: 12px; + color: #6c757d; +} + +.project-item-check { + color: #1976d2; + font-weight: bold; + font-size: 16px; +} diff --git a/apps/browser-extension/entrypoints/popup/App.tsx b/apps/browser-extension/entrypoints/popup/App.tsx new file mode 100644 index 00000000..2f42747a --- /dev/null +++ b/apps/browser-extension/entrypoints/popup/App.tsx @@ -0,0 +1,394 @@ +import { useQueryClient } from "@tanstack/react-query" +import { useEffect, useState } from "react" +import "./App.css" +import { STORAGE_KEYS } from "../../utils/constants" +import { + useDefaultProject, + useProjects, + useSetDefaultProject, +} from "../../utils/query-hooks" +import type { Project } from "../../utils/types" + +function App() { + const [userSignedIn, setUserSignedIn] = useState(false) + const [loading, setLoading] = useState(true) + const [showProjectSelector, setShowProjectSelector] = useState(false) + const [currentUrl, setCurrentUrl] = useState("") + const [currentTitle, setCurrentTitle] = useState("") + const [saving, setSaving] = useState(false) + const [activeTab, setActiveTab] = useState<"save" | "imports">("save") + + const queryClient = useQueryClient() + const { data: projects = [], isLoading: loadingProjects } = useProjects({ + enabled: userSignedIn, + }) + const { data: defaultProject } = useDefaultProject({ + enabled: userSignedIn, + }) + const setDefaultProjectMutation = useSetDefaultProject() + + useEffect(() => { + const checkAuthStatus = async () => { + try { + const result = await chrome.storage.local.get([ + STORAGE_KEYS.BEARER_TOKEN, + ]) + const isSignedIn = !!result[STORAGE_KEYS.BEARER_TOKEN] + setUserSignedIn(isSignedIn) + } catch (error) { + console.error("Error checking auth status:", error) + setUserSignedIn(false) + } finally { + setLoading(false) + } + } + + const getCurrentTab = async () => { + try { + const tabs = await chrome.tabs.query({ + active: true, + currentWindow: true, + }) + if (tabs.length > 0 && tabs[0].url && tabs[0].title) { + setCurrentUrl(tabs[0].url) + setCurrentTitle(tabs[0].title) + } + } catch (error) { + console.error("Error getting current tab:", error) + } + } + + checkAuthStatus() + getCurrentTab() + }, []) + + const handleProjectSelect = (project: Project) => { + setDefaultProjectMutation.mutate(project, { + onSuccess: () => { + setShowProjectSelector(false) + }, + onError: (error) => { + console.error("Error setting default project:", error) + }, + }) + } + + const handleShowProjectSelector = () => { + setShowProjectSelector(true) + } + + useEffect(() => { + if (!defaultProject && projects.length > 0) { + const firstProject = projects[0] + setDefaultProjectMutation.mutate(firstProject) + } + }, [defaultProject, projects, setDefaultProjectMutation]) + + const handleSaveCurrentPage = async () => { + setSaving(true) + try { + const tabs = await chrome.tabs.query({ + active: true, + currentWindow: true, + }) + if (tabs.length > 0 && tabs[0].id) { + await chrome.tabs.sendMessage(tabs[0].id, { + action: "saveMemory", + }) + } + } catch (error) { + console.error("Failed to save current page:", error) + } finally { + setSaving(false) + } + } + + const handleSignOut = async () => { + try { + await chrome.storage.local.remove([STORAGE_KEYS.BEARER_TOKEN]) + setUserSignedIn(false) + queryClient.clear() + } catch (error) { + console.error("Error signing out:", error) + } + } + + if (loading) { + return ( +
+
+ supermemory +

supermemory

+
+
+
Loading...
+
+
+ ) + } + + return ( +
+
+ supermemory + {userSignedIn && ( + + )} +
+
+ {userSignedIn ? ( +
+ {/* Tab Navigation */} +
+ + +
+ + {/* Tab Content */} + {activeTab === "save" ? ( +
+ {/* Current Page Info */} +
+
+

+ {currentTitle || "Current Page"} +

+

{currentUrl}

+
+
+ + {/* Project Selection */} +
+ +
+ + {/* Save Button at Bottom */} +
+ +
+
+ ) : ( +
+ {/* Import Actions */} +
+
+ +
+ +
+ +

+ Click on supermemory on top right to import bookmarks +

+
+
+
+ )} + + {showProjectSelector && ( +
+
+ Select the Project + +
+ {loadingProjects ? ( +
Loading projects...
+ ) : ( +
+ {projects.map((project) => ( + + ))} +
+ )} +
+ )} +
+ ) : ( +
+
+

+ Login to unlock all chrome extension features +

+ +
    +
  • Save any page to your supermemory
  • +
  • Import all your Twitter / X Bookmarks
  • +
  • Import your ChatGPT Memories
  • +
+
+ +
+

+ Having trouble logging in?{" "} + +

+ + +
+
+ )} +
+
+ ) +} + +export default App diff --git a/apps/browser-extension/entrypoints/popup/index.html b/apps/browser-extension/entrypoints/popup/index.html new file mode 100644 index 00000000..ed4cb949 --- /dev/null +++ b/apps/browser-extension/entrypoints/popup/index.html @@ -0,0 +1,13 @@ + + + + + + Default Popup Title + + + +
+ + + diff --git a/apps/browser-extension/entrypoints/popup/main.tsx b/apps/browser-extension/entrypoints/popup/main.tsx new file mode 100644 index 00000000..746e1018 --- /dev/null +++ b/apps/browser-extension/entrypoints/popup/main.tsx @@ -0,0 +1,17 @@ +import { QueryClientProvider } from "@tanstack/react-query" +import React from "react" +import ReactDOM from "react-dom/client" +import { queryClient } from "../../utils/query-client" +import App from "./App.js" +import "./style.css" + +const rootElement = document.getElementById("root") +if (rootElement) { + ReactDOM.createRoot(rootElement).render( + + + + + , + ) +} diff --git a/apps/browser-extension/entrypoints/popup/style.css b/apps/browser-extension/entrypoints/popup/style.css new file mode 100644 index 00000000..ee057e19 --- /dev/null +++ b/apps/browser-extension/entrypoints/popup/style.css @@ -0,0 +1,70 @@ +:root { + font-family: + "Space Grotesk", Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/apps/browser-extension/entrypoints/welcome/Welcome.tsx b/apps/browser-extension/entrypoints/welcome/Welcome.tsx new file mode 100644 index 00000000..31e0c424 --- /dev/null +++ b/apps/browser-extension/entrypoints/welcome/Welcome.tsx @@ -0,0 +1,95 @@ +function Welcome() { + return ( +
+
+ {/* Header */} +
+ supermemory +

+ Your AI-powered second brain for saving and organizing everything + that matters +

+
+ + {/* Features Section */} +
+

What can you do with supermemory?

+ +
+
+
💾
+

Save Any Page

+

+ Instantly save web pages, articles, and content to your personal + knowledge base +

+
+ +
+
🐦
+

Import Twitter/X Bookmarks

+

+ Bring all your saved tweets and bookmarks into one organized + place +

+
+ +
+
🤖
+

Import ChatGPT Memories

+

+ Keep your important AI conversations and insights accessible +

+
+ +
+
🔍
+

AI-Powered Search

+

+ Find anything you've saved using intelligent semantic search +

+
+
+
+ + {/* Actions */} +
+ +
+ + {/* Footer */} +
+

+ Learn more at{" "} + + supermemory.ai + +

+
+
+
+ ) +} + +export default Welcome diff --git a/apps/browser-extension/entrypoints/welcome/index.html b/apps/browser-extension/entrypoints/welcome/index.html new file mode 100644 index 00000000..92bb26e0 --- /dev/null +++ b/apps/browser-extension/entrypoints/welcome/index.html @@ -0,0 +1,13 @@ + + + + + + + Welcome to supermemory + + +
+ + + \ No newline at end of file diff --git a/apps/browser-extension/entrypoints/welcome/main.tsx b/apps/browser-extension/entrypoints/welcome/main.tsx new file mode 100644 index 00000000..2df8dbf1 --- /dev/null +++ b/apps/browser-extension/entrypoints/welcome/main.tsx @@ -0,0 +1,17 @@ +import { QueryClientProvider } from "@tanstack/react-query" +import React from "react" +import ReactDOM from "react-dom/client" +import { queryClient } from "../../utils/query-client" +import Welcome from "./Welcome" +import "./welcome.css" + +const rootElement = document.getElementById("root") +if (rootElement) { + ReactDOM.createRoot(rootElement).render( + + + + + , + ) +} diff --git a/apps/browser-extension/entrypoints/welcome/welcome.css b/apps/browser-extension/entrypoints/welcome/welcome.css new file mode 100644 index 00000000..d2c8d43d --- /dev/null +++ b/apps/browser-extension/entrypoints/welcome/welcome.css @@ -0,0 +1,275 @@ +/* Custom Font Definitions */ +@font-face { + font-family: "Space Grotesk"; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url("/fonts/SpaceGrotesk-Light.ttf") format("truetype"); +} + +@font-face { + font-family: "Space Grotesk"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("/fonts/SpaceGrotesk-Regular.ttf") format("truetype"); +} + +@font-face { + font-family: "Space Grotesk"; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url("/fonts/SpaceGrotesk-Medium.ttf") format("truetype"); +} + +@font-face { + font-family: "Space Grotesk"; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url("/fonts/SpaceGrotesk-SemiBold.ttf") format("truetype"); +} + +@font-face { + font-family: "Space Grotesk"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("/fonts/SpaceGrotesk-Bold.ttf") format("truetype"); +} + +/* Welcome Page Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: + "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + sans-serif; + background: #ffffff; + color: #000000; + line-height: 1.5; +} + +.welcome-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%); +} + +.welcome-content { + max-width: 800px; + width: 100%; + text-align: center; +} + +/* Header Styles */ +.welcome-header { + margin-bottom: 48px; +} + +.welcome-logo { + height: 64px; + margin-bottom: 24px; +} + +.welcome-title { + font-size: 32px; + font-weight: 700; + color: #000000; + margin-bottom: 16px; +} + +.welcome-subtitle { + font-size: 18px; + color: #6c757d; + font-weight: 400; + max-width: 600px; + margin: 0 auto; +} + +/* Features Section */ +.welcome-features { + margin-bottom: 48px; +} + +.features-title { + font-size: 24px; + font-weight: 600; + color: #000000; + margin-bottom: 32px; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 24px; + margin-bottom: 32px; +} + +.feature-card { + background: #ffffff; + border: 1px solid #e9ecef; + border-radius: 12px; + padding: 24px; + text-align: center; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +.feature-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); + border-color: #ced4da; +} + +.feature-icon { + font-size: 32px; + margin-bottom: 16px; + display: block; +} + +.feature-card h3 { + font-size: 18px; + font-weight: 600; + color: #000000; + margin-bottom: 12px; +} + +.feature-card p { + font-size: 14px; + color: #6c757d; + line-height: 1.4; +} + +.steps { + display: flex; + justify-content: center; + gap: 32px; + flex-wrap: wrap; +} + +.step { + display: flex; + align-items: center; + gap: 16px; + max-width: 300px; + text-align: left; +} + +.step-number { + width: 48px; + height: 48px; + background: #374151; + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 600; + flex-shrink: 0; +} + +.step-content h3 { + font-size: 16px; + font-weight: 600; + color: #000000; + margin-bottom: 4px; +} + +.step-content p { + font-size: 14px; + color: #6c757d; + line-height: 1.3; +} + +/* Actions Section */ +.welcome-actions { + margin-bottom: 32px; +} + +.login-primary-btn { + width: auto; + min-width: 200px; + padding: 16px 32px; + background-color: #374151; + color: white; + border: none; + border-radius: 24px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease; + margin-bottom: 16px; + outline: none; +} + +.login-primary-btn:hover:not(:disabled) { + background-color: #1f2937; +} + +.login-primary-btn:disabled { + background-color: #9e9e9e; + cursor: not-allowed; +} + +/* Footer */ +.welcome-footer { + border-top: 1px solid #e9ecef; + padding-top: 24px; + margin-top: 32px; +} + +.welcome-footer p { + font-size: 14px; + color: #6c757d; +} + +.footer-link { + color: #4285f4; + text-decoration: none; +} + +.footer-link:hover { + text-decoration: underline; + color: #1a73e8; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .welcome-container { + padding: 16px; + } + + .welcome-title { + font-size: 28px; + } + + .welcome-subtitle { + font-size: 16px; + } + + .features-grid { + grid-template-columns: 1fr; + gap: 16px; + } + + .steps { + flex-direction: column; + align-items: center; + } + + .step { + max-width: 100%; + text-align: center; + flex-direction: column; + } +} diff --git a/apps/browser-extension/fonts/SpaceGrotesk-Bold.ttf b/apps/browser-extension/fonts/SpaceGrotesk-Bold.ttf new file mode 100644 index 00000000..8a8611a5 Binary files /dev/null and b/apps/browser-extension/fonts/SpaceGrotesk-Bold.ttf differ diff --git a/apps/browser-extension/fonts/SpaceGrotesk-Light.ttf b/apps/browser-extension/fonts/SpaceGrotesk-Light.ttf new file mode 100644 index 00000000..0f03f08b Binary files /dev/null and b/apps/browser-extension/fonts/SpaceGrotesk-Light.ttf differ diff --git a/apps/browser-extension/fonts/SpaceGrotesk-Medium.ttf b/apps/browser-extension/fonts/SpaceGrotesk-Medium.ttf new file mode 100644 index 00000000..e530cf83 Binary files /dev/null and b/apps/browser-extension/fonts/SpaceGrotesk-Medium.ttf differ diff --git a/apps/browser-extension/fonts/SpaceGrotesk-Regular.ttf b/apps/browser-extension/fonts/SpaceGrotesk-Regular.ttf new file mode 100644 index 00000000..8215f81e Binary files /dev/null and b/apps/browser-extension/fonts/SpaceGrotesk-Regular.ttf differ diff --git a/apps/browser-extension/fonts/SpaceGrotesk-SemiBold.ttf b/apps/browser-extension/fonts/SpaceGrotesk-SemiBold.ttf new file mode 100644 index 00000000..e05b9673 Binary files /dev/null and b/apps/browser-extension/fonts/SpaceGrotesk-SemiBold.ttf differ diff --git a/apps/browser-extension/fonts/SpaceGrotesk-VariableFont_wght.ttf b/apps/browser-extension/fonts/SpaceGrotesk-VariableFont_wght.ttf new file mode 100644 index 00000000..2c6cc59a Binary files /dev/null and b/apps/browser-extension/fonts/SpaceGrotesk-VariableFont_wght.ttf differ diff --git a/apps/browser-extension/package.json b/apps/browser-extension/package.json new file mode 100644 index 00000000..fae767d0 --- /dev/null +++ b/apps/browser-extension/package.json @@ -0,0 +1,30 @@ +{ + "name": "supermemory-browser-extension", + "description": "Browser extension for the supermemory app", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "wxt --port 3001", + "dev:firefox": "wxt -b firefox", + "build": "wxt build", + "build:firefox": "wxt build -b firefox", + "zip": "wxt zip", + "zip:firefox": "wxt zip -b firefox", + "compile": "tsc --noEmit", + "postinstall": "wxt prepare" + }, + "dependencies": { + "@tanstack/react-query": "^5.85.5", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@types/chrome": "^0.1.4", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.3", + "@wxt-dev/module-react": "^1.1.3", + "typescript": "^5.8.3", + "wxt": "^0.20.6" + } +} diff --git a/apps/browser-extension/public/icon-128.png b/apps/browser-extension/public/icon-128.png new file mode 100644 index 00000000..059b2cce Binary files /dev/null and b/apps/browser-extension/public/icon-128.png differ diff --git a/apps/browser-extension/public/icon-16.png b/apps/browser-extension/public/icon-16.png new file mode 100644 index 00000000..549d267d Binary files /dev/null and b/apps/browser-extension/public/icon-16.png differ diff --git a/apps/browser-extension/public/icon-48.png b/apps/browser-extension/public/icon-48.png new file mode 100644 index 00000000..bf132193 Binary files /dev/null and b/apps/browser-extension/public/icon-48.png differ diff --git a/apps/browser-extension/tsconfig.json b/apps/browser-extension/tsconfig.json new file mode 100644 index 00000000..621fa129 --- /dev/null +++ b/apps/browser-extension/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./.wxt/tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "jsx": "react-jsx", + "types": ["chrome"] + } +} diff --git a/apps/browser-extension/utils/api.ts b/apps/browser-extension/utils/api.ts new file mode 100644 index 00000000..858e81b1 --- /dev/null +++ b/apps/browser-extension/utils/api.ts @@ -0,0 +1,156 @@ +/** + * API service for supermemory browser extension + */ +import { API_ENDPOINTS, STORAGE_KEYS } from "./constants" +import { + AuthenticationError, + type MemoryPayload, + type Project, + type ProjectsResponse, + SupermemoryAPIError, +} from "./types" + +/** + * Get bearer token from storage + */ +async function getBearerToken(): Promise { + const result = await chrome.storage.local.get([STORAGE_KEYS.BEARER_TOKEN]) + const token = result[STORAGE_KEYS.BEARER_TOKEN] + + if (!token) { + throw new AuthenticationError("Bearer token not found") + } + + return token +} + +/** + * Make authenticated API request + */ +async function makeAuthenticatedRequest( + endpoint: string, + options: RequestInit = {}, +): Promise { + const token = await getBearerToken() + + const response = await fetch(`${API_ENDPOINTS.SUPERMEMORY_API}${endpoint}`, { + ...options, + credentials: "omit", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...options.headers, + }, + }) + + if (!response.ok) { + if (response.status === 401) { + throw new AuthenticationError("Invalid or expired token") + } + throw new SupermemoryAPIError( + `API request failed: ${response.statusText}`, + response.status, + ) + } + + return response.json() +} + +/** + * Fetch all projects from API + */ +export async function fetchProjects(): Promise { + try { + const response = + await makeAuthenticatedRequest("/v3/projects") + return response.projects + } catch (error) { + console.error("Failed to fetch projects:", error) + throw error + } +} + +/** + * Get default project from storage + */ +export async function getDefaultProject(): Promise { + try { + const result = await chrome.storage.local.get([ + STORAGE_KEYS.DEFAULT_PROJECT, + ]) + return result[STORAGE_KEYS.DEFAULT_PROJECT] || null + } catch (error) { + console.error("Failed to get default project:", error) + return null + } +} + +/** + * Set default project in storage + */ +export async function setDefaultProject(project: Project): Promise { + try { + await chrome.storage.local.set({ + [STORAGE_KEYS.DEFAULT_PROJECT]: project, + }) + } catch (error) { + console.error("Failed to set default project:", error) + throw error + } +} + +/** + * Save memory to Supermemory API + */ +export async function saveMemory(payload: MemoryPayload): Promise { + try { + const response = await makeAuthenticatedRequest("/v3/memories", { + method: "POST", + body: JSON.stringify(payload), + }) + return response + } catch (error) { + console.error("Failed to save memory:", error) + throw error + } +} + +/** + * Search memories using Supermemory API + */ +export async function searchMemories(query: string): Promise { + try { + const response = await makeAuthenticatedRequest("/v3/search", { + method: "POST", + body: JSON.stringify({ q: query }), + }) + return response + } catch (error) { + console.error("Failed to search memories:", error) + throw error + } +} + +/** + * Save tweet to Supermemory API (specific for Twitter imports) + */ +export async function saveTweet( + content: string, + metadata: { sm_source: string; [key: string]: unknown }, + containerTag = "sm_project_twitter_bookmarks", +): Promise { + try { + const payload: MemoryPayload = { + containerTags: [containerTag], + content, + metadata, + } + await saveMemory(payload) + } catch (error) { + if (error instanceof SupermemoryAPIError && error.statusCode === 409) { + // Skip if already exists (409 Conflict) + return + } + throw error + } +} diff --git a/apps/browser-extension/utils/constants.ts b/apps/browser-extension/utils/constants.ts new file mode 100644 index 00000000..b499a359 --- /dev/null +++ b/apps/browser-extension/utils/constants.ts @@ -0,0 +1,81 @@ +/** + * API Endpoints + */ +export const API_ENDPOINTS = { + SUPERMEMORY_API: import.meta.env.PROD + ? "https://api.supermemory.ai" + : "http://localhost:8787", + SUPERMEMORY_WEB: import.meta.env.PROD + ? "https://app.supermemory.ai" + : "http://localhost:3000", +} as const + +/** + * Storage Keys + */ +export const STORAGE_KEYS = { + BEARER_TOKEN: "bearer-token", + TOKENS_LOGGED: "tokens-logged", + TWITTER_COOKIE: "twitter-cookie", + TWITTER_CSRF: "twitter-csrf", + TWITTER_AUTH_TOKEN: "twitter-auth-token", + DEFAULT_PROJECT: "sm-default-project", +} as const + +/** + * DOM Element IDs + */ +export const ELEMENT_IDS = { + TWITTER_IMPORT_BUTTON: "sm-twitter-import-button", + TWITTER_IMPORT_STATUS: "sm-twitter-import-status", + TWITTER_CLOSE_BTN: "sm-twitter-close-btn", + TWITTER_IMPORT_BTN: "sm-twitter-import-btn", + TWITTER_SIGNIN_BTN: "sm-twitter-signin-btn", + SUPERMEMORY_TOAST: "sm-toast", + SUPERMEMORY_SAVE_BUTTON: "sm-save-button", + SAVE_TWEET_ELEMENT: "sm-save-tweet-element", + CHATGPT_INPUT_BAR_ELEMENT: "sm-chatgpt-input-bar-element", +} as const + +/** + * UI Configuration + */ +export const UI_CONFIG = { + BUTTON_SHOW_DELAY: 2000, // milliseconds + TOAST_DURATION: 3000, // milliseconds + RATE_LIMIT_BASE_WAIT: 60000, // 1 minute + PAGINATION_DELAY: 1000, // 1 second between requests +} as const + +/** + * Supported Domains + */ +export const DOMAINS = { + TWITTER: ["x.com", "twitter.com"], + CHATGPT: ["chatgpt.com", "chat.openai.com"], + SUPERMEMORY: ["localhost", "supermemory.ai", "app.supermemory.ai"], +} as const + +/** + * Container Tags + */ +export const CONTAINER_TAGS = { + TWITTER_BOOKMARKS: "sm_project_twitter_bookmarks", + DEFAULT_PROJECT: "sm_project_default", +} as const + +/** + * Message Types for extension communication + */ +export const MESSAGE_TYPES = { + SAVE_MEMORY: "sm-save-memory", + SHOW_TOAST: "sm-show-toast", + BATCH_IMPORT_ALL: "sm-batch-import-all", + IMPORT_UPDATE: "sm-import-update", + IMPORT_DONE: "sm-import-done", + GET_RELATED_MEMORIES: "sm-get-related-memories", +} as const + +export const CONTEXT_MENU_IDS = { + SAVE_TO_SUPERMEMORY: "sm-save-to-supermemory", +} as const diff --git a/apps/browser-extension/utils/query-client.ts b/apps/browser-extension/utils/query-client.ts new file mode 100644 index 00000000..c1839691 --- /dev/null +++ b/apps/browser-extension/utils/query-client.ts @@ -0,0 +1,24 @@ +/** + * React Query configuration for supermemory browser extension + */ +import { QueryClient } from "@tanstack/react-query" + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes (previously cacheTime) + retry: (failureCount, error) => { + // Don't retry on authentication errors + if (error?.constructor?.name === "AuthenticationError") { + return false + } + return failureCount < 3 + }, + refetchOnWindowFocus: false, + }, + mutations: { + retry: 1, + }, + }, +}) diff --git a/apps/browser-extension/utils/query-hooks.ts b/apps/browser-extension/utils/query-hooks.ts new file mode 100644 index 00000000..721a68ad --- /dev/null +++ b/apps/browser-extension/utils/query-hooks.ts @@ -0,0 +1,64 @@ +/** + * React Query hooks for supermemory API + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { + fetchProjects, + getDefaultProject, + saveMemory, + searchMemories, + setDefaultProject, +} from "./api" +import type { MemoryPayload } from "./types" + +// Query Keys +export const queryKeys = { + projects: ["projects"] as const, + defaultProject: ["defaultProject"] as const, +} + +// Projects Query +export function useProjects(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: queryKeys.projects, + queryFn: fetchProjects, + staleTime: 5 * 60 * 1000, // 5 minutes + enabled: options?.enabled ?? true, + }) +} + +// Default Project Query +export function useDefaultProject(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: queryKeys.defaultProject, + queryFn: getDefaultProject, + staleTime: 2 * 60 * 1000, // 2 minutes + enabled: options?.enabled ?? true, + }) +} + +// Set Default Project Mutation +export function useSetDefaultProject() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: setDefaultProject, + onSuccess: (_, project) => { + queryClient.setQueryData(queryKeys.defaultProject, project) + }, + }) +} + +// Save Memory Mutation +export function useSaveMemory() { + return useMutation({ + mutationFn: (payload: MemoryPayload) => saveMemory(payload), + }) +} + +// Search Memories Mutation +export function useSearchMemories() { + return useMutation({ + mutationFn: (query: string) => searchMemories(query), + }) +} diff --git a/apps/browser-extension/utils/twitter-auth.ts b/apps/browser-extension/utils/twitter-auth.ts new file mode 100644 index 00000000..3dfc50f6 --- /dev/null +++ b/apps/browser-extension/utils/twitter-auth.ts @@ -0,0 +1,101 @@ +/** + * Twitter Authentication Module + * Handles token capture and storage for Twitter API access + */ +import { STORAGE_KEYS } from "./constants" + +export interface TwitterAuthTokens { + cookie: string + csrf: string + auth: string +} + +/** + * Captures Twitter authentication tokens from web request headers + * @param details - Web request details containing headers + * @returns True if tokens were captured, false otherwise + */ +export function captureTwitterTokens( + details: chrome.webRequest.WebRequestDetails & { + requestHeaders?: chrome.webRequest.HttpHeader[] + }, +): boolean { + if (!(details.url.includes("x.com") || details.url.includes("twitter.com"))) { + return false + } + + const authHeader = details.requestHeaders?.find( + (header) => header.name.toLowerCase() === "authorization", + ) + const cookieHeader = details.requestHeaders?.find( + (header) => header.name.toLowerCase() === "cookie", + ) + const csrfHeader = details.requestHeaders?.find( + (header) => header.name.toLowerCase() === "x-csrf-token", + ) + + if (authHeader?.value && cookieHeader?.value && csrfHeader?.value) { + chrome.storage.session.get([STORAGE_KEYS.TOKENS_LOGGED], (result) => { + if (!result[STORAGE_KEYS.TOKENS_LOGGED]) { + console.log("Twitter auth tokens captured successfully") + chrome.storage.session.set({ [STORAGE_KEYS.TOKENS_LOGGED]: true }) + } + }) + + chrome.storage.session.set({ + [STORAGE_KEYS.TWITTER_COOKIE]: cookieHeader.value, + [STORAGE_KEYS.TWITTER_CSRF]: csrfHeader.value, + [STORAGE_KEYS.TWITTER_AUTH_TOKEN]: authHeader.value, + }) + + return true + } + + return false +} + +/** + * Retrieves stored Twitter authentication tokens + * @returns Promise resolving to tokens or null if not available + */ +export async function getTwitterTokens(): Promise { + const result = await chrome.storage.session.get([ + STORAGE_KEYS.TWITTER_COOKIE, + STORAGE_KEYS.TWITTER_CSRF, + STORAGE_KEYS.TWITTER_AUTH_TOKEN, + ]) + + if ( + !result[STORAGE_KEYS.TWITTER_COOKIE] || + !result[STORAGE_KEYS.TWITTER_CSRF] || + !result[STORAGE_KEYS.TWITTER_AUTH_TOKEN] + ) { + return null + } + + return { + cookie: result[STORAGE_KEYS.TWITTER_COOKIE], + csrf: result[STORAGE_KEYS.TWITTER_CSRF], + auth: result[STORAGE_KEYS.TWITTER_AUTH_TOKEN], + } +} + +/** + * Creates HTTP headers for Twitter API requests using stored tokens + * @param tokens - Twitter authentication tokens + * @returns Headers object ready for fetch requests + */ +export function createTwitterAPIHeaders(tokens: TwitterAuthTokens): Headers { + const headers = new Headers() + headers.append("Cookie", tokens.cookie) + headers.append("X-Csrf-Token", tokens.csrf) + headers.append("Authorization", tokens.auth) + headers.append("Content-Type", "application/json") + headers.append( + "User-Agent", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + ) + headers.append("Accept", "*/*") + headers.append("Accept-Language", "en-US,en;q=0.9") + return headers +} diff --git a/apps/browser-extension/utils/twitter-import.ts b/apps/browser-extension/utils/twitter-import.ts new file mode 100644 index 00000000..c516e094 --- /dev/null +++ b/apps/browser-extension/utils/twitter-import.ts @@ -0,0 +1,192 @@ +/** + * Twitter Bookmarks Import Module + * Handles the import process for Twitter bookmarks + */ + +import { saveTweet } from "./api" +import { createTwitterAPIHeaders, getTwitterTokens } from "./twitter-auth" +import { + BOOKMARKS_URL, + buildRequestVariables, + extractNextCursor, + getAllTweets, + type Tweet, + type TwitterAPIResponse, + tweetToMarkdown, +} from "./twitter-utils" + +export type ImportProgressCallback = (message: string) => Promise + +export type ImportCompleteCallback = (totalImported: number) => Promise + +export interface TwitterImportConfig { + onProgress: ImportProgressCallback + onComplete: ImportCompleteCallback + onError: (error: Error) => Promise +} + +/** + * Rate limiting configuration + */ +class RateLimiter { + private waitTime = 60000 // Start with 1 minute + + async handleRateLimit(onProgress: ImportProgressCallback): Promise { + const waitTimeInSeconds = this.waitTime / 1000 + + await onProgress( + `Rate limit reached. Waiting for ${waitTimeInSeconds} seconds before retrying...`, + ) + + await new Promise((resolve) => setTimeout(resolve, this.waitTime)) + this.waitTime *= 2 // Exponential backoff + } + + reset(): void { + this.waitTime = 60000 + } +} + +/** + * Imports a single tweet to Supermemory + * @param tweetMd - Tweet content in markdown format + * @param tweet - Original tweet object with metadata + * @returns Promise that resolves when tweet is imported + */ +async function importTweet(tweetMd: string, tweet: Tweet): Promise { + const metadata = { + sm_source: "consumer", + tweet_id: tweet.id_str, + author: tweet.user.screen_name, + created_at: tweet.created_at, + likes: tweet.favorite_count, + retweets: tweet.retweet_count || 0, + } + + try { + await saveTweet(tweetMd, metadata) + } catch (error) { + throw new Error( + `Failed to save tweet: ${error instanceof Error ? error.message : "Unknown error"}`, + ) + } +} + +/** + * Main class for handling Twitter bookmarks import + */ +export class TwitterImporter { + private importInProgress = false + private rateLimiter = new RateLimiter() + + constructor(private config: TwitterImportConfig) {} + + /** + * Starts the import process for all Twitter bookmarks + * @returns Promise that resolves when import is complete + */ + async startImport(): Promise { + if (this.importInProgress) { + throw new Error("Import already in progress") + } + + this.importInProgress = true + + try { + await this.batchImportAll("", 0) + this.rateLimiter.reset() + } catch (error) { + await this.config.onError(error as Error) + } finally { + this.importInProgress = false + } + } + + /** + * Recursive function to import all bookmarks with pagination + * @param cursor - Pagination cursor for Twitter API + * @param totalImported - Number of tweets imported so far + */ + private async batchImportAll(cursor = "", totalImported = 0): Promise { + try { + // Use a local variable to track imported count + let importedCount = totalImported + + // Get authentication tokens + const tokens = await getTwitterTokens() + if (!tokens) { + await this.config.onProgress( + "Please visit Twitter/X first to capture authentication tokens", + ) + return + } + + // Create headers for API request + const headers = createTwitterAPIHeaders(tokens) + + // Build API request with pagination + const variables = buildRequestVariables(cursor) + const urlWithCursor = cursor + ? `${BOOKMARKS_URL}&variables=${encodeURIComponent(JSON.stringify(variables))}` + : BOOKMARKS_URL + + console.log("Making Twitter API request to:", urlWithCursor) + console.log("Request headers:", Object.fromEntries(headers.entries())) + + const response = await fetch(urlWithCursor, { + method: "GET", + headers, + redirect: "follow", + }) + + if (!response.ok) { + const errorText = await response.text() + console.error(`Twitter API Error ${response.status}:`, errorText) + + if (response.status === 429) { + await this.rateLimiter.handleRateLimit(this.config.onProgress) + return this.batchImportAll(cursor, totalImported) + } + throw new Error( + `Failed to fetch data: ${response.status} - ${errorText}`, + ) + } + + const data: TwitterAPIResponse = await response.json() + const tweets = getAllTweets(data) + + console.log("Tweets:", tweets) + + // Process each tweet + for (const tweet of tweets) { + try { + const tweetMd = tweetToMarkdown(tweet) + await importTweet(tweetMd, tweet) + importedCount++ + await this.config.onProgress(`Imported ${importedCount} tweets`) + } catch (error) { + console.error("Error importing tweet:", error) + // Continue with next tweet + } + } + + // Handle pagination + const instructions = + data.data?.bookmark_timeline_v2?.timeline?.instructions + const nextCursor = extractNextCursor(instructions || []) + + console.log("Next cursor:", nextCursor) + console.log("Tweets length:", tweets.length) + + if (nextCursor && tweets.length > 0) { + await new Promise((resolve) => setTimeout(resolve, 1000)) // Rate limiting + await this.batchImportAll(nextCursor, importedCount) + } else { + await this.config.onComplete(importedCount) + } + } catch (error) { + console.error("Batch import error:", error) + await this.config.onError(error as Error) + } + } +} diff --git a/apps/browser-extension/utils/twitter-utils.ts b/apps/browser-extension/utils/twitter-utils.ts new file mode 100644 index 00000000..7a7b86db --- /dev/null +++ b/apps/browser-extension/utils/twitter-utils.ts @@ -0,0 +1,377 @@ +// Twitter API data structures and transformation utilities + +interface TwitterAPITweet { + __typename?: string + legacy: { + lang?: string + favorite_count: number + created_at: string + display_text_range?: [number, number] + entities?: { + hashtags?: Array<{ indices: [number, number]; text: string }> + urls?: Array<{ + display_url: string + expanded_url: string + indices: [number, number] + url: string + }> + user_mentions?: Array<{ + id_str: string + indices: [number, number] + name: string + screen_name: string + }> + symbols?: Array<{ indices: [number, number]; text: string }> + media?: MediaEntity[] + } + id_str: string + full_text: string + reply_count?: number + retweet_count?: number + quote_count?: number + } + core?: { + user_results?: { + result?: { + legacy?: { + id_str: string + name: string + profile_image_url_https: string + screen_name: string + verified: boolean + } + is_blue_verified?: boolean + } + } + } +} + +interface MediaEntity { + type: string + media_url_https: string + sizes?: { + large?: { + w: number + h: number + } + } + video_info?: { + variants?: Array<{ + url: string + }> + duration_millis?: number + } +} + +export interface Tweet { + __typename?: string + lang?: string + favorite_count: number + created_at: string + display_text_range?: [number, number] + entities: { + hashtags: Array<{ + indices: [number, number] + text: string + }> + urls?: Array<{ + display_url: string + expanded_url: string + indices: [number, number] + url: string + }> + user_mentions: Array<{ + id_str: string + indices: [number, number] + name: string + screen_name: string + }> + symbols: Array<{ + indices: [number, number] + text: string + }> + } + id_str: string + text: string + user: { + id_str: string + name: string + profile_image_url_https: string + screen_name: string + verified: boolean + is_blue_verified?: boolean + } + conversation_count: number + photos?: Array<{ + url: string + width: number + height: number + }> + videos?: Array<{ + url: string + thumbnail_url: string + duration: number + }> + retweet_count?: number + quote_count?: number + reply_count?: number +} + +export interface TwitterAPIResponse { + data: { + bookmark_timeline_v2: { + timeline: { + instructions: Array<{ + type: string + entries?: Array<{ + entryId: string + sortIndex: string + content: Record + }> + }> + } + } + } +} + +// Twitter API features configuration +export const TWITTER_API_FEATURES = { + graphql_timeline_v2_bookmark_timeline: true, + responsive_web_graphql_exclude_directive_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_enhance_cards_enabled: false, + rweb_tipjar_consumption_enabled: true, + responsive_web_twitter_article_notes_tab_enabled: true, + creator_subscriptions_tweet_preview_api_enabled: true, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_media_download_video_enabled: false, + responsive_web_text_conversations_enabled: false, + // Missing features that the API is complaining about + creator_subscriptions_quote_tweet_preview_enabled: true, + view_counts_everywhere_api_enabled: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + tweetypie_unmention_optimization_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + tweet_awards_web_tipping_enabled: true, + communities_web_enable_tweet_community_results_fetch: true, + responsive_web_edit_tweet_api_enabled: true, + longform_notetweets_consumption_enabled: true, + articles_preview_enabled: true, + rweb_video_timestamps_enabled: true, + verified_phone_label_enabled: true, +} + +export const BOOKMARKS_URL = `https://x.com/i/api/graphql/xLjCVTqYWz8CGSprLU349w/Bookmarks?features=${encodeURIComponent(JSON.stringify(TWITTER_API_FEATURES))}` + +/** + * Transform raw Twitter API response data into standardized Tweet format + */ +export function transformTweetData( + input: Record, +): Tweet | null { + try { + const content = input.content as { + itemContent?: { tweet_results?: { result?: unknown } } + } + const tweetData = content?.itemContent?.tweet_results?.result + + if (!tweetData) { + return null + } + + const tweet = tweetData as TwitterAPITweet + + if (!tweet.legacy) { + return null + } + + // Handle media entities + const media = (tweet.legacy.entities?.media as MediaEntity[]) || [] + const photos = media + .filter((m) => m.type === "photo") + .map((m) => ({ + url: m.media_url_https, + width: m.sizes?.large?.w || 0, + height: m.sizes?.large?.h || 0, + })) + + const videos = media + .filter((m) => m.type === "video") + .map((m) => ({ + url: m.video_info?.variants?.[0]?.url || "", + thumbnail_url: m.media_url_https, + duration: m.video_info?.duration_millis || 0, + })) + + const transformed: Tweet = { + __typename: tweet.__typename, + lang: tweet.legacy?.lang, + favorite_count: tweet.legacy.favorite_count || 0, + created_at: new Date(tweet.legacy.created_at).toISOString(), + display_text_range: tweet.legacy.display_text_range, + entities: { + hashtags: tweet.legacy.entities?.hashtags || [], + urls: tweet.legacy.entities?.urls || [], + user_mentions: tweet.legacy.entities?.user_mentions || [], + symbols: tweet.legacy.entities?.symbols || [], + }, + id_str: tweet.legacy.id_str, + text: tweet.legacy.full_text, + user: { + id_str: tweet.core?.user_results?.result?.legacy?.id_str || "", + name: tweet.core?.user_results?.result?.legacy?.name || "Unknown", + profile_image_url_https: + tweet.core?.user_results?.result?.legacy?.profile_image_url_https || + "", + screen_name: + tweet.core?.user_results?.result?.legacy?.screen_name || "unknown", + verified: tweet.core?.user_results?.result?.legacy?.verified || false, + is_blue_verified: + tweet.core?.user_results?.result?.is_blue_verified || false, + }, + conversation_count: tweet.legacy.reply_count || 0, + retweet_count: tweet.legacy.retweet_count || 0, + quote_count: tweet.legacy.quote_count || 0, + reply_count: tweet.legacy.reply_count || 0, + } + + if (photos.length > 0) { + transformed.photos = photos + } + + if (videos.length > 0) { + transformed.videos = videos + } + + return transformed + } catch (error) { + console.error("Error transforming tweet data:", error) + return null + } +} + +/** + * Extract all tweets from Twitter API response + */ +export function getAllTweets(data: TwitterAPIResponse): Tweet[] { + const tweets: Tweet[] = [] + + try { + const instructions = + data.data?.bookmark_timeline_v2?.timeline?.instructions || [] + + for (const instruction of instructions) { + if (instruction.type === "TimelineAddEntries" && instruction.entries) { + for (const entry of instruction.entries) { + if (entry.entryId.startsWith("tweet-")) { + const tweet = transformTweetData(entry) + if (tweet) { + tweets.push(tweet) + } + } + } + } + } + } catch (error) { + console.error("Error extracting tweets:", error) + } + + return tweets +} + +/** + * Extract pagination cursor from Twitter API response + */ +export function extractNextCursor( + instructions: Array>, +): string | null { + try { + for (const instruction of instructions) { + if (instruction.type === "TimelineAddEntries" && instruction.entries) { + const entries = instruction.entries as Array<{ + entryId: string + content?: { value?: string } + }> + for (const entry of entries) { + if (entry.entryId.startsWith("cursor-bottom-")) { + return entry.content?.value || null + } + } + } + } + } catch (error) { + console.error("Error extracting cursor:", error) + } + + return null +} + +/** + * Convert Tweet object to markdown format for storage + */ +export function tweetToMarkdown(tweet: Tweet): string { + const username = tweet.user?.screen_name || "unknown" + const displayName = tweet.user?.name || "Unknown User" + const date = new Date(tweet.created_at).toLocaleDateString() + const time = new Date(tweet.created_at).toLocaleTimeString() + + let markdown = `# Tweet by @${username} (${displayName})\n\n` + markdown += `**Date:** ${date} ${time}\n` + markdown += `**Likes:** ${tweet.favorite_count} | **Retweets:** ${tweet.retweet_count || 0} | **Replies:** ${tweet.reply_count || 0}\n\n` + + // Add tweet text + markdown += `${tweet.text}\n\n` + + // Add media if present + if (tweet.photos && tweet.photos.length > 0) { + markdown += "**Images:**\n" + tweet.photos.forEach((photo, index) => { + markdown += `![Image ${index + 1}](${photo.url})\n` + }) + markdown += "\n" + } + + if (tweet.videos && tweet.videos.length > 0) { + markdown += "**Videos:**\n" + tweet.videos.forEach((video, index) => { + markdown += `[Video ${index + 1}](${video.url})\n` + }) + markdown += "\n" + } + + // Add hashtags and mentions + if (tweet.entities.hashtags.length > 0) { + markdown += `**Hashtags:** ${tweet.entities.hashtags.map((h) => `#${h.text}`).join(", ")}\n` + } + + if (tweet.entities.user_mentions.length > 0) { + markdown += `**Mentions:** ${tweet.entities.user_mentions.map((m) => `@${m.screen_name}`).join(", ")}\n` + } + + // Add raw data for reference + markdown += `\n---\n
\nRaw Tweet Data\n\n\`\`\`json\n${JSON.stringify(tweet, null, 2)}\n\`\`\`\n
` + + return markdown +} + +/** + * Build Twitter API request variables for pagination + */ +export function buildRequestVariables(cursor?: string, count = 100) { + const variables = { + count, + includePromotedContent: false, + } + + if (cursor) { + ;(variables as Record).cursor = cursor + } + + return variables +} diff --git a/apps/browser-extension/utils/types.ts b/apps/browser-extension/utils/types.ts new file mode 100644 index 00000000..2d0981c8 --- /dev/null +++ b/apps/browser-extension/utils/types.ts @@ -0,0 +1,149 @@ +/** + * Type definitions for the browser extension + */ + +/** + * Toast states for UI feedback + */ +export type ToastState = "loading" | "success" | "error" + +/** + * Message types for extension communication + */ +export interface ExtensionMessage { + action?: string + type?: string + data?: unknown + state?: ToastState + importedMessage?: string + totalImported?: number +} + +/** + * Memory data structure for saving content + */ +export interface MemoryData { + html: string + highlightedText?: string + url?: string +} + +/** + * Supermemory API payload for storing memories + */ +export interface MemoryPayload { + containerTags: string[] + content: string + metadata: { + sm_source: string + [key: string]: unknown + } +} + +/** + * Twitter-specific memory metadata + */ +export interface TwitterMemoryMetadata { + sm_source: "twitter_bookmarks" + tweet_id: string + author: string + created_at: string + likes: number + retweets: number +} + +/** + * Storage data structure for Chrome storage + */ +export interface StorageData { + bearerToken?: string + twitterAuth?: { + cookie: string + csrf: string + auth: string + } + tokens_logged?: boolean + cookie?: string + csrf?: string + auth?: string + defaultProject?: Project + projectsCache?: { + projects: Project[] + timestamp: number + } +} + +/** + * Context menu click info + */ +export interface ContextMenuClickInfo { + menuItemId: string | number + editable?: boolean + frameId?: number + frameUrl?: string + linkUrl?: string + mediaType?: string + pageUrl?: string + parentMenuItemId?: string | number + selectionText?: string + srcUrl?: string + targetElementId?: number + wasChecked?: boolean +} + +/** + * API Response types + */ +export interface APIResponse { + success: boolean + data?: T + error?: string +} + +/** + * Error types for better error handling + */ +export class ExtensionError extends Error { + constructor( + message: string, + public code?: string, + public statusCode?: number, + ) { + super(message) + this.name = "ExtensionError" + } +} + +export class TwitterAPIError extends ExtensionError { + constructor(message: string, statusCode?: number) { + super(message, "TWITTER_API_ERROR", statusCode) + this.name = "TwitterAPIError" + } +} + +export class SupermemoryAPIError extends ExtensionError { + constructor(message: string, statusCode?: number) { + super(message, "SUPERMEMORY_API_ERROR", statusCode) + this.name = "SupermemoryAPIError" + } +} + +export class AuthenticationError extends ExtensionError { + constructor(message = "Authentication required") { + super(message, "AUTH_ERROR") + this.name = "AuthenticationError" + } +} + +export interface Project { + id: string + name: string + containerTag: string + createdAt: string + updatedAt: string + documentCount: number +} + +export interface ProjectsResponse { + projects: Project[] +} diff --git a/apps/browser-extension/utils/ui-components.ts b/apps/browser-extension/utils/ui-components.ts new file mode 100644 index 00000000..7fbf098d --- /dev/null +++ b/apps/browser-extension/utils/ui-components.ts @@ -0,0 +1,449 @@ +/** + * UI Components Module + * Reusable UI components for the browser extension + */ + +import { API_ENDPOINTS, ELEMENT_IDS, UI_CONFIG } from "./constants" +import type { ToastState } from "./types" + +/** + * Creates a toast notification element + * @param state - The state of the toast (loading, success, error) + * @returns HTMLElement - The toast element + */ +export function createToast(state: ToastState): HTMLElement { + const toast = document.createElement("div") + toast.id = ELEMENT_IDS.SUPERMEMORY_TOAST + + toast.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + z-index: 2147483647; + background: #ffffff; + border-radius: 9999px; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 12px; + font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + color: #374151; + min-width: 200px; + max-width: 300px; + animation: slideIn 0.3s ease-out; + ` + + // Add keyframe animations and fonts if not already present + if (!document.getElementById("supermemory-toast-styles")) { + const style = document.createElement("style") + style.id = "supermemory-toast-styles" + style.textContent = ` + @font-face { + font-family: 'Space Grotesk'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-Light.ttf")}') format('truetype'); + } + @font-face { + font-family: 'Space Grotesk'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-Regular.ttf")}') format('truetype'); + } + @font-face { + font-family: 'Space Grotesk'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-Medium.ttf")}') format('truetype'); + } + @font-face { + font-family: 'Space Grotesk'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-SemiBold.ttf")}') format('truetype'); + } + @font-face { + font-family: 'Space Grotesk'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('${chrome.runtime.getURL("fonts/SpaceGrotesk-Bold.ttf")}') format('truetype'); + } + @keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + @keyframes fadeOut { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } + } + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + ` + document.head.appendChild(style) + } + + const icon = document.createElement("div") + icon.style.cssText = "width: 20px; height: 20px; flex-shrink: 0;" + + const text = document.createElement("span") + text.style.fontWeight = "500" + + // Configure toast based on state + switch (state) { + case "loading": + icon.innerHTML = ` + + + + + + + + + + + ` + icon.style.animation = "spin 1s linear infinite" + text.textContent = "Adding to Memory..." + break + + case "success": { + const iconUrl = browser.runtime.getURL("/icon-16.png") + icon.innerHTML = `Success` + text.textContent = "Added to Memory" + break + } + + case "error": + icon.innerHTML = ` + + + + + + ` + text.textContent = "Failed to save memory / Make sure you are logged in" + break + } + + toast.appendChild(icon) + toast.appendChild(text) + + return toast +} + +/** + * Creates the Twitter import button + * @param onClick - Click handler for the button + * @returns HTMLElement - The button element + */ +export function createTwitterImportButton(onClick: () => void): HTMLElement { + const button = document.createElement("div") + button.id = ELEMENT_IDS.TWITTER_IMPORT_BUTTON + button.style.cssText = ` + position: fixed; + top: 10px; + right: 10px; + z-index: 2147483646; + background: #ffffff; + color: black; + border: none; + border-radius: 50px; + padding: 12px 16px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; + ` + + const iconUrl = browser.runtime.getURL("/icon-16.png") + button.innerHTML = ` + Save to Memory + ` + + button.addEventListener("mouseenter", () => { + button.style.transform = "scale(1.05)" + button.style.boxShadow = "0 4px 12px rgba(29, 155, 240, 0.4)" + }) + + button.addEventListener("mouseleave", () => { + button.style.transform = "scale(1)" + button.style.boxShadow = "0 2px 8px rgba(29, 155, 240, 0.3)" + }) + + button.addEventListener("click", onClick) + + return button +} + +/** + * Creates the Twitter import UI dialog + * @param onClose - Close handler + * @param onImport - Import handler + * @param isAuthenticated - Whether user is authenticated + * @returns HTMLElement - The dialog element + */ +export function createTwitterImportUI( + onClose: () => void, + onImport: () => void, + isAuthenticated: boolean, +): HTMLElement { + const container = document.createElement("div") + container.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + z-index: 2147483647; + background: #ffffff; + border-radius: 12px; + padding: 16px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 280px; + max-width: 400px; + border: 1px solid #e1e5e9; + font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + ` + + container.innerHTML = ` +
+
+ + + +

+ Import Twitter Bookmarks +

+
+ +
+ + ${ + isAuthenticated + ? ` +
+

+ This will import all your Twitter bookmarks to Supermemory +

+ + + +
+
+ ` + : ` +
+

+ Please sign in to supermemory first +

+ +
+ ` + } + + + ` + + // Add event listeners + const closeBtn = container.querySelector(`#${ELEMENT_IDS.TWITTER_CLOSE_BTN}`) + closeBtn?.addEventListener("click", onClose) + + const importBtn = container.querySelector( + `#${ELEMENT_IDS.TWITTER_IMPORT_BTN}`, + ) + importBtn?.addEventListener("click", onImport) + + const signinBtn = container.querySelector( + `#${ELEMENT_IDS.TWITTER_SIGNIN_BTN}`, + ) + signinBtn?.addEventListener("click", () => { + browser.tabs.create({ url: `${API_ENDPOINTS.SUPERMEMORY_WEB}/login` }) + }) + + return container +} + +/** + * Creates a save tweet element button for Twitter/X + * @param onClick - Click handler for the button + * @returns HTMLElement - The save button element + */ +export function createSaveTweetElement(onClick: () => void): HTMLElement { + const iconButton = document.createElement("div") + iconButton.style.cssText = ` + display: inline-flex; + align-items: flex-end; + opacity: 0.7; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + cursor: pointer; + margin-right: 10px; + margin-bottom: 2px; + z-index: 1000; + ` + + const iconFileName = "/icon-16.png" + const iconUrl = browser.runtime.getURL(iconFileName) + iconButton.innerHTML = ` + Save to Memory + ` + + iconButton.addEventListener("mouseenter", () => { + iconButton.style.opacity = "1" + }) + + iconButton.addEventListener("mouseleave", () => { + iconButton.style.opacity = "0.7" + }) + + iconButton.addEventListener("click", (event) => { + event.stopPropagation() + event.preventDefault() + onClick() + }) + + return iconButton +} + +/** + * Creates a save element button for ChatGPT input bar + * @param onClick - Click handler for the button + * @returns HTMLElement - The save button element + */ +export function createChatGPTInputBarElement(onClick: () => void): HTMLElement { + const iconButton = document.createElement("div") + iconButton.style.cssText = ` + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + cursor: pointer; + transition: opacity 0.2s ease; + border-radius: 50%; + ` + + // Use appropriate icon based on theme + const iconFileName = "/icon-16.png" + const iconUrl = browser.runtime.getURL(iconFileName) + iconButton.innerHTML = ` + Save to Memory + ` + + iconButton.addEventListener("mouseenter", () => { + iconButton.style.opacity = "0.8" + }) + + iconButton.addEventListener("mouseleave", () => { + iconButton.style.opacity = "1" + }) + + iconButton.addEventListener("click", (event) => { + event.stopPropagation() + event.preventDefault() + onClick() + }) + + return iconButton +} + +/** + * Utility functions for DOM manipulation + */ +export const DOMUtils = { + /** + * Check if current page is on specified domains + * @param domains - Array of domain names to check + * @returns boolean + */ + isOnDomain(domains: readonly string[]): boolean { + return domains.includes(window.location.hostname) + }, + + /** + * Detect if the page is in dark mode based on color-scheme style + * @returns boolean - true if dark mode, false if light mode + */ + isDarkMode(): boolean { + const htmlElement = document.documentElement + const style = htmlElement.getAttribute("style") + return style?.includes("color-scheme: dark") || false + }, + + /** + * Check if element exists in DOM + * @param id - Element ID to check + * @returns boolean + */ + elementExists(id: string): boolean { + return !!document.getElementById(id) + }, + + /** + * Remove element from DOM if it exists + * @param id - Element ID to remove + */ + removeElement(id: string): void { + const element = document.getElementById(id) + element?.remove() + }, + + /** + * Show toast notification with auto-dismiss + * @param state - Toast state + * @param duration - Duration to show toast (default from config) + * @returns The toast element + */ + showToast( + state: ToastState, + duration: number = UI_CONFIG.TOAST_DURATION, + ): HTMLElement { + // Remove all existing toasts more aggressively + const existingToasts = document.querySelectorAll( + `#${ELEMENT_IDS.SUPERMEMORY_TOAST}`, + ) + existingToasts.forEach((toast) => { + toast.remove() + }) + + const toast = createToast(state) + document.body.appendChild(toast) + + // Auto-dismiss for success and error states + if (state === "success" || state === "error") { + setTimeout(() => { + if (document.body.contains(toast)) { + toast.style.animation = "fadeOut 0.3s ease-out" + setTimeout(() => { + if (document.body.contains(toast)) { + toast.remove() + } + }, 300) + } + }, duration) + } + + return toast + }, +} diff --git a/apps/browser-extension/wxt.config.ts b/apps/browser-extension/wxt.config.ts new file mode 100644 index 00000000..579f2485 --- /dev/null +++ b/apps/browser-extension/wxt.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from "wxt" + +// See https://wxt.dev/api/config.html +export default defineConfig({ + modules: ["@wxt-dev/module-react"], + manifest: { + name: "supermemory", + homepage_url: "https://supermemory.ai", + permissions: [ + "contextMenus", + "storage", + "scripting", + "activeTab", + "webRequest", + "tabs", + ], + host_permissions: [ + "*://x.com/*", + "*://twitter.com/*", + "*://supermemory.ai/*", + "*://api.supermemory.ai/*", + "*://chatgpt.com/*", + "*://chat.openai.com/*", + ], + web_accessible_resources: [ + { + resources: ["icon-16.png", "fonts/*.ttf"], + matches: [""], + }, + ], + }, + webExt: { + disabled: true, + }, +}) -- cgit v1.2.3 From 33a70f570516c7c1ab4a869b101855d2d4aaf902 Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommmu Date: Thu, 28 Aug 2025 19:08:04 -0700 Subject: added app token registration for extension --- apps/web/app/page.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) (limited to 'apps') diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index d6edc122..eafa4dcd 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -677,7 +677,6 @@ export default function Page() { const { data: waitlistStatus, isLoading: isCheckingWaitlist, - error: waitlistError, } = useQuery({ queryKey: ["waitlist-status", user?.id], queryFn: async () => { @@ -695,11 +694,24 @@ export default function Page() { retry: 1, // Only retry once on failure }); + useEffect(() => { + // save the token for chrome extension + const url = new URL(window.location.href); + const rawToken = url.searchParams.get("token"); + + if (rawToken) { + const encodedToken = encodeURIComponent(rawToken); + window.postMessage({ token: encodedToken }, "*"); + url.searchParams.delete("token"); + window.history.replaceState({}, "", url.toString()); + } + }, []); + useEffect(() => { if (waitlistStatus && !waitlistStatus.accessGranted) { router.push("/waitlist"); } - }, []); + }, [waitlistStatus, router]); // Show loading state while checking authentication and waitlist status if (!user || isCheckingWaitlist) { -- cgit v1.2.3 From d1cc3921c45ab3586e183e657bd11221798a4fa3 Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommmu Date: Thu, 28 Aug 2025 19:45:56 -0700 Subject: welcome page tailwind css integration --- .../entrypoints/welcome/Welcome.tsx | 58 +++--- .../entrypoints/welcome/welcome.css | 232 +-------------------- apps/browser-extension/package.json | 4 +- apps/browser-extension/wxt.config.ts | 7 +- 4 files changed, 41 insertions(+), 260 deletions(-) (limited to 'apps') diff --git a/apps/browser-extension/entrypoints/welcome/Welcome.tsx b/apps/browser-extension/entrypoints/welcome/Welcome.tsx index 31e0c424..205fbad2 100644 --- a/apps/browser-extension/entrypoints/welcome/Welcome.tsx +++ b/apps/browser-extension/entrypoints/welcome/Welcome.tsx @@ -1,55 +1,55 @@ function Welcome() { return ( -
-
+
+
{/* Header */} -
+
supermemory -

+

Your AI-powered second brain for saving and organizing everything that matters

{/* Features Section */} -
-

What can you do with supermemory?

+
+

What can you do with supermemory?

-
-
-
💾
-

Save Any Page

-

+

+
+
💾
+

Save Any Page

+

Instantly save web pages, articles, and content to your personal knowledge base

-
-
🐦
-

Import Twitter/X Bookmarks

-

+

+
🐦
+

Import Twitter/X Bookmarks

+

Bring all your saved tweets and bookmarks into one organized place

-
-
🤖
-

Import ChatGPT Memories

-

+

+
🤖
+

Import ChatGPT Memories

+

Keep your important AI conversations and insights accessible

-
-
🔍
-

AI-Powered Search

-

+

+
🔍
+

AI-Powered Search

+

Find anything you've saved using intelligent semantic search

@@ -57,9 +57,9 @@ function Welcome() {
{/* Actions */} -
+
{/* Footer */} -
-

+

+

Learn more at{" "} + ({ + plugins: [tailwindcss()], + }) as WxtViteConfig, manifest: { name: "supermemory", homepage_url: "https://supermemory.ai", -- cgit v1.2.3 From c74357a6b0106a52fb19ea62c49b19ccac94811b Mon Sep 17 00:00:00 2001 From: Mahesh Sanikommmu Date: Thu, 28 Aug 2025 22:48:57 -0700 Subject: tailwind css to popup --- apps/browser-extension/entrypoints/popup/App.css | 670 +-------------------- apps/browser-extension/entrypoints/popup/App.tsx | 126 ++-- apps/browser-extension/entrypoints/popup/style.css | 40 -- .../entrypoints/welcome/Welcome.tsx | 22 +- 4 files changed, 86 insertions(+), 772 deletions(-) (limited to 'apps') diff --git a/apps/browser-extension/entrypoints/popup/App.css b/apps/browser-extension/entrypoints/popup/App.css index 4924360e..f91be533 100644 --- a/apps/browser-extension/entrypoints/popup/App.css +++ b/apps/browser-extension/entrypoints/popup/App.css @@ -1,3 +1,5 @@ +@import "tailwindcss"; + /* Custom Font Definitions */ @font-face { font-family: "Space Grotesk"; @@ -38,671 +40,3 @@ font-display: swap; src: url("/fonts/SpaceGrotesk-Bold.ttf") format("truetype"); } - -.popup-container { - width: 320px; - padding: 0; - font-family: - "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - sans-serif; - background: #ffffff; - border-radius: 8px; - position: relative; - overflow: hidden; -} - -.header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px; - border-bottom: 1px solid #e5e7eb; - position: relative; -} - -.header .logo { - width: 32px; - height: 32px; - flex-shrink: 0; -} - -.header h1 { - margin: 0; - font-size: 18px; - font-weight: 600; - color: #000000; - flex: 1; -} - -.header-sign-out { - background: none; - border: none; - font-size: 16px; - cursor: pointer; - color: #6c757d; - padding: 4px; - border-radius: 4px; - transition: - color 0.2s ease, - background-color 0.2s ease; -} - -.header-sign-out:hover { - color: #000000; - background-color: #f1f3f4; -} - -.content { - padding: 16px; -} - -.status { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 16px; - font-size: 14px; - color: #000000; -} - -.status-indicator { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} - -.status-indicator.signed-in { - background-color: #000000; -} - -.status-indicator.signed-out { - background-color: #666666; -} - -.sign-out-btn { - width: 100%; - padding: 8px 16px; - background-color: #000000; - color: white; - border: none; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; -} - -.sign-out-btn:hover { - background-color: #333333; -} - -.instruction { - margin: 0; - font-size: 13px; - color: #666666; - line-height: 1.4; -} - -.login-btn { - background: none; - border: none; - color: #1976d2; - cursor: pointer; - text-decoration: underline; - font-size: 13px; - padding: 0; -} - -.login-btn:hover { - color: #1565c0; -} - -.authenticated { - text-align: left; -} - -.unauthenticated { - text-align: center; - padding: 8px 0; -} - -/* Login Screen Styles */ -.login-intro { - margin-bottom: 32px; -} - -.login-title { - margin: 0 0 16px 0; - font-size: 14px; - font-weight: 400; - color: #000000; - line-height: 1.3; -} - -.features-list { - list-style: none; - padding: 0; - margin: 0; - text-align: left; -} - -.features-list li { - padding: 6px 0; - font-size: 14px; - color: #000000; - position: relative; - padding-left: 20px; -} - -.features-list li::before { - content: "•"; - position: absolute; - left: 0; - color: #000000; - font-weight: bold; -} - -.login-actions { - margin-top: 32px; -} - -.login-help { - margin: 0 0 16px 0; - font-size: 14px; - color: #6c757d; -} - -.help-link { - background: none; - border: none; - color: #4285f4; - cursor: pointer; - text-decoration: underline; - font-size: 14px; - padding: 0; -} - -.help-link:hover { - color: #1a73e8; -} - -.login-primary-btn { - width: 100%; - padding: 12px 24px; - background-color: #374151; - color: white; - border: none; - border-radius: 24px; - font-size: 16px; - font-weight: 500; - cursor: pointer; - transition: background-color 0.2s ease; -} - -.login-primary-btn:hover:not(:disabled) { - background-color: #1f2937; -} - -.login-primary-btn:disabled { - background-color: #9e9e9e; - cursor: not-allowed; -} - -/* Tab Navigation Styles */ -.tab-navigation { - display: flex; - background-color: #f1f3f4; - border-radius: 8px; - padding: 4px; - margin-bottom: 16px; -} - -.tab-btn { - flex: 1; - padding: 8px 16px; - background: transparent; - border: none; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - color: #6c757d; - cursor: pointer; - transition: all 0.2s ease; - outline: none; - box-shadow: none; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; -} - -.tab-btn:focus { - outline: none; - box-shadow: none; - border: none; -} - -.tab-btn:active { - outline: none; - box-shadow: none; -} - -.tab-btn.active { - background-color: #ffffff; - color: #000000; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); -} - -.tab-btn:hover:not(.active) { - color: #374151; -} - -/* Tab Content */ -.tab-content { - display: flex; - flex-direction: column; - gap: 16px; - min-height: 200px; -} - -/* Save Action at Bottom */ -.save-action { - margin-top: auto; - padding-top: 16px; -} - -/* Import Actions */ -.import-actions { - display: flex; - flex-direction: column; - gap: 16px; -} - -.import-item { - display: flex; - flex-direction: column; - gap: 8px; -} - -.import-instructions { - margin: 0; - font-size: 12px; - color: #6c757d; - line-height: 1.3; - padding-left: 4px; -} - -/* Save Section Styles */ -.save-section { - margin-bottom: 16px; -} - -.current-page { - margin-bottom: 0; -} - -.page-info { - background-color: #f8f9fa; - padding: 12px; - border-radius: 6px; - border: 1px solid #e9ecef; -} - -.page-title { - margin: 0 0 4px 0; - font-size: 14px; - font-weight: 600; - color: #000000; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.page-url { - margin: 0; - font-size: 12px; - color: #6c757d; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.save-page-btn { - width: 100%; - padding: 12px 16px; - background-color: #1976d2; - color: white; - border: none; - border-radius: 6px; - font-size: 16px; - font-weight: 600; - cursor: pointer; - transition: background-color 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; -} - -.save-page-btn:hover:not(:disabled) { - background-color: #1565c0; -} - -.save-page-btn:disabled { - background-color: #9e9e9e; - cursor: not-allowed; -} - -.secondary-actions { - margin-top: 16px; -} - -.secondary-btn { - width: 100%; - padding: 8px 12px; - background-color: white; - color: #6c757d; - border: 1px solid #e4e6eb; - border-radius: 6px; - font-size: 13px; - font-weight: 400; - cursor: pointer; - transition: - background-color 0.2s ease, - color 0.2s ease; -} - -.secondary-btn:hover { - background-color: #f8f9fa; - color: #000000; -} - -.actions { - display: flex; - flex-direction: column; - gap: 12px; -} - -.chatgpt-btn { - width: 100%; - padding: 12px 12px; - background-color: white; - color: black; - border: 1px solid #e4e6eb; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: background-color 0.2s ease; -} - -.chatgpt-btn:hover { - background-color: #f0f0f0; - border-color: #e4e6eb; -} - -.chatgpt-logo { - width: 18px; - height: 18px; - flex-shrink: 0; - margin-right: 8px; -} - -.twitter-btn { - width: 100%; - padding: 12px 12px; - background-color: white; - color: black; - border: 1px solid #e4e6eb; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: background-color 0.2s ease; - outline: none; - box-shadow: none; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; -} - -.twitter-btn:hover { - background-color: #f0f0f0; - border-color: #e4e6eb; -} - -.twitter-btn:focus { - outline: none; - box-shadow: none; -} - -.twitter-logo { - width: 18px; - height: 18px; - flex-shrink: 0; - margin-right: 8px; -} - -/* Project Selection Styles */ -.project-section { - margin-bottom: 0; -} - -.project-selector-btn { - width: 100%; - background: none; - border: none; - padding: 0; - cursor: pointer; - text-align: left; -} - -.project-selector-content { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px; - background-color: #f8f9fa; - border-radius: 8px; - border: 1px solid #e9ecef; - transition: - background-color 0.2s ease, - border-color 0.2s ease; -} - -.project-selector-btn:hover .project-selector-content { - background-color: #e9ecef; - border-color: #ced4da; -} - -.project-label { - font-size: 14px; - font-weight: 500; - color: #495057; -} - -.project-value { - display: flex; - align-items: center; - gap: 8px; -} - -.project-name { - font-size: 14px; - font-weight: 500; - color: #000000; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - max-width: 120px; -} - -.project-arrow { - color: #6c757d; - flex-shrink: 0; - transition: transform 0.2s ease; -} - -.project-selector-btn:hover .project-arrow { - color: #495057; - transform: translateX(2px); -} - -.project-count { - font-size: 12px; - color: #6c757d; - margin-left: 8px; -} - -.project-none { - font-size: 14px; - color: #6c757d; - font-style: italic; -} - -/* Project Selector Modal */ -.project-selector { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: #ffffff; - border-radius: 8px; - z-index: 1000; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - display: flex; - flex-direction: column; -} - -.project-selector-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px; - border-bottom: 1px solid #e5e7eb; - font-size: 16px; - font-weight: 600; - color: #000000; - flex-shrink: 0; -} - -.project-header-actions { - display: flex; - align-items: center; - gap: 12px; -} - -.project-logout-btn { - background: none; - border: none; - font-size: 14px; - color: #6c757d; - cursor: pointer; - padding: 4px 8px; - border-radius: 4px; - transition: - color 0.2s ease, - background-color 0.2s ease; - outline: none; -} - -.project-logout-btn:hover { - color: #dc3545; - background-color: #f8f9fa; -} - -.project-logout-btn:focus { - outline: none; -} - -.project-close-btn { - background: none; - border: none; - font-size: 20px; - cursor: pointer; - color: #6c757d; - padding: 0; - width: 24px; - height: 24px; - display: flex; - align-items: center; - justify-content: center; -} - -.project-close-btn:hover { - color: #000000; -} - -.project-loading { - padding: 32px 16px; - text-align: center; - color: #6c757d; - font-size: 14px; -} - -.project-list { - flex: 1; - overflow-y: auto; - min-height: 0; -} - -.project-item { - display: flex; - justify-content: space-between; - align-items: center; - padding: 12px 16px; - cursor: pointer; - transition: background-color 0.2s ease; - border-bottom: 1px solid #f1f3f4; - background: none; - border: none; - width: 100%; - text-align: left; -} - -.project-item:hover { - background-color: #f8f9fa; -} - -.project-item:last-child { - border-bottom: none; -} - -.project-item.selected { - background-color: #e3f2fd; -} - -.project-item-info { - display: flex; - flex-direction: column; - flex: 1; - gap: 2px; -} - -.project-item-name { - font-size: 14px; - font-weight: 500; - color: #000000; - word-wrap: break-word; - overflow-wrap: break-word; - hyphens: auto; - line-height: 1.3; -} - -.project-item-count { - font-size: 12px; - color: #6c757d; -} - -.project-item-check { - color: #1976d2; - font-weight: bold; - font-size: 16px; -} diff --git a/apps/browser-extension/entrypoints/popup/App.tsx b/apps/browser-extension/entrypoints/popup/App.tsx index 2f42747a..50a502d3 100644 --- a/apps/browser-extension/entrypoints/popup/App.tsx +++ b/apps/browser-extension/entrypoints/popup/App.tsx @@ -115,12 +115,12 @@ function App() { if (loading) { return ( -

-
- supermemory -

supermemory

+
+
+ supermemory +

supermemory

-
+
Loading...
@@ -128,17 +128,17 @@ function App() { } return ( -
-
+
+
supermemory {userSignedIn && (
-
+
{userSignedIn ? ( -
+
{/* Tab Navigation */} -
+
) : ( -
+
{/* Import Actions */} -
-
+
+
-
+
-

+

Click on supermemory on top right to import bookmarks

@@ -302,11 +310,11 @@ function App() { )} {showProjectSelector && ( -
-
+
+
Select the Project
{loadingProjects ? ( -
Loading projects...
+
Loading projects...
) : ( -
+
{projects.map((project) => ( ))} @@ -343,24 +353,24 @@ function App() { )}
) : ( -
-
-

+
+
+

Login to unlock all chrome extension features

-
    -
  • Save any page to your supermemory
  • -
  • Import all your Twitter / X Bookmarks
  • -
  • Import your ChatGPT Memories
  • +
      +
    • Save any page to your supermemory
    • +
    • Import all your Twitter / X Bookmarks
    • +
    • Import your ChatGPT Memories
-
-

+

+

Having trouble logging in?{" "}