Commit 25b29d27 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

feat: complete redesign — shadcn/ui dark mode, Canifa branding, Memos auth

parent ba521e44
{ {
"../../../@crx/manifest": { "../../../@crx/manifest": {
"file": "assets/crx-manifest.js-SqlU4S0k.js", "file": "assets/crx-manifest.js-kDmztkte.js",
"name": "crx-manifest.js", "name": "crx-manifest.js",
"src": "../../../@crx/manifest", "src": "../../../@crx/manifest",
"isEntry": true "isEntry": true
}, },
"_api-client-Y5oDKlCr.js": { "_api-client-CzjXLdoC.js": {
"file": "assets/api-client-Y5oDKlCr.js", "file": "assets/api-client-CzjXLdoC.js",
"name": "api-client" "name": "api-client"
}, },
"src/background/service-worker.ts": { "src/background/service-worker.ts": {
"file": "assets/service-worker.ts-DvMHIkFe.js", "file": "assets/service-worker.ts-C6gHU6BA.js",
"name": "service-worker.ts", "name": "service-worker.ts",
"src": "src/background/service-worker.ts", "src": "src/background/service-worker.ts",
"isEntry": true, "isEntry": true,
"imports": [ "imports": [
"_api-client-Y5oDKlCr.js" "_api-client-CzjXLdoC.js"
] ]
}, },
"src/content/content-script.ts": { "src/content/content-script.ts": {
"file": "assets/content-script.ts-BWL85FVS.js", "file": "assets/content-script.ts-CTdB63jU.js",
"name": "content-script.ts", "name": "content-script.ts",
"src": "src/content/content-script.ts", "src": "src/content/content-script.ts",
"isEntry": true "isEntry": true
}, },
"src/popup/popup.html": { "src/popup/popup.html": {
"file": "assets/popup-DmXuB8QF.js", "file": "assets/popup-CSzsyzxK.js",
"name": "popup", "name": "popup",
"src": "src/popup/popup.html", "src": "src/popup/popup.html",
"isEntry": true, "isEntry": true,
"imports": [ "imports": [
"_api-client-Y5oDKlCr.js" "_api-client-CzjXLdoC.js"
], ],
"css": [ "css": [
"assets/popup-dYtSf2jU.css" "assets/popup-C6DDov6Y.css"
] ]
} }
} }
\ No newline at end of file
:root{--background: oklch(.95 .015 75);--foreground: oklch(.25 .02 65);--card: oklch(.98 .008 80);--card-foreground: oklch(.22 .015 68);--popover: oklch(.98 .008 80);--popover-foreground: oklch(.25 .02 65);--primary: oklch(.45 .08 45);--primary-foreground: oklch(.98 .008 80);--secondary: oklch(.92 .025 70);--secondary-foreground: oklch(.35 .03 60);--muted: oklch(.9 .025 75);--muted-foreground: oklch(.5 .02 68);--accent: oklch(.88 .035 55);--accent-foreground: oklch(.25 .02 65);--destructive: oklch(.48 .15 25);--border: oklch(.88 .018 72);--input: oklch(.8 .03 75);--ring: oklch(.45 .08 45);--radius: 12px;--success: oklch(.6 .15 145);--warning: oklch(.7 .12 75);--shadow-sm: 0 1px 2px oklch(0 0 0 / .04);--shadow-md: 0 4px 12px oklch(0 0 0 / .06);--shadow-lg: 0 8px 24px oklch(0 0 0 / .08);--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif}[data-theme=dark]{--background: oklch(.16 .008 60);--foreground: oklch(.9 .012 75);--card: oklch(.2 .01 62);--card-foreground: oklch(.9 .012 75);--popover: oklch(.2 .01 62);--popover-foreground: oklch(.88 .01 72);--primary: oklch(.65 .1 45);--primary-foreground: oklch(.15 .008 60);--secondary: oklch(.26 .012 65);--secondary-foreground: oklch(.85 .01 72);--muted: oklch(.23 .01 62);--muted-foreground: oklch(.6 .015 70);--accent: oklch(.28 .015 55);--accent-foreground: oklch(.82 .012 68);--destructive: oklch(.55 .1 25);--border: oklch(.3 .012 62);--input: oklch(.35 .015 65);--ring: oklch(.65 .1 45);--success: oklch(.65 .15 145);--warning: oklch(.75 .12 75);--shadow-sm: 0 1px 2px oklch(0 0 0 / .15);--shadow-md: 0 4px 12px oklch(0 0 0 / .2);--shadow-lg: 0 8px 24px oklch(0 0 0 / .25)}*{box-sizing:border-box;margin:0;padding:0}body{font-family:var(--font-sans);background:var(--background);color:var(--foreground);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;width:380px;min-height:200px;overflow-x:hidden}@keyframes fadeIn{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@keyframes slideUp{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}@keyframes pulse{0%,to{opacity:1}50%{opacity:.5}}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes glow{0%,to{box-shadow:0 0 4px #3a97424d}50%{box-shadow:0 0 10px #3a974280}}@keyframes successPop{0%{transform:scale(.9);opacity:0}50%{transform:scale(1.02)}to{transform:scale(1);opacity:1}}.popup-container{display:flex;flex-direction:column;min-height:100%;animation:fadeIn .2s ease-out}.header{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;background:var(--card);border-bottom:1px solid var(--border);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}.header-brand{display:flex;align-items:center;gap:8px;font-weight:700;font-size:15px;color:var(--foreground);letter-spacing:-.01em}.header-brand-icon{font-size:20px;filter:drop-shadow(0 1px 2px oklch(0 0 0 / .1))}.header-status{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:500;color:var(--muted-foreground)}.status-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.status-dot.status-connected{background:var(--success);animation:glow 2s ease-in-out infinite}.status-dot.status-offline{background:var(--destructive)}.status-text{font-size:11px}.content{padding:16px 18px;animation:fadeIn .3s ease-out}.note-form{display:flex;flex-direction:column;animation:slideUp .25s ease-out}.note-textarea{display:block;width:100%;min-height:100px;max-height:200px;resize:vertical;border:1.5px solid var(--border);border-radius:var(--radius);background:var(--background);color:var(--foreground);padding:12px 14px;font-size:14px;font-family:inherit;line-height:1.6;transition:border-color .2s ease,box-shadow .2s ease}.note-textarea::placeholder{color:var(--muted-foreground);font-style:italic}.note-textarea:focus{outline:none;border-color:var(--ring);box-shadow:0 0 0 3px #7a462e1a}.source-info{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--muted-foreground);padding:8px 12px;background:var(--muted);border-radius:calc(var(--radius) - 2px);margin-top:10px;border:1px solid var(--border)}.label{display:block;font-size:11px;font-weight:600;margin-bottom:8px;color:var(--muted-foreground);text-transform:uppercase;letter-spacing:.05em}.tag-chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}.tag-chip{display:inline-flex;align-items:center;gap:4px;padding:5px 12px;border-radius:100px;font-size:12px;font-weight:500;cursor:pointer;transition:all .2s cubic-bezier(.4,0,.2,1);border:1.5px solid var(--border);background:var(--card);color:var(--muted-foreground);font-family:inherit;-webkit-user-select:none;user-select:none}.tag-chip:hover{border-color:var(--primary);color:var(--primary);background:#7a462e0f;transform:translateY(-1px);box-shadow:var(--shadow-sm)}.tag-chip.selected{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary);box-shadow:0 2px 6px #7a462e33}.tag-chip.selected:hover{filter:brightness(1.1);background:var(--primary);color:var(--primary-foreground);transform:translateY(-1px)}.tag-input-inline{display:inline-flex;align-items:center;gap:4px;padding:5px 12px;border-radius:100px;font-size:12px;border:1.5px dashed var(--border);background:transparent;color:var(--muted-foreground);cursor:pointer;transition:all .2s ease;font-family:inherit}.tag-input-inline:hover{border-color:var(--primary);color:var(--primary)}.tag-input-field{border:none;outline:none;background:transparent;font-size:12px;font-family:inherit;color:var(--foreground);width:80px}.form-group{margin-bottom:14px}.select{display:block;width:100%;border-radius:calc(var(--radius) - 2px);border:1.5px solid var(--border);background:var(--background);color:var(--foreground);padding:10px 36px 10px 14px;font-size:13px;font-family:inherit;cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;transition:border-color .2s ease,box-shadow .2s ease}.select:focus{outline:none;border-color:var(--ring);box-shadow:0 0 0 3px #7a462e1a}.visibility-selector{display:inline-flex;align-items:center;gap:5px;padding:6px 14px;border-radius:100px;font-size:12px;font-weight:500;border:1.5px solid var(--border);background:var(--card);color:var(--muted-foreground);cursor:pointer;transition:all .2s ease;font-family:inherit}.visibility-selector:hover{border-color:var(--primary);color:var(--primary)}.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;white-space:nowrap;border-radius:calc(var(--radius) - 2px);font-size:13px;font-weight:600;font-family:inherit;cursor:pointer;border:none;padding:9px 18px;transition:all .2s cubic-bezier(.4,0,.2,1)}.btn:active{transform:scale(.96)}.btn-primary{background:linear-gradient(135deg,var(--primary),oklch(.5 .1 50));color:var(--primary-foreground);box-shadow:0 2px 8px #7a462e40}.btn-primary:hover{filter:brightness(1.08);box-shadow:0 4px 14px #7a462e59;transform:translateY(-1px)}.btn-secondary{background:var(--secondary);color:var(--secondary-foreground);border:1.5px solid var(--border)}.btn-secondary:hover{background:var(--muted);transform:translateY(-1px)}.btn-ghost{background:transparent;color:var(--muted-foreground);padding:6px 12px}.btn-ghost:hover{background:var(--muted);color:var(--foreground)}.btn-sm{padding:6px 14px;font-size:12px;border-radius:calc(var(--radius) - 4px)}.btn:disabled{opacity:.5;cursor:not-allowed;transform:none!important;filter:none!important}.action-bar{display:flex;align-items:center;justify-content:space-between;padding:12px 18px 14px;border-top:1px solid var(--border);background:var(--card)}.action-bar-left{display:flex;align-items:center;gap:6px}.action-bar-right{display:flex;align-items:center;gap:8px}.badge{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:100px;font-size:12px;font-weight:600}.badge-success{background:#3a97421f;color:var(--success);border:1px solid oklch(.6 .15 145 / .2);animation:successPop .3s ease-out}.error{padding:10px 14px;margin-top:10px;background:#b9464214;color:var(--destructive);border-radius:calc(var(--radius) - 2px);font-size:12px;border:1px solid oklch(.55 .15 25 / .15);animation:fadeIn .2s ease-out}.success{padding:10px 14px;margin-bottom:12px;background:#3a974214;color:var(--success);border-radius:calc(var(--radius) - 2px);font-size:12px;border:1px solid oklch(.6 .15 145 / .15)}.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--primary);border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle}::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:100px}::-webkit-scrollbar-thumb:hover{background:var(--muted-foreground)}*,*:before,*:after{transition-property:background-color,color,border-color,box-shadow,opacity;transition-duration:.2s;transition-timing-function:ease}.spinner,.spinner *,[class*=animate-]{transition:none!important}
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "CuCu Note", "name": "Canifa Note",
"version": "1.0.0", "version": "1.1.0",
"description": "Quick note-taking extension - Highlight text and save to CuCu Note", "description": "Ghi chú nhanh từ web — Bôi đen, nhấn Space, lưu tự động",
"permissions": [ "permissions": [
"activeTab", "activeTab",
"storage", "storage",
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
"content_scripts": [ "content_scripts": [
{ {
"js": [ "js": [
"assets/content-script.ts-BWL85FVS.js" "assets/content-script.ts-CTdB63jU.js"
], ],
"matches": [ "matches": [
"<all_urls>" "<all_urls>"
...@@ -48,7 +48,7 @@ ...@@ -48,7 +48,7 @@
"<all_urls>" "<all_urls>"
], ],
"resources": [ "resources": [
"assets/content-script.ts-BWL85FVS.js" "assets/content-script.ts-CTdB63jU.js"
], ],
"use_dynamic_url": false "use_dynamic_url": false
} }
......
import './assets/service-worker.ts-DvMHIkFe.js'; import './assets/service-worker.ts-C6gHU6BA.js';
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CuCu Note</title> <title>Canifa Note</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> <style>
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
width: 380px; width: 380px;
min-height: 420px; min-height: 380px;
max-height: 560px; max-height: 560px;
overflow-y: auto; overflow-y: auto;
} }
</style> </style>
<script type="module" crossorigin src="/assets/popup-DmXuB8QF.js"></script> <script type="module" crossorigin src="/assets/popup-CSzsyzxK.js"></script>
<link rel="modulepreload" crossorigin href="/assets/api-client-Y5oDKlCr.js"> <link rel="modulepreload" crossorigin href="/assets/api-client-CzjXLdoC.js">
<link rel="stylesheet" crossorigin href="/assets/popup-dYtSf2jU.css"> <link rel="stylesheet" crossorigin href="/assets/popup-C6DDov6Y.css">
</head> </head>
<body> <body>
<div id="popup-root"></div> <div id="popup-root"></div>
</body> </body>
</html> </html>
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "CuCu Note", "name": "Canifa Note",
"version": "1.0.0", "version": "1.1.0",
"description": "Quick note-taking extension - Highlight text and save to CuCu Note", "description": "Ghi chú nhanh từ web — Bôi đen, nhấn Space, lưu tự động",
"permissions": [ "permissions": [
"activeTab", "activeTab",
"storage", "storage",
......
/** /**
* Background Service Worker * Background Service Worker — Canifa Note
* Nhiệm vụ: *
* 1. Handle SYNC_AUTH từ content script (auto-sync Clerk token) * 1. Handle SYNC_AUTH from content script (auto-sync Memos token)
* 2. Handle SAVE_NOTE từ content script (gọi API save memo) * 2. Handle SAVE_NOTE from content script (call API save memo)
* 3. Handle REFRESH_TOKEN từ popup (refresh Clerk JWT trước khi gọi API) * 3. Handle REFRESH_TOKEN from popup
*/ */
import { createMemo } from '../shared/api-client'; import { createMemo } from '../shared/api-client';
/** /**
* Grab fresh Clerk token from ANY OpenNotion tab * Grab fresh Memos token from any Canifa Note tab
* Scans all open tabs, finds OpenNotion, injects script to get token */
*/ async function grabFreshToken(): Promise<string | null> {
async function grabFreshToken(): Promise<string | null> { try {
try { const allTabs = await chrome.tabs.query({});
const allTabs = await chrome.tabs.query({}); const candidates = allTabs.filter((t) => {
const candidates = allTabs.filter((t) => { const url = t.url || '';
const url = t.url || ''; return (
return ( url.includes('172.16.2.210') ||
url.includes('172.16.2.210') || url.includes('localhost:5230') ||
url.includes('localhost:3001') || url.includes('cucunote') ||
url.includes('opennotion') || url.includes('canifa')
url.includes('cucunote') );
); });
});
for (const tab of candidates) {
for (const tab of candidates) { if (!tab.id) continue;
if (!tab.id) continue; try {
try { const results = await chrome.scripting.executeScript({
const results = await chrome.scripting.executeScript({ target: { tabId: tab.id },
target: { tabId: tab.id }, world: 'MAIN',
world: 'MAIN', func: () => {
func: async () => { try {
try { // 1. Memos access-token cookie
// 1. Try Memos cookie const match = document.cookie.match(/(?:^|; )memos\.access-token=([^;]+)/);
const match = document.cookie.match(/(?:^|; )memos\.access-token=([^;]+)/); if (match && match[1]) return match[1];
if (match && match[1]) return match[1];
// 2. localStorage
// 2. Try localStorage for (let i = 0; i < localStorage.length; i++) {
for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i);
const key = localStorage.key(i); if (key && (key.includes('access_token') || key.includes('token'))) {
if (key && (key.includes('token') || key.includes('session'))) { const val = localStorage.getItem(key);
const val = localStorage.getItem(key); if (val && val.length > 20 && !val.startsWith('{')) return val;
if (val && typeof val === 'string' && val.length > 50 && !val.startsWith('{')) { }
return val; }
} } catch { /* ignore */ }
} return null;
} },
});
// 3. Try Clerk const token = results[0]?.result;
const clerk = (window as any).Clerk; if (token) {
if (clerk?.session?.getToken) { await chrome.storage.local.set({
return await clerk.session.getToken(); memosAccessToken: token,
} tokenSyncedAt: Date.now(),
} catch { /* ignore */ } });
return null; console.log('[Canifa Note] ✅ Token synced from tab', tab.id);
}, return token;
}); }
const token = results[0]?.result; } catch {
if (token) { // Try next tab
await chrome.storage.local.set({ }
clerkSessionToken: token, }
clerkTokenSyncedAt: Date.now(), } catch {
}); // Tab scanning failed
console.log('[CuCu BG] ✅ Fresh token from tab', tab.id, `(${token.length} chars)`); }
return token; return null;
} }
} catch {
// This tab didn't work, try next chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
} // ============ AUTO-SYNC AUTH TOKEN ============
} if (message.type === 'SYNC_AUTH') {
} catch { const token = message.data?.memosAccessToken || message.data?.clerkSessionToken;
// Tab scanning failed if (token) {
} chrome.storage.local.set({
return null; memosAccessToken: token,
} tokenSyncedAt: Date.now(),
});
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { console.log('[Canifa Note] ✅ Token auto-synced from content script');
// ============ AUTO-SYNC AUTH TOKEN ============ }
if (message.type === 'SYNC_AUTH') { sendResponse({ success: true });
const { clerkSessionToken } = message.data || {}; return false;
if (clerkSessionToken) { }
chrome.storage.local.set({
clerkSessionToken, // ============ REFRESH TOKEN ============
clerkTokenSyncedAt: Date.now(), if (message.type === 'REFRESH_TOKEN') {
}); grabFreshToken()
console.log('[CuCu BG] ✅ Clerk token auto-synced from content script'); .then((token) => {
} if (token) {
sendResponse({ success: true }); sendResponse({ success: true, token });
return false; // synchronous response } else {
} sendResponse({ success: false, reason: 'no_canifa_note_tab' });
}
// ============ REFRESH TOKEN (popup requests fresh token) ============ })
if (message.type === 'REFRESH_TOKEN') { .catch((err) => {
// Scan ALL OpenNotion tabs to get a fresh Clerk JWT sendResponse({ success: false, reason: err?.message });
grabFreshToken() });
.then((token) => { return true;
if (token) { }
sendResponse({ success: true, token });
} else { // ============ SHOW NOTE FORM ============
sendResponse({ success: false, reason: 'no_opennotion_tab' }); if (message.type === 'SHOW_NOTE_FORM') {
} chrome.action.openPopup();
}) chrome.storage.local.set({ pendingNote: message.data });
.catch((err) => { sendResponse({ success: true });
console.log('[CuCu BG] ❌ Token refresh failed:', err?.message); }
sendResponse({ success: false, reason: err?.message });
}); // ============ SAVE NOTE ============
return true; // keep channel open for async else if (message.type === 'SAVE_NOTE') {
} const { text, url, title } = message.data;
// ============ SHOW NOTE FORM ============ const domain = new URL(url).hostname.replace('www.', '');
if (message.type === 'SHOW_NOTE_FORM') { const tagList = [domain, 'web-highlight'];
// Mở popup với note form const contentWithSource = `${text}\n\n---\nSource: [${title}](${url})`;
chrome.action.openPopup();
const memoData = {
// Lưu data vào storage để popup có thể lấy content: contentWithSource,
chrome.storage.local.set({ tags: tagList,
pendingNote: message.data, visibility: 'PRIVATE',
}); };
sendResponse({ success: true }); createMemo(memoData)
} .then((memo) => {
sendResponse({ success: true, memo });
// ============ SAVE NOTE ============ })
else if (message.type === 'SAVE_NOTE') { .catch((error) => {
// Auto save note ngay lập tức sendResponse({ success: false, error: error.message });
const { text, url, title } = message.data; });
// Parse tags từ URL return true;
const domain = new URL(url).hostname.replace('www.', ''); }
const tagList = [domain, 'web-highlight'];
return true;
// Add source info vào content });
const contentWithSource = `${text}\n\n---\nSource: [${title}](${url})`;
// Gọi API để save (có gửi kèm Clerk token trong Authorization header)
const memoData = {
content: contentWithSource,
tags: tagList,
visibility: 'PRIVATE',
};
createMemo(memoData)
.then((memo) => {
sendResponse({ success: true, memo });
})
.catch((error) => {
sendResponse({ success: false, error: error.message });
});
return true; // Keep channel open for async
}
return true; // Keep channel open for async response
});
/** /**
* NoteForm Component * NoteForm Component — shadcn/ui styled
* *
* The main note-taking form. Accepts a getToken prop from Clerk * Clean, minimal note form. Always visible.
* to get fresh JWTs for API calls. Does NOT close the popup after save. * Uses Memos access token for API calls.
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { createMemoWithToken } from '../shared/api-client'; import { createMemoWithToken } from '../shared/api-client';
import { TagSelector } from './TagSelector'; import { TagSelector } from './TagSelector';
import { WorkspaceSelector } from './WorkspaceSelector';
interface NoteFormProps {
interface NoteFormProps { initialText?: string;
initialText?: string; initialUrl?: string;
initialUrl?: string; initialTitle?: string;
initialTitle?: string; getToken: () => Promise<string | null>;
getToken: () => Promise<string | null>; onSave?: () => void;
onSave?: () => void; onCancel?: () => void;
onCancel?: () => void; }
}
export function NoteForm({
export function NoteForm({ initialText = '',
initialText = '', initialUrl = '',
initialUrl = '', initialTitle = '',
initialTitle = '', getToken,
getToken, onSave,
onSave, onCancel,
onCancel, }: NoteFormProps) {
}: NoteFormProps) { const [text, setText] = useState(initialText);
const [text, setText] = useState(initialText); const [tags, setTags] = useState<string[]>([]);
const [tags, setTags] = useState<string[]>([]); const [visibility, setVisibility] = useState<'PRIVATE' | 'PUBLIC'>('PRIVATE');
const [workspace, setWorkspace] = useState(''); const [loading, setLoading] = useState(false);
const [visibility, setVisibility] = useState<'PRIVATE' | 'PUBLIC'>('PRIVATE'); const [saved, setSaved] = useState(false);
const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null); const handleSave = async () => {
if (!text.trim()) {
const handleSave = async () => { setError('Chưa nhập nội dung ghi chú');
if (!text.trim()) { return;
setError('Note cannot be empty'); }
return;
} setLoading(true);
setError(null);
setLoading(true); setSaved(false);
setError(null);
setSaved(false); try {
const token = await getToken();
try { if (!token) {
// Get FRESH token from Clerk setError('Chưa đồng bộ. Mở Canifa Note trên trình duyệt và đăng nhập.');
const token = await getToken(); setLoading(false);
if (!token) { return;
setError('Not authenticated. Please sign in again.'); }
setLoading(false);
return; let content = text;
} if (initialUrl && initialUrl !== 'about:blank') {
content += `\n\n---\nSource: [${initialTitle || initialUrl}](${initialUrl})`;
// Build content with source info }
let content = text;
if (initialUrl && initialUrl !== 'about:blank') { await createMemoWithToken(token, {
content += `\n\n---\nSource: [${initialTitle || initialUrl}](${initialUrl})`; content,
} tags,
visibility,
// Call API with fresh token });
await createMemoWithToken(token, {
content, setLoading(false);
tags, setSaved(true);
visibility, setText('');
}); setTags([]);
setError(null);
// Success! Show message but KEEP popup open setTimeout(() => setSaved(false), 3000);
setLoading(false); if (onSave) onSave();
setSaved(true); } catch (err) {
setError(err instanceof Error ? err.message : 'Lưu thất bại');
// Clear form for next note setLoading(false);
setText(''); }
setTags([]); };
setError(null);
return (
// Auto-hide success after 3s <div className="note-form">
setTimeout(() => setSaved(false), 3000); <div className="content">
{/* Success */}
if (onSave) onSave(); {saved && (
} catch (err) { <div className="success" style={{ textAlign: 'center', animation: 'fadeIn 0.2s ease' }}>
setError(err instanceof Error ? err.message : 'Failed to save note'); ✓ Đã lưu vào Canifa Note
setLoading(false); </div>
} )}
};
{/* Textarea */}
return ( <textarea
<div className="note-form"> className="note-textarea"
<div className="content"> placeholder="Ghi chú..."
{/* Success Banner */} value={text}
{saved && ( onChange={(e) => setText(e.target.value)}
<div className="badge badge-success" style={{ rows={4}
textAlign: 'center', autoFocus
padding: '10px', />
fontSize: '14px',
marginBottom: '8px', {/* Source */}
animation: 'fadeIn 0.3s ease' {initialUrl && initialUrl !== 'about:blank' && (
}}> <div className="source-info">
✅ Saved to CuCu Note! <svg className="source-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
</div> <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
)} <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
{/* Text Area */} <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<textarea {initialTitle || initialUrl}
className="note-textarea" </span>
placeholder="Any thoughts..." </div>
value={text} )}
onChange={(e) => setText(e.target.value)}
rows={4} {/* Tags */}
autoFocus <div style={{ marginTop: '10px' }}>
/> <TagSelector selectedTags={tags} onChange={setTags} />
</div>
{/* Source Info */}
{initialUrl && initialUrl !== 'about:blank' && ( {/* Error */}
<div className="source-info" style={{ {error && <div className="error">{error}</div>}
fontSize: '12px', </div>
color: 'var(--text-secondary)',
padding: '6px 10px', {/* Action Bar */}
backgroundColor: 'var(--surface)', <div className="action-bar">
borderRadius: '8px', <div className="action-bar-left">
marginTop: '6px', <button
overflow: 'hidden', className="visibility-selector"
textOverflow: 'ellipsis', onClick={() => setVisibility(visibility === 'PRIVATE' ? 'PUBLIC' : 'PRIVATE')}
whiteSpace: 'nowrap', title={`Toggle: ${visibility}`}
}}> >
🔗 {initialTitle || initialUrl} {visibility === 'PRIVATE' ? (
</div> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
)} <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
{/* Tag Selector */} </svg>
<div style={{ marginTop: '12px' }}> ) : (
<TagSelector selectedTags={tags} onChange={setTags} /> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
</div> <circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
{/* Workspace Selector */} <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
<WorkspaceSelector value={workspace} onChange={setWorkspace} /> </svg>
)}
{/* Error */} {visibility === 'PRIVATE' ? 'Private' : 'Public'}
{error && <div className="error">{error}</div>} </button>
</div> </div>
<div className="action-bar-right">
{/* Action Bar */} <button
<div className="action-bar"> className="btn btn-ghost btn-sm"
<div className="action-bar-left"> onClick={onCancel || (() => window.close())}
<button disabled={loading}
className="visibility-selector" >
onClick={() => Cancel
setVisibility(visibility === 'PRIVATE' ? 'PUBLIC' : 'PRIVATE') </button>
} <button
title={`Click to toggle: ${visibility}`} className="btn btn-primary btn-sm"
> onClick={handleSave}
{visibility === 'PRIVATE' ? '🔒' : '🌐'}{' '} disabled={loading || !text.trim()}
{visibility === 'PRIVATE' ? 'Private' : 'Public'} >
</button> {saved ? (
</div> '✓ Saved'
<div className="action-bar-right"> ) : loading ? (
<button <>
className="btn btn-ghost btn-sm" <span className="spinner" style={{ width: 12, height: 12 }} /> Saving...
onClick={onCancel || (() => window.close())} </>
disabled={loading} ) : (
> 'Save'
Cancel )}
</button> </button>
<button </div>
className="btn btn-primary btn-sm" </div>
onClick={handleSave} </div>
disabled={loading || !text.trim()} );
> }
{saved ? (
'✅ Saved!'
) : loading ? (
<>
<span className="spinner" /> Saving...
</>
) : (
'Save'
)}
</button>
</div>
</div>
</div>
);
}
/** /**
* Settings Component * Settings Component — shadcn/ui styled
* Server URL config, auth sync, connection test */
*/
import { useState, useEffect } from 'react';
import { useState, useEffect } from 'react'; import {
import { getApiBaseUrl,
getApiBaseUrl, setApiBaseUrl,
setApiBaseUrl, getAuthStatus,
getAuthStatus, testConnection,
syncClerkTokenFromPage, } from '../shared/api-client';
testConnection,
} from '../shared/api-client'; export function Settings() {
const [serverUrl, setServerUrl] = useState('');
export function Settings() { const [authStatus, setAuthStatus] = useState<{ isConnected: boolean; tokenLength: number }>({
const [serverUrl, setServerUrl] = useState(''); isConnected: false,
const [authStatus, setAuthStatus] = useState<{ isConnected: boolean; tokenLength: number }>({ tokenLength: 0,
isConnected: false, });
tokenLength: 0, const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
}); const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); const [saved, setSaved] = useState(false);
const [syncing, setSyncing] = useState(false);
const [testing, setTesting] = useState(false); useEffect(() => {
const [saved, setSaved] = useState(false); loadSettings();
}, []);
useEffect(() => {
loadSettings(); const loadSettings = async () => {
}, []); const url = await getApiBaseUrl();
setServerUrl(url);
const loadSettings = async () => { const status = await getAuthStatus();
const url = await getApiBaseUrl(); setAuthStatus(status);
setServerUrl(url); };
const status = await getAuthStatus();
setAuthStatus(status); const handleSaveUrl = async () => {
}; const cleaned = serverUrl.trim().replace(/\/+$/, '');
await setApiBaseUrl(cleaned);
const handleSaveUrl = async () => { setServerUrl(cleaned);
const cleaned = serverUrl.trim().replace(/\/+$/, ''); // remove trailing slashes setSaved(true);
await setApiBaseUrl(cleaned); setTestResult(null);
setServerUrl(cleaned); setTimeout(() => setSaved(false), 2000);
setSaved(true); };
setTestResult(null);
setTimeout(() => setSaved(false), 2000); const handleTestConnection = async () => {
}; setTesting(true);
setTestResult(null);
const handleSyncAuth = async () => { const result = await testConnection();
setSyncing(true); setTestResult(result);
try { setTesting(false);
const token = await syncClerkTokenFromPage(); };
if (token) {
setAuthStatus({ isConnected: true, tokenLength: token.length }); return (
} else { <div className="content">
setAuthStatus({ isConnected: false, tokenLength: 0 }); {/* Server */}
} <div className="settings-section">
} catch { <div className="settings-title">Server</div>
setAuthStatus({ isConnected: false, tokenLength: 0 }); <div className="form-group">
} <label className="label">API URL</label>
setSyncing(false); <div className="settings-input-group">
}; <input
type="url"
const handleTestConnection = async () => { className="input"
setTesting(true); value={serverUrl}
setTestResult(null); onChange={(e) => {
const result = await testConnection(); setServerUrl(e.target.value);
setTestResult(result); setSaved(false);
setTesting(false); setTestResult(null);
}; }}
placeholder="http://172.16.2.210:5230"
return ( />
<div className="content"> <button className="btn btn-primary btn-sm" onClick={handleSaveUrl}>
{/* Server URL */} {saved ? '✓' : 'Save'}
<div className="settings-section"> </button>
<div className="settings-title">🌐 Server</div> </div>
<div className="label-hint">Backend server URL (without /api/v1)</div>
<div className="form-group"> </div>
<label className="label">API URL</label>
<div className="settings-input-group"> <div style={{ marginTop: '6px' }}>
<input <button
type="url" className="btn btn-secondary btn-sm btn-full"
className="input" onClick={handleTestConnection}
value={serverUrl} disabled={testing}
onChange={(e) => { >
setServerUrl(e.target.value); {testing ? (
setSaved(false); <>
setTestResult(null); <span className="spinner" style={{ width: 12, height: 12 }} /> Testing...
}} </>
placeholder="https://your-domain.com" ) : (
/> 'Test Connection'
<button className="btn btn-primary btn-sm" onClick={handleSaveUrl}> )}
{saved ? '✓' : 'Save'} </button>
</button> </div>
</div>
<div className="label-hint"> {testResult && (
Backend server URL (without /api/v1) <div className={testResult.ok ? 'success' : 'error'} style={{ marginTop: '6px' }}>
</div> {testResult.message}
</div> </div>
)}
<div style={{ marginTop: '8px' }}> </div>
<button
className="btn btn-secondary btn-sm btn-full" {/* Auth Status */}
onClick={handleTestConnection} <div className="settings-section">
disabled={testing} <div className="settings-title">Authentication</div>
> <div className="settings-item">
{testing ? ( <div>
<> <div className="settings-item-label">Memos Access Token</div>
<span className="spinner" /> Testing... <div className="settings-item-desc">Auto-synced from Canifa Note tab</div>
</> </div>
) : ( <span className={`badge ${authStatus.isConnected ? 'badge-success' : 'badge-error'}`}>
'🔌 Test Connection' <span className={`status-dot ${authStatus.isConnected ? 'connected' : 'disconnected'}`} />
)} {authStatus.isConnected ? 'Active' : 'Inactive'}
</button> </span>
</div> </div>
</div>
{testResult && (
<div {/* About */}
className={testResult.ok ? 'success' : 'error'} <div className="settings-section">
style={{ marginTop: '8px' }} <div className="settings-title">About</div>
> <div style={{ fontSize: '12px', color: 'var(--muted-foreground)', lineHeight: '1.6' }}>
{testResult.message} <strong>Canifa Note</strong> v1.0.0
</div> <br />
)} Ghi chú nhanh từ bất kỳ trang web nào.
</div> <br />
Bôi đen → Nhấn Space → Lưu tự động
{/* Auth */} </div>
<div className="settings-section"> </div>
<div className="settings-title">🔑 Authentication</div> </div>
);
<div className="settings-item"> }
<div>
<div className="settings-item-label">Clerk Session</div>
<div className="settings-item-desc">
Sync auth from your OpenNotion app
</div>
</div>
<span className={`badge ${authStatus.isConnected ? 'badge-success' : 'badge-error'}`}>
<span
className={`status-dot ${authStatus.isConnected ? 'connected' : 'disconnected'}`}
/>
{authStatus.isConnected ? 'Active' : 'Inactive'}
</span>
</div>
<div style={{ marginTop: '8px' }}>
<button
className="btn btn-secondary btn-sm btn-full"
onClick={handleSyncAuth}
disabled={syncing}
>
{syncing ? (
<>
<span className="spinner" /> Syncing...
</>
) : (
'🔄 Sync Auth from Current Page'
)}
</button>
<div className="label-hint" style={{ marginTop: '6px' }}>
Open your OpenNotion app first, then click sync
</div>
</div>
</div>
{/* About */}
<div className="settings-section">
<div className="settings-title">ℹ️ About</div>
<div
style={{
fontSize: '12px',
color: 'var(--muted-foreground)',
lineHeight: '1.6',
}}
>
<strong>CuCu Note</strong> v1.0.0
<br />
Quick note-taking from any web page.
<br />
Highlight text → Press Space → Auto save!
</div>
</div>
</div>
);
}
/** /**
* Tag Selector Component * Tag Selector — shadcn/ui badge chips
* Chip-based tag selector with server-fetched tags + inline new tag input */
*/
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef } from 'react'; import { fetchTags, getRecentTags, saveRecentTags } from '../shared/api-client';
import { fetchTags, getRecentTags, saveRecentTags } from '../shared/api-client';
interface TagSelectorProps {
interface TagSelectorProps { selectedTags: string[];
selectedTags: string[]; onChange: (tags: string[]) => void;
onChange: (tags: string[]) => void; }
}
export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
export function TagSelector({ selectedTags, onChange }: TagSelectorProps) { const [availableTags, setAvailableTags] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<string[]>([]); const [isAdding, setIsAdding] = useState(false);
const [isAdding, setIsAdding] = useState(false); const [newTag, setNewTag] = useState('');
const [newTag, setNewTag] = useState(''); const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(true); const inputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
useEffect(() => { loadTags();
loadTags(); }, []);
}, []);
useEffect(() => {
useEffect(() => { if (isAdding && inputRef.current) {
if (isAdding && inputRef.current) { inputRef.current.focus();
inputRef.current.focus(); }
} }, [isAdding]);
}, [isAdding]);
const loadTags = async () => {
const loadTags = async () => { setLoading(true);
setLoading(true); try {
try { const [serverTags, recentTags] = await Promise.all([
const [serverTags, recentTags] = await Promise.all([ fetchTags(),
fetchTags(), getRecentTags(),
getRecentTags(), ]);
]); const merged = [...new Set([...recentTags, ...serverTags])];
// Merge: recent first, then server tags setAvailableTags(merged);
const merged = [...new Set([...recentTags, ...serverTags])]; } catch {
setAvailableTags(merged); // Fallback
} catch { }
// Fallback to empty setLoading(false);
} };
setLoading(false);
}; const toggleTag = (tag: string) => {
if (selectedTags.includes(tag)) {
const toggleTag = (tag: string) => { onChange(selectedTags.filter((t) => t !== tag));
if (selectedTags.includes(tag)) { } else {
onChange(selectedTags.filter((t) => t !== tag)); onChange([...selectedTags, tag]);
} else { saveRecentTags([tag, ...selectedTags]);
onChange([...selectedTags, tag]); }
// Save to recent };
saveRecentTags([tag, ...selectedTags]);
} const addNewTag = () => {
}; const cleaned = newTag.trim().replace(/^#/, '');
if (cleaned && !selectedTags.includes(cleaned)) {
const addNewTag = () => { onChange([...selectedTags, cleaned]);
const cleaned = newTag.trim().replace(/^#/, ''); if (!availableTags.includes(cleaned)) {
if (cleaned && !selectedTags.includes(cleaned)) { setAvailableTags([cleaned, ...availableTags]);
onChange([...selectedTags, cleaned]); }
if (!availableTags.includes(cleaned)) { saveRecentTags([cleaned, ...selectedTags]);
setAvailableTags([cleaned, ...availableTags]); }
} setNewTag('');
saveRecentTags([cleaned, ...selectedTags]); setIsAdding(false);
} };
setNewTag('');
setIsAdding(false); const handleKeyDown = (e: React.KeyboardEvent) => {
}; if (e.key === 'Enter') {
e.preventDefault();
const handleKeyDown = (e: React.KeyboardEvent) => { addNewTag();
if (e.key === 'Enter') { } else if (e.key === 'Escape') {
e.preventDefault(); setNewTag('');
addNewTag(); setIsAdding(false);
} else if (e.key === 'Escape') { }
setNewTag(''); };
setIsAdding(false);
} const displayTags = availableTags.slice(0, 10);
};
return (
// Show max 12 tags to keep UI compact <div className="form-group">
const displayTags = availableTags.slice(0, 12); <label className="label">Tags</label>
<div className="tag-chips">
return ( {loading ? (
<div className="form-group"> <span style={{ fontSize: '11px', color: 'var(--muted-foreground)' }}>
<label className="label">Tags</label> Loading...
<div className="tag-chips"> </span>
{loading ? ( ) : (
<span style={{ fontSize: '11px', color: 'var(--muted-foreground)' }}> <>
Loading tags... {displayTags.map((tag) => (
</span> <button
) : ( key={tag}
<> type="button"
{displayTags.map((tag) => ( className={`tag-chip ${selectedTags.includes(tag) ? 'selected' : ''}`}
<button onClick={() => toggleTag(tag)}
key={tag} >
type="button" <span className="tag-chip-icon">#</span>
className={`tag-chip ${selectedTags.includes(tag) ? 'selected' : ''}`} {tag}
onClick={() => toggleTag(tag)} </button>
> ))}
<span className="tag-chip-icon">#</span>
{tag} {isAdding ? (
</button> <div className="tag-input-inline">
))} <span className="tag-chip-icon">#</span>
<input
{isAdding ? ( ref={inputRef}
<div className="tag-input-inline"> type="text"
<span className="tag-chip-icon">#</span> className="tag-input-field"
<input value={newTag}
ref={inputRef} onChange={(e) => setNewTag(e.target.value)}
type="text" onKeyDown={handleKeyDown}
className="tag-input-field" onBlur={addNewTag}
value={newTag} placeholder="tag"
onChange={(e) => setNewTag(e.target.value)} />
onKeyDown={handleKeyDown} </div>
onBlur={addNewTag} ) : (
placeholder="new tag" <button
/> type="button"
</div> className="tag-input-inline"
) : ( onClick={() => setIsAdding(true)}
<button >
type="button" + Add
className="tag-input-inline" </button>
onClick={() => setIsAdding(true)} )}
> </>
+ Add )}
</button> </div>
)} </div>
</> );
)} }
</div>
{selectedTags.length > 0 && (
<div className="label-hint">
{selectedTags.length} tag{selectedTags.length > 1 ? 's' : ''} selected
</div>
)}
</div>
);
}
/** /**
* Content Script - Chạy trên mọi web page * Content Script — Canifa Note
* Nhiệm vụ: *
* 1. Auto-sync Clerk auth token khi đang ở trang OpenNotion * 1. Auto-sync Memos access token when on Canifa Note pages
* 2. Detect text selection → Space key → Auto save → Toast notification * 2. Detect text selection → Space/Enter → Auto save with toast
*/ */
let selectedText = ''; let selectedText = '';
let selectedUrl = ''; let selectedUrl = '';
let selectedTitle = ''; let selectedTitle = '';
// ========== AUTO CLERK TOKEN SYNC ========== // ========== AUTO MEMOS TOKEN SYNC ==========
/** async function tryExtractToken(): Promise<string | null> {
* Detect nếu đang ở trang OpenNotion frontend → auto lấy Clerk token const maxRetries = 3;
* Clerk SDK loads async, nên cần poll chờ nó sẵn sàng const retryDelay = 500;
*/
async function tryExtractClerkToken(): Promise<string | null> { for (let attempt = 0; attempt < maxRetries; attempt++) {
const maxRetries = 5; try {
const retryDelay = 800; // ms // 1. Memos cookie
const match = document.cookie.match(/(?:^|; )memos\.access-token=([^;]+)/);
for (let attempt = 0; attempt < maxRetries; attempt++) { if (match && match[1]) return match[1];
try {
// 1. Try Memos cookie // 2. localStorage
const match = document.cookie.match(/(?:^|; )memos\.access-token=([^;]+)/); for (let i = 0; i < localStorage.length; i++) {
if (match && match[1]) return match[1]; const key = localStorage.key(i);
if (key && (key.includes('access_token') || key.includes('token'))) {
// 2. Try Memos API token from localStorage (if any) const val = localStorage.getItem(key);
for (let i = 0; i < localStorage.length; i++) { if (val && typeof val === 'string' && val.length > 20 && !val.startsWith('{')) {
const key = localStorage.key(i); return val;
if (key && (key.includes('token') || key.includes('session'))) { }
const val = localStorage.getItem(key); }
if (val && typeof val === 'string' && val.length > 50 && !val.startsWith('{')) { }
// Return if looks like a JWT or long token } catch {
return val; // Not ready yet
} }
}
} if (attempt < maxRetries - 1) {
await new Promise((resolve) => setTimeout(resolve, retryDelay));
// 3. Try Clerk token }
const clerk = (window as any).Clerk; }
// Skip if Clerk loaded but user not signed in return null;
if (clerk && !clerk.user) return null; }
if (clerk?.session?.getToken) {
const token = await clerk.session.getToken(); async function autoSyncToken() {
if (token) return token; const hostname = window.location.hostname;
} const isCanifaDomain =
} catch { hostname === '172.16.2.210' ||
// Clerk/Memos chưa ready, thử lại hostname === 'localhost' ||
} hostname.includes('canifa') ||
hostname.includes('cucunote') ||
if (attempt < maxRetries - 1) { hostname.includes('cucu-note');
await new Promise((resolve) => setTimeout(resolve, retryDelay));
} if (!isCanifaDomain) return;
}
return null; const token = await tryExtractToken();
} if (token) {
chrome.runtime.sendMessage({
/** type: 'SYNC_AUTH',
* Auto-sync: nếu đang ở trang OpenNotion, lấy Clerk token rồi gửi về background data: { memosAccessToken: token },
*/ });
async function autoSyncClerkToken() { }
// Chỉ chạy trên domain OpenNotion (kiểm tra cả localhost dev và production) }
const hostname = window.location.hostname;
const isOpenNotionDomain = autoSyncToken();
hostname === '172.16.2.210' ||
hostname === 'localhost' || // On-demand token request
hostname.includes('opennotion') || chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
hostname.includes('cucunote') || if (message.type === 'GET_TOKEN') {
hostname.includes('cucu-note'); tryExtractToken().then((token) => {
sendResponse({ token });
if (!isOpenNotionDomain) return; });
return true;
const token = await tryExtractClerkToken(); }
if (token) { });
// Gửi token về background service worker để lưu vào chrome.storage.local
chrome.runtime.sendMessage({ // ========== TEXT SELECTION → AUTO SAVE ==========
type: 'SYNC_AUTH',
data: { clerkSessionToken: token }, document.addEventListener('mouseup', handleTextSelection);
}); document.addEventListener('keydown', async (e) => {
} if ((e.key === ' ' || e.key === 'Enter') && selectedText.length > 0) {
} const target = e.target as HTMLElement;
if (!target || (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA' && !target.isContentEditable)) {
// Chạy auto-sync khi content script load e.preventDefault();
autoSyncClerkToken(); e.stopPropagation();
await handleAutoSave();
// Cũng lắng nghe request từ popup để sync on-demand return;
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { }
if (message.type === 'GET_CLERK_TOKEN') { }
tryExtractClerkToken().then((token) => { });
sendResponse({ token }); document.addEventListener('keyup', handleTextSelection);
});
return true; // keep channel open for async function handleTextSelection() {
} try {
}); setTimeout(() => {
const selection = window.getSelection();
// Listen khi user bôi đen text if (!selection || selection.rangeCount === 0) {
document.addEventListener('mouseup', handleTextSelection); selectedText = '';
document.addEventListener('keydown', async (e) => { removeQuickHint();
// Nếu nhấn Space hoặc Enter sau khi đã bôi đen text → tự động lưu ngay return;
if ((e.key === ' ' || e.key === 'Enter') && selectedText.length > 0) { }
// Chỉ prevent default nếu không phải trong input/textarea
const target = e.target as HTMLElement; const text = selection.toString().trim();
if (!target || (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA' && !target.isContentEditable)) { if (text.length === 0) {
e.preventDefault(); selectedText = '';
e.stopPropagation(); removeQuickHint();
await handleAutoSave(); return;
return; }
}
} selectedText = text;
}); selectedUrl = window.location.href;
document.addEventListener('keyup', handleTextSelection); selectedTitle = document.title;
showQuickHint();
function handleTextSelection() { }, 50);
try { } catch {
// Delay một chút để đảm bảo selection đã hoàn tất // Silently ignore
setTimeout(() => { }
const selection = window.getSelection(); }
if (!selection || selection.rangeCount === 0) { async function handleAutoSave() {
selectedText = ''; if (!selectedText) return;
removeQuickHint();
return; try {
} showToast('Đang lưu...', 'loading');
const text = selection.toString().trim(); chrome.runtime.sendMessage({
type: 'SAVE_NOTE',
if (text.length === 0) { data: {
selectedText = ''; text: selectedText,
removeQuickHint(); url: selectedUrl,
return; title: selectedTitle,
} },
}, (response) => {
// Lưu text đã chọn + metadata if (chrome.runtime.lastError) {
selectedText = text; showToast(`Lỗi: ${chrome.runtime.lastError.message || 'Extension error'}`, 'error');
selectedUrl = window.location.href; return;
selectedTitle = document.title; }
// Hiện hint nhỏ để user biết có thể nhấn Space/Enter if (response?.success) {
showQuickHint(); showToast('Đã lưu vào Canifa Note', 'success');
}, 50); window.getSelection()?.removeAllRanges();
} catch { selectedText = '';
// Silently ignore selection errors removeQuickHint();
} } else {
} const reason = response?.error || 'Không thể lưu';
showToast(reason, 'error');
async function handleAutoSave() { }
if (!selectedText) return; });
} catch (err: any) {
try { showToast(err?.message || 'Lỗi khi lưu', 'error');
// Show loading toast }
showToast('Đang lưu...', 'loading'); }
// Gửi message đến background để save // ========== UI: Quick Hint ==========
chrome.runtime.sendMessage({
type: 'SAVE_NOTE', function showQuickHint() {
data: { removeQuickHint();
text: selectedText,
url: selectedUrl, const hint = document.createElement('div');
title: selectedTitle, hint.id = 'cucu-quick-hint';
}, hint.textContent = 'Space hoặc Enter để lưu nhanh';
}, (response) => {
if (chrome.runtime.lastError) { hint.style.cssText = `
showToast(`❌ Lỗi: ${chrome.runtime.lastError.message || 'Extension error'}`, 'error'); position: fixed;
return; bottom: 20px;
} right: 20px;
background: hsl(240 10% 3.9%);
if (response?.success) { color: hsl(0 0% 98%);
showToast('✅ Đã lưu vào CuCu Note!', 'success'); padding: 10px 16px;
// Clear selection border-radius: 8px;
window.getSelection()?.removeAllRanges(); font-size: 13px;
selectedText = ''; font-weight: 500;
removeQuickHint(); border: 1px solid hsl(240 3.7% 15.9%);
} else { box-shadow: 0 4px 12px rgba(0,0,0,0.3);
const reason = response?.error || 'Không thể lưu note'; z-index: 999999;
showToast(`❌ ${reason}`, 'error'); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
} animation: cucuSlideUp 0.2s ease-out;
}); pointer-events: none;
} catch (err: any) { `;
showToast(`❌ ${err?.message || 'Lỗi khi lưu note'}`, 'error');
} if (!document.getElementById('cucu-hint-style')) {
} const style = document.createElement('style');
style.id = 'cucu-hint-style';
function showQuickHint() { style.textContent = `
// Hiện hint nhỏ "Nhấn Space để lưu" với gradient đẹp @keyframes cucuSlideUp {
removeQuickHint(); from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
const hint = document.createElement('div'); }
hint.id = 'cucu-quick-hint'; `;
hint.textContent = '💡 Nhấn Space hoặc Enter để lưu nhanh'; document.head.appendChild(style);
}
// Gradient màu đẹp
hint.style.cssText = ` try {
position: fixed; document.body.appendChild(hint);
bottom: 20px; } catch {
right: 20px; document.documentElement.appendChild(hint);
background: linear-gradient(135deg, #a0845c 0%, #8b6914 100%); }
color: white;
padding: 12px 20px; setTimeout(() => removeQuickHint(), 3000);
border-radius: 8px; }
font-size: 14px;
font-weight: 500; function removeQuickHint() {
box-shadow: 0 4px 12px rgba(0,0,0,0.15); const hint = document.getElementById('cucu-quick-hint');
z-index: 999999; if (hint) {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; try { hint.remove(); } catch { /* ignore */ }
animation: cucuSlideUp 0.3s ease-out; }
pointer-events: none; }
`;
// ========== UI: Toast ==========
// Inject animation CSS
if (!document.getElementById('cucu-hint-style')) { function showToast(message: string, type: 'success' | 'error' | 'loading' = 'success') {
const style = document.createElement('style'); const oldToast = document.getElementById('cucu-toast');
style.id = 'cucu-hint-style'; if (oldToast) {
style.textContent = ` try { oldToast.remove(); } catch { /* ignore */ }
@keyframes cucuSlideUp { }
from {
opacity: 0; const toast = document.createElement('div');
transform: translateY(20px); toast.id = 'cucu-toast';
}
to { const icons = { success: '✓', error: '✕', loading: '⟳' };
opacity: 1; toast.textContent = `${icons[type]} ${message}`;
transform: translateY(0);
} const colors = {
} success: 'hsl(142 71% 45%)',
`; error: 'hsl(0 72% 51%)',
document.head.appendChild(style); loading: 'hsl(240 5% 64.9%)',
} };
try { toast.style.cssText = `
document.body.appendChild(hint); position: fixed;
} catch (e) { top: 20px;
document.documentElement.appendChild(hint); right: 20px;
} background: hsl(240 10% 3.9%);
color: ${colors[type]};
// Auto ẩn sau 3 giây padding: 12px 18px;
setTimeout(() => { border-radius: 8px;
removeQuickHint(); font-size: 13px;
}, 3000); font-weight: 500;
} border: 1px solid hsl(240 3.7% 15.9%);
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
function removeQuickHint() { z-index: 999999;
const hint = document.getElementById('cucu-quick-hint'); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
if (hint) { animation: cucuToastSlide 0.25s ease-out;
try { max-width: 320px;
hint.remove(); word-wrap: break-word;
} catch (e) { `;
// Ignore
} if (!document.getElementById('cucu-toast-style')) {
} const style = document.createElement('style');
} style.id = 'cucu-toast-style';
style.textContent = `
function showToast(message: string, type: 'success' | 'error' | 'loading' = 'success') { @keyframes cucuToastSlide {
// Remove toast cũ nếu có from { opacity: 0; transform: translateX(100%); }
const oldToast = document.getElementById('cucu-toast'); to { opacity: 1; transform: translateX(0); }
if (oldToast) { }
try { `;
oldToast.remove(); try { document.head.appendChild(style); } catch { /* ignore */ }
} catch (e) { }
// Ignore
} try {
} document.body.appendChild(toast);
} catch {
const toast = document.createElement('div'); try { document.documentElement.appendChild(toast); } catch { return; }
toast.id = 'cucu-toast'; }
toast.textContent = message;
if (type !== 'loading') {
// Màu sắc đẹp theo type (gradient) setTimeout(() => {
const colors = { if (toast.parentNode) {
success: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', toast.style.animation = 'cucuToastSlide 0.2s ease-out reverse';
error: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)', setTimeout(() => {
loading: 'linear-gradient(135deg, #a0845c 0%, #8b6914 100%)', try { toast.remove(); } catch { /* ignore */ }
}; }, 200);
}
toast.style.cssText = ` }, 3000);
position: fixed; }
top: 20px; }
right: 20px;
background: ${colors[type]};
color: white;
padding: 16px 24px;
border-radius: 12px;
font-size: 15px;
font-weight: 500;
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
animation: cucuToastSlide 0.4s ease-out;
max-width: 350px;
word-wrap: break-word;
`;
// Inject animation CSS
if (!document.getElementById('cucu-toast-style')) {
const style = document.createElement('style');
style.id = 'cucu-toast-style';
style.textContent = `
@keyframes cucuToastSlide {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
`;
try {
document.head.appendChild(style);
} catch (e) {
// Ignore
}
}
try {
document.body.appendChild(toast);
} catch (e) {
try {
document.documentElement.appendChild(toast);
} catch (e2) {
return;
}
}
// Auto ẩn sau 3 giây (trừ loading)
if (type !== 'loading') {
setTimeout(() => {
if (toast.parentNode) {
toast.style.animation = 'cucuToastSlide 0.3s ease-out reverse';
setTimeout(() => {
try {
toast.remove();
} catch (e) {
// Ignore
}
}, 300);
}
}, 3000);
}
}
/* ================================================================ /* ================================================================
CuCu Note Extension — Premium Design System Canifa Note Extension — shadcn/ui Design System
Modern, rounded, glassmorphic UI with warm brown palette Dark-first, token-driven, Canifa red accent
================================================================ */ ================================================================ */
/* ---- Light Theme (Paper — warm brown/amber) ---- */ /* ---- Design Tokens ---- */
:root { :root {
--background: oklch(0.95 0.015 75); /* Surface */
--foreground: oklch(0.25 0.02 65); --background: hsl(240 10% 3.9%);
--card: oklch(0.98 0.008 80); --foreground: hsl(0 0% 98%);
--card-foreground: oklch(0.22 0.015 68); --card: hsl(240 10% 3.9%);
--popover: oklch(0.98 0.008 80); --card-foreground: hsl(0 0% 98%);
--popover-foreground: oklch(0.25 0.02 65); --popover: hsl(240 10% 3.9%);
--primary: oklch(0.45 0.08 45); --popover-foreground: hsl(0 0% 98%);
--primary-foreground: oklch(0.98 0.008 80);
--secondary: oklch(0.92 0.025 70); /* Canifa Red Accent */
--secondary-foreground: oklch(0.35 0.03 60); --primary: hsl(2 78% 53%);
--muted: oklch(0.9 0.025 75); --primary-foreground: hsl(0 0% 100%);
--muted-foreground: oklch(0.5 0.02 68);
--accent: oklch(0.88 0.035 55); /* Secondary / Muted */
--accent-foreground: oklch(0.25 0.02 65); --secondary: hsl(240 3.7% 15.9%);
--destructive: oklch(0.48 0.15 25); --secondary-foreground: hsl(0 0% 98%);
--border: oklch(0.88 0.018 72); --muted: hsl(240 3.7% 15.9%);
--input: oklch(0.8 0.03 75); --muted-foreground: hsl(240 5% 64.9%);
--ring: oklch(0.45 0.08 45); --accent: hsl(240 3.7% 15.9%);
--radius: 12px; --accent-foreground: hsl(0 0% 98%);
--success: oklch(0.6 0.15 145);
--warning: oklch(0.7 0.12 75); /* Feedback */
--shadow-sm: 0 1px 2px oklch(0 0 0 / 0.04); --destructive: hsl(0 62.8% 30.6%);
--shadow-md: 0 4px 12px oklch(0 0 0 / 0.06); --destructive-foreground: hsl(0 0% 98%);
--shadow-lg: 0 8px 24px oklch(0 0 0 / 0.08); --success: hsl(142 71% 45%);
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --success-foreground: hsl(0 0% 100%);
} --warning: hsl(48 96% 53%);
/* ---- Dark Theme ---- */ /* Border / Input / Ring */
[data-theme="dark"] { --border: hsl(240 3.7% 15.9%);
--background: oklch(0.16 0.008 60); --input: hsl(240 3.7% 15.9%);
--foreground: oklch(0.9 0.012 75); --ring: hsl(2 78% 53%);
--card: oklch(0.20 0.01 62);
--card-foreground: oklch(0.9 0.012 75); /* Radius */
--popover: oklch(0.20 0.01 62); --radius: 8px;
--popover-foreground: oklch(0.88 0.01 72);
--primary: oklch(0.65 0.1 45); /* Typography */
--primary-foreground: oklch(0.15 0.008 60); --font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--secondary: oklch(0.26 0.012 65); --font-mono: "Geist Mono", ui-monospace, monospace;
--secondary-foreground: oklch(0.85 0.01 72);
--muted: oklch(0.23 0.01 62); /* Shadows */
--muted-foreground: oklch(0.6 0.015 70); --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--accent: oklch(0.28 0.015 55); --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--accent-foreground: oklch(0.82 0.012 68);
--destructive: oklch(0.55 0.1 25); /* Motion */
--border: oklch(0.30 0.012 62); --duration-fast: 150ms;
--input: oklch(0.35 0.015 65); --duration-normal: 200ms;
--ring: oklch(0.65 0.1 45); --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--success: oklch(0.65 0.15 145); }
--warning: oklch(0.75 0.12 75);
--shadow-sm: 0 1px 2px oklch(0 0 0 / 0.15); /* ================================================================
--shadow-md: 0 4px 12px oklch(0 0 0 / 0.2); Base Reset
--shadow-lg: 0 8px 24px oklch(0 0 0 / 0.25); ================================================================ */
} *, *::before, *::after {
box-sizing: border-box;
/* ================================================================ margin: 0;
Base Reset padding: 0;
================================================================ */ }
* {
box-sizing: border-box; body {
margin: 0; font-family: var(--font-sans);
padding: 0; background: var(--background);
} color: var(--foreground);
font-size: 14px;
body { font-weight: 400;
font-family: var(--font-sans); line-height: 1.5;
background: var(--background); -webkit-font-smoothing: antialiased;
color: var(--foreground); -moz-osx-font-smoothing: grayscale;
font-size: 13px; width: 380px;
line-height: 1.5; min-height: 200px;
-webkit-font-smoothing: antialiased; overflow-x: hidden;
-moz-osx-font-smoothing: grayscale; }
width: 380px;
min-height: 200px; /* ================================================================
overflow-x: hidden; Animations
} ================================================================ */
@keyframes fadeIn {
/* ================================================================ from { opacity: 0; transform: translateY(4px); }
Animations to { opacity: 1; transform: translateY(0); }
================================================================ */ }
@keyframes fadeIn {
from { @keyframes slideUp {
opacity: 0; from { opacity: 0; transform: translateY(8px); }
transform: translateY(6px); to { opacity: 1; transform: translateY(0); }
} }
to { @keyframes spin {
opacity: 1; from { transform: rotate(0deg); }
transform: translateY(0); to { transform: rotate(360deg); }
} }
}
@keyframes pulse-dot {
@keyframes slideUp { 0%, 100% { opacity: 1; }
from { 50% { opacity: 0.5; }
opacity: 0; }
transform: translateY(12px);
} /* ================================================================
Popup Container
to { ================================================================ */
opacity: 1; .popup-container {
transform: translateY(0); display: flex;
} flex-direction: column;
} min-height: 100%;
animation: fadeIn var(--duration-normal) var(--ease-out);
@keyframes pulse { }
0%, /* ================================================================
100% { Header
opacity: 1; ================================================================ */
} .header {
display: flex;
50% { align-items: center;
opacity: 0.5; justify-content: space-between;
} padding: 12px 16px;
} border-bottom: 1px solid var(--border);
background: var(--card);
@keyframes spin { }
from {
transform: rotate(0deg); .header-brand {
} display: flex;
align-items: center;
to { gap: 8px;
transform: rotate(360deg); font-weight: 600;
} font-size: 14px;
} color: var(--foreground);
letter-spacing: -0.02em;
@keyframes glow { }
0%, .header-brand-logo {
100% { width: 22px;
box-shadow: 0 0 4px oklch(0.6 0.15 145 / 0.3); height: 22px;
} border-radius: 4px;
object-fit: contain;
50% { }
box-shadow: 0 0 10px oklch(0.6 0.15 145 / 0.5);
} .header-brand-name {
} font-weight: 700;
color: var(--primary);
@keyframes successPop { letter-spacing: 0.02em;
0% { }
transform: scale(0.9);
opacity: 0; .header-brand-suffix {
} font-weight: 400;
color: var(--muted-foreground);
50% { font-size: 13px;
transform: scale(1.02); }
}
.header-actions {
100% { display: flex;
transform: scale(1); align-items: center;
opacity: 1; gap: 4px;
} }
}
.header-status {
/* ================================================================ display: flex;
Popup Container align-items: center;
================================================================ */ gap: 6px;
.popup-container { font-size: 12px;
display: flex; font-weight: 500;
flex-direction: column; color: var(--muted-foreground);
min-height: 100%; }
animation: fadeIn 0.2s ease-out;
} /* Status Dots */
.status-dot {
/* ================================================================ width: 6px;
Header height: 6px;
================================================================ */ border-radius: 50%;
.header { flex-shrink: 0;
display: flex; }
align-items: center;
justify-content: space-between; .status-dot.connected {
padding: 14px 18px; background: var(--success);
background: var(--card); box-shadow: 0 0 6px hsl(142 71% 45% / 0.4);
border-bottom: 1px solid var(--border); animation: pulse-dot 2s ease-in-out infinite;
backdrop-filter: blur(10px); }
}
.status-dot.disconnected {
.header-brand { background: var(--muted-foreground);
display: flex; }
align-items: center;
gap: 8px; .status-text {
font-weight: 700; font-size: 11px;
font-size: 15px; color: var(--muted-foreground);
color: var(--foreground); }
letter-spacing: -0.01em;
} /* ================================================================
Content
.header-brand-icon { ================================================================ */
font-size: 20px; .content {
filter: drop-shadow(0 1px 2px oklch(0 0 0 / 0.1)); padding: 14px 16px;
} animation: fadeIn 0.2s var(--ease-out);
}
.header-status {
display: flex; /* ================================================================
align-items: center; Note Form
gap: 6px; ================================================================ */
font-size: 11px; .note-form {
font-weight: 500; display: flex;
color: var(--muted-foreground); flex-direction: column;
} animation: slideUp 0.2s var(--ease-out);
}
.status-dot {
width: 8px; /* Textarea */
height: 8px; .note-textarea {
border-radius: 50%; display: block;
flex-shrink: 0; width: 100%;
} min-height: 96px;
max-height: 200px;
.status-dot.status-connected { resize: vertical;
background: var(--success); border: 1px solid var(--border);
animation: glow 2s ease-in-out infinite; border-radius: var(--radius);
} background: var(--background);
color: var(--foreground);
.status-dot.status-offline { padding: 10px 12px;
background: var(--destructive); font-size: 14px;
} font-family: var(--font-sans);
line-height: 1.6;
.status-text { transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
font-size: 11px; }
}
.note-textarea::placeholder {
/* ================================================================ color: var(--muted-foreground);
Content }
================================================================ */
.content { .note-textarea:focus {
padding: 16px 18px; outline: none;
animation: fadeIn 0.3s ease-out; border-color: var(--ring);
} box-shadow: 0 0 0 2px hsl(2 78% 53% / 0.15);
}
/* ================================================================
Note Form /* ================================================================
================================================================ */ Source Info
.note-form { ================================================================ */
display: flex; .source-info {
flex-direction: column; display: flex;
animation: slideUp 0.25s ease-out; align-items: center;
} gap: 6px;
font-size: 12px;
/* ---- Textarea ---- */ color: var(--muted-foreground);
.note-textarea { padding: 6px 10px;
display: block; background: var(--secondary);
width: 100%; border-radius: calc(var(--radius) - 2px);
min-height: 100px; margin-top: 8px;
max-height: 200px; border: 1px solid var(--border);
resize: vertical; overflow: hidden;
border: 1.5px solid var(--border); text-overflow: ellipsis;
border-radius: var(--radius); white-space: nowrap;
background: var(--background); }
color: var(--foreground);
padding: 12px 14px; .source-icon {
font-size: 14px; flex-shrink: 0;
font-family: inherit; width: 14px;
line-height: 1.6; height: 14px;
transition: border-color 0.2s ease, box-shadow 0.2s ease; color: var(--muted-foreground);
} }
.note-textarea::placeholder { /* ================================================================
color: var(--muted-foreground); Labels
font-style: italic; ================================================================ */
} .label {
display: block;
.note-textarea:focus { font-size: 12px;
outline: none; font-weight: 500;
border-color: var(--ring); margin-bottom: 6px;
box-shadow: 0 0 0 3px oklch(0.45 0.08 45 / 0.1); color: var(--muted-foreground);
} letter-spacing: 0.01em;
}
/* ================================================================
Source Info .label-hint {
================================================================ */ font-size: 11px;
.source-info { color: var(--muted-foreground);
display: flex; margin-top: 4px;
align-items: center; opacity: 0.8;
gap: 8px; }
font-size: 11px;
color: var(--muted-foreground); /* ================================================================
padding: 8px 12px; Tags
background: var(--muted); ================================================================ */
border-radius: calc(var(--radius) - 2px); .tag-chips {
margin-top: 10px; display: flex;
border: 1px solid var(--border); flex-wrap: wrap;
} gap: 5px;
margin-top: 4px;
/* ================================================================ }
Tags Section
================================================================ */ .tag-chip {
.label { display: inline-flex;
display: block; align-items: center;
font-size: 11px; gap: 2px;
font-weight: 600; padding: 3px 10px;
margin-bottom: 8px; border-radius: 9999px;
color: var(--muted-foreground); font-size: 12px;
text-transform: uppercase; font-weight: 500;
letter-spacing: 0.05em; cursor: pointer;
} transition: all var(--duration-fast) var(--ease-out);
border: 1px solid var(--border);
.tag-chips { background: transparent;
display: flex; color: var(--muted-foreground);
flex-wrap: wrap; font-family: var(--font-sans);
gap: 6px; user-select: none;
margin-top: 6px; }
}
.tag-chip:hover {
.tag-chip { border-color: hsl(2 78% 53% / 0.5);
display: inline-flex; color: var(--foreground);
align-items: center; background: hsl(2 78% 53% / 0.06);
gap: 4px; }
padding: 5px 12px;
border-radius: 100px; .tag-chip.selected {
font-size: 12px; background: var(--primary);
font-weight: 500; color: var(--primary-foreground);
cursor: pointer; border-color: var(--primary);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); }
border: 1.5px solid var(--border);
background: var(--card); .tag-chip.selected:hover {
color: var(--muted-foreground); filter: brightness(1.1);
font-family: inherit; background: var(--primary);
user-select: none; color: var(--primary-foreground);
} }
.tag-chip:hover { .tag-chip-icon {
border-color: var(--primary); font-size: 11px;
color: var(--primary); opacity: 0.6;
background: oklch(0.45 0.08 45 / 0.06); }
transform: translateY(-1px);
box-shadow: var(--shadow-sm); .tag-input-inline {
} display: inline-flex;
align-items: center;
.tag-chip.selected { gap: 2px;
background: var(--primary); padding: 3px 10px;
color: var(--primary-foreground); border-radius: 9999px;
border-color: var(--primary); font-size: 12px;
box-shadow: 0 2px 6px oklch(0.45 0.08 45 / 0.2); border: 1px dashed var(--border);
} background: transparent;
color: var(--muted-foreground);
.tag-chip.selected:hover { cursor: pointer;
filter: brightness(1.1); transition: all var(--duration-fast) var(--ease-out);
background: var(--primary); font-family: var(--font-sans);
color: var(--primary-foreground); }
transform: translateY(-1px);
} .tag-input-inline:hover {
border-color: var(--primary);
.tag-input-inline { color: var(--foreground);
display: inline-flex; }
align-items: center;
gap: 4px; .tag-input-field {
padding: 5px 12px; border: none;
border-radius: 100px; outline: none;
font-size: 12px; background: transparent;
border: 1.5px dashed var(--border); font-size: 12px;
background: transparent; font-family: var(--font-sans);
color: var(--muted-foreground); color: var(--foreground);
cursor: pointer; width: 70px;
transition: all 0.2s ease; }
font-family: inherit;
} /* ================================================================
Form Group / Select
.tag-input-inline:hover { ================================================================ */
border-color: var(--primary); .form-group {
color: var(--primary); margin-bottom: 12px;
} }
.tag-input-field { .select {
border: none; display: block;
outline: none; width: 100%;
background: transparent; border-radius: var(--radius);
font-size: 12px; border: 1px solid var(--border);
font-family: inherit; background: var(--background);
color: var(--foreground); color: var(--foreground);
width: 80px; padding: 8px 32px 8px 12px;
} font-size: 13px;
font-family: var(--font-sans);
/* ================================================================ cursor: pointer;
Workspace / Select appearance: none;
================================================================ */ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a1a1aa' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
.form-group { background-repeat: no-repeat;
margin-bottom: 14px; background-position: right 10px center;
} transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
}
.select {
display: block; .select:focus {
width: 100%; outline: none;
border-radius: calc(var(--radius) - 2px); border-color: var(--ring);
border: 1.5px solid var(--border); box-shadow: 0 0 0 2px hsl(2 78% 53% / 0.15);
background: var(--background); }
color: var(--foreground);
padding: 10px 36px 10px 14px; /* ================================================================
font-size: 13px; Input
font-family: inherit; ================================================================ */
cursor: pointer; .input {
appearance: none; display: block;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); width: 100%;
background-repeat: no-repeat; border-radius: var(--radius);
background-position: right 12px center; border: 1px solid var(--border);
transition: border-color 0.2s ease, box-shadow 0.2s ease; background: var(--background);
} color: var(--foreground);
padding: 8px 12px;
.select:focus { font-size: 13px;
outline: none; font-family: var(--font-sans);
border-color: var(--ring); transition: border-color var(--duration-fast), box-shadow var(--duration-fast);
box-shadow: 0 0 0 3px oklch(0.45 0.08 45 / 0.1); }
}
.input:focus {
/* ================================================================ outline: none;
Visibility Selector border-color: var(--ring);
================================================================ */ box-shadow: 0 0 0 2px hsl(2 78% 53% / 0.15);
.visibility-selector { }
display: inline-flex;
align-items: center; .input::placeholder {
gap: 5px; color: var(--muted-foreground);
padding: 6px 14px; }
border-radius: 100px;
font-size: 12px; /* ================================================================
font-weight: 500; Visibility Selector
border: 1.5px solid var(--border); ================================================================ */
background: var(--card); .visibility-selector {
color: var(--muted-foreground); display: inline-flex;
cursor: pointer; align-items: center;
transition: all 0.2s ease; gap: 4px;
font-family: inherit; padding: 5px 12px;
} border-radius: var(--radius);
font-size: 12px;
.visibility-selector:hover { font-weight: 500;
border-color: var(--primary); border: 1px solid var(--border);
color: var(--primary); background: transparent;
} color: var(--muted-foreground);
cursor: pointer;
/* ================================================================ transition: all var(--duration-fast) var(--ease-out);
Buttons font-family: var(--font-sans);
================================================================ */ }
.btn {
display: inline-flex; .visibility-selector:hover {
align-items: center; border-color: hsl(2 78% 53% / 0.5);
justify-content: center; color: var(--foreground);
gap: 6px; }
white-space: nowrap;
border-radius: calc(var(--radius) - 2px); /* ================================================================
font-size: 13px; Buttons
font-weight: 600; ================================================================ */
font-family: inherit; .btn {
cursor: pointer; display: inline-flex;
border: none; align-items: center;
padding: 9px 18px; justify-content: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); gap: 6px;
} white-space: nowrap;
border-radius: var(--radius);
.btn:active { font-size: 13px;
transform: scale(0.96); font-weight: 500;
} font-family: var(--font-sans);
cursor: pointer;
.btn-primary { border: none;
background: linear-gradient(135deg, var(--primary), oklch(0.5 0.1 50)); padding: 8px 16px;
color: var(--primary-foreground); transition: all var(--duration-fast) var(--ease-out);
box-shadow: 0 2px 8px oklch(0.45 0.08 45 / 0.25); }
}
.btn:focus-visible {
.btn-primary:hover { outline: 2px solid var(--ring);
filter: brightness(1.08); outline-offset: 2px;
box-shadow: 0 4px 14px oklch(0.45 0.08 45 / 0.35); }
transform: translateY(-1px);
} .btn:active {
transform: scale(0.97);
.btn-secondary { }
background: var(--secondary);
color: var(--secondary-foreground); .btn-primary {
border: 1.5px solid var(--border); background: var(--primary);
} color: var(--primary-foreground);
}
.btn-secondary:hover {
background: var(--muted); .btn-primary:hover {
transform: translateY(-1px); background: hsl(2 78% 48%);
} }
.btn-ghost { .btn-secondary {
background: transparent; background: var(--secondary);
color: var(--muted-foreground); color: var(--secondary-foreground);
padding: 6px 12px; border: 1px solid var(--border);
} }
.btn-ghost:hover { .btn-secondary:hover {
background: var(--muted); background: hsl(240 3.7% 20%);
color: var(--foreground); }
}
.btn-ghost {
.btn-sm { background: transparent;
padding: 6px 14px; color: var(--muted-foreground);
font-size: 12px; padding: 6px 10px;
border-radius: calc(var(--radius) - 4px); }
}
.btn-ghost:hover {
.btn:disabled { background: var(--accent);
opacity: 0.5; color: var(--foreground);
cursor: not-allowed; }
transform: none !important;
filter: none !important; .btn-sm {
} padding: 6px 12px;
font-size: 12px;
/* ================================================================ border-radius: calc(var(--radius) - 2px);
Action Bar (bottom) }
================================================================ */
.action-bar { .btn-full {
display: flex; width: 100%;
align-items: center; }
justify-content: space-between;
padding: 12px 18px 14px; .btn:disabled {
border-top: 1px solid var(--border); opacity: 0.5;
background: var(--card); cursor: not-allowed;
} pointer-events: none;
}
.action-bar-left {
display: flex; /* Icon Button */
align-items: center; .btn-icon {
gap: 6px; padding: 6px;
} width: 28px;
height: 28px;
.action-bar-right { border-radius: calc(var(--radius) - 2px);
display: flex; background: transparent;
align-items: center; color: var(--muted-foreground);
gap: 8px; border: none;
} cursor: pointer;
display: inline-flex;
/* ================================================================ align-items: center;
Badges & Messages justify-content: center;
================================================================ */ transition: all var(--duration-fast) var(--ease-out);
.badge { font-family: var(--font-sans);
display: inline-flex; }
align-items: center;
gap: 4px; .btn-icon:hover {
padding: 4px 10px; background: var(--accent);
border-radius: 100px; color: var(--foreground);
font-size: 12px; }
font-weight: 600;
} /* ================================================================
Action Bar (bottom)
.badge-success { ================================================================ */
background: oklch(0.6 0.15 145 / 0.12); .action-bar {
color: var(--success); display: flex;
border: 1px solid oklch(0.6 0.15 145 / 0.2); align-items: center;
animation: successPop 0.3s ease-out; justify-content: space-between;
} padding: 10px 16px 12px;
border-top: 1px solid var(--border);
.error { background: var(--card);
padding: 10px 14px; }
margin-top: 10px;
background: oklch(0.55 0.15 25 / 0.08); .action-bar-left {
color: var(--destructive); display: flex;
border-radius: calc(var(--radius) - 2px); align-items: center;
font-size: 12px; gap: 6px;
border: 1px solid oklch(0.55 0.15 25 / 0.15); }
animation: fadeIn 0.2s ease-out;
} .action-bar-right {
display: flex;
.success { align-items: center;
padding: 10px 14px; gap: 6px;
margin-bottom: 12px; }
background: oklch(0.6 0.15 145 / 0.08);
color: var(--success); /* ================================================================
border-radius: calc(var(--radius) - 2px); Badges & Messages
font-size: 12px; ================================================================ */
border: 1px solid oklch(0.6 0.15 145 / 0.15); .badge {
} display: inline-flex;
align-items: center;
/* ================================================================ gap: 4px;
Loading Spinner padding: 3px 8px;
================================================================ */ border-radius: 9999px;
.spinner { font-size: 11px;
display: inline-block; font-weight: 500;
width: 14px; }
height: 14px;
border: 2px solid var(--border); .badge-success {
border-top-color: var(--primary); background: hsl(142 71% 45% / 0.12);
border-radius: 50%; color: var(--success);
animation: spin 0.6s linear infinite; border: 1px solid hsl(142 71% 45% / 0.2);
vertical-align: middle; }
}
.badge-error {
/* ================================================================ background: hsl(0 62.8% 30.6% / 0.12);
Scrollbar color: hsl(0 72% 51%);
================================================================ */ border: 1px solid hsl(0 62.8% 30.6% / 0.2);
::-webkit-scrollbar { }
width: 5px;
} .error {
padding: 8px 12px;
::-webkit-scrollbar-track { margin-top: 8px;
background: transparent; background: hsl(0 62.8% 30.6% / 0.1);
} color: hsl(0 72% 51%);
border-radius: var(--radius);
::-webkit-scrollbar-thumb { font-size: 12px;
background: var(--border); border: 1px solid hsl(0 62.8% 30.6% / 0.2);
border-radius: 100px; animation: fadeIn var(--duration-fast) var(--ease-out);
} }
::-webkit-scrollbar-thumb:hover { .success {
background: var(--muted-foreground); padding: 8px 12px;
} margin-bottom: 8px;
background: hsl(142 71% 45% / 0.1);
/* ================================================================ color: var(--success);
Smooth transitions on all themed elements border-radius: var(--radius);
================================================================ */ font-size: 12px;
*, border: 1px solid hsl(142 71% 45% / 0.2);
*::before, }
*::after {
transition-property: background-color, color, border-color, box-shadow, opacity; /* Inline Banner */
transition-duration: 0.2s; .inline-banner {
transition-timing-function: ease; display: flex;
} align-items: center;
gap: 8px;
/* Opt-out for animation elements */ padding: 10px 12px;
.spinner, background: var(--secondary);
.spinner *, border: 1px solid var(--border);
[class*="animate-"] { border-radius: var(--radius);
transition: none !important; margin-bottom: 12px;
font-size: 12px;
color: var(--muted-foreground);
}
.inline-banner-icon {
flex-shrink: 0;
font-size: 14px;
}
.inline-banner a {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.inline-banner a:hover {
color: hsl(2 78% 60%);
}
/* ================================================================
Settings
================================================================ */
.settings-section {
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.settings-section:last-child {
border-bottom: none;
}
.settings-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 10px;
color: var(--foreground);
}
.settings-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.settings-item-label {
font-size: 13px;
font-weight: 500;
color: var(--foreground);
}
.settings-item-desc {
font-size: 11px;
color: var(--muted-foreground);
margin-top: 1px;
}
.settings-input-group {
display: flex;
gap: 6px;
}
.settings-input-group .input {
flex: 1;
}
/* ================================================================
Loading Spinner
================================================================ */
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
vertical-align: middle;
}
/* ================================================================
Scrollbar
================================================================ */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
} }
\ No newline at end of file
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CuCu Note</title> <title>Canifa Note</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="./popup.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <style>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> body {
<link rel="stylesheet" href="./popup.css"> margin: 0;
<style> padding: 0;
body { width: 380px;
margin: 0; min-height: 380px;
padding: 0; max-height: 560px;
width: 380px; overflow-y: auto;
min-height: 420px; }
max-height: 560px; </style>
overflow-y: auto; </head>
} <body>
</style> <div id="popup-root"></div>
</head> <script type="module" src="./popup.tsx"></script>
<body> </body>
<div id="popup-root"></div> </html>
<script type="module" src="./popup.tsx"></script>
</body>
</html>
/** /**
* CuCu Note Extension Popup * Canifa Note Extension Popup
* *
* Simple, reliable approach: * Redesigned with shadcn/ui dark mode + Canifa branding.
* 1. On open → grab fresh Clerk token from any OpenNotion tab * Auth: Extracts Memos access-token from open tabs.
* 2. If token found → show NoteForm * Always shows NoteForm — no login wall.
* 3. If no token → show "Sign in" button */
* 4. Popup stays OPEN after save
*/ import { useState, useEffect, useCallback } from 'react';
import { createRoot } from 'react-dom/client';
import { useState, useEffect, useCallback } from 'react'; import { NoteForm } from '../components/NoteForm';
import { createRoot } from 'react-dom/client'; import './popup.css';
import { NoteForm } from '../components/NoteForm';
import './popup.css'; const WEB_APP_URL = 'http://172.16.2.210:5230';
const WEB_APP_URL = 'http://172.16.2.210:5230'; // ========== Token Grabber (Memos) ==========
// ========== Theme Hook ========== async function grabTokenFromTabs(): Promise<string | null> {
try {
function useTheme() { const allTabs = await chrome.tabs.query({});
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('dark'); const candidates = allTabs.filter((t) => {
const url = t.url || '';
useEffect(() => { return (
const saved = localStorage.getItem('cucu-theme') as 'light' | 'dark' | 'system' | null; url.includes('172.16.2.210') ||
if (saved) setTheme(saved); url.includes('localhost:5230') ||
}, []); url.includes('cucunote') ||
url.includes('canifa')
useEffect(() => { );
localStorage.setItem('cucu-theme', theme); });
const root = document.documentElement;
if (theme === 'system') { for (const tab of candidates) {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; if (!tab.id) continue;
root.setAttribute('data-theme', isDark ? 'dark' : 'light'); try {
} else { const results = await chrome.scripting.executeScript({
root.setAttribute('data-theme', theme); target: { tabId: tab.id },
} world: 'MAIN',
}, [theme]); func: () => {
try {
const cycleTheme = () => { // 1. Memos access-token cookie
setTheme((prev) => (prev === 'light' ? 'dark' : prev === 'dark' ? 'system' : 'light')); const match = document.cookie.match(/(?:^|; )memos\.access-token=([^;]+)/);
}; if (match && match[1]) return match[1];
const icon = theme === 'light' ? '☀️' : theme === 'dark' ? '🌙' : '🌓'; // 2. localStorage
return { theme, cycleTheme, icon }; for (let i = 0; i < localStorage.length; i++) {
} const key = localStorage.key(i);
if (key && (key.includes('access_token') || key.includes('token'))) {
// ========== Token Grabber ========== const val = localStorage.getItem(key);
if (val && val.length > 20 && !val.startsWith('{')) return val;
async function grabTokenFromTabs(): Promise<string | null> { }
try { }
const allTabs = await chrome.tabs.query({}); } catch { /* ignore */ }
const candidates = allTabs.filter((t) => { return null;
const url = t.url || ''; },
return ( });
url.includes('172.16.2.210') || const token = results[0]?.result;
url.includes('localhost:3001') || if (token && typeof token === 'string' && token.length > 10) {
url.includes('opennotion') || await chrome.storage.local.set({
url.includes('cucunote') memosAccessToken: token,
); tokenSyncedAt: Date.now(),
}); });
return token;
for (const tab of candidates) { }
if (!tab.id) continue; } catch {
try { // Tab didn't work, try next
const results = await chrome.scripting.executeScript({ }
target: { tabId: tab.id }, }
world: 'MAIN', } catch { /* Tab query failed */ }
func: async () => {
try { // Fallback: stored token
// 1. Try Memos cookie try {
const match = document.cookie.match(/(?:^|; )memos\.access-token=([^;]+)/); const stored = await chrome.storage.local.get(['memosAccessToken', 'tokenSyncedAt']);
if (match && match[1]) return match[1]; if (stored.memosAccessToken) {
const age = Date.now() - (stored.tokenSyncedAt || 0);
// 2. Try localStorage if (age < 24 * 60 * 60 * 1000) { // Memos tokens last much longer than Clerk
for (let i = 0; i < localStorage.length; i++) { return stored.memosAccessToken;
const key = localStorage.key(i); }
if (key && (key.includes('token') || key.includes('session'))) { }
const val = localStorage.getItem(key); } catch { /* storage failed */ }
if (val && typeof val === 'string' && val.length > 50 && !val.startsWith('{')) {
return val; return null;
} }
}
} // ========== Main App ==========
// 3. Try Clerk function App() {
const clerk = (window as any).Clerk; const [isConnected, setIsConnected] = useState(false);
if (clerk?.session?.getToken) { const [isLoading, setIsLoading] = useState(true);
return await clerk.session.getToken(); const [currentUrl, setCurrentUrl] = useState('');
} const [currentTitle, setCurrentTitle] = useState('');
} catch { /* ignore */ } const [pendingText, setPendingText] = useState('');
return null;
}, useEffect(() => {
}); (async () => {
const token = results[0]?.result; // Get current tab info
if (token && typeof token === 'string' && token.length > 50) { try {
// Save to storage for other parts of extension const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
await chrome.storage.local.set({ if (tab) {
clerkSessionToken: token, setCurrentUrl(tab.url || '');
clerkTokenSyncedAt: Date.now(), setCurrentTitle(tab.title || '');
}); }
return token; } catch { /* ignore */ }
}
} catch { // Get pending note
// This tab didn't work, try next try {
} const result = await chrome.storage.local.get(['pendingNote']);
} if (result.pendingNote) {
} catch { /* Tab query failed */ } setPendingText(result.pendingNote.text || '');
chrome.storage.local.remove(['pendingNote']);
// Fallback: check storage }
try { } catch { /* ignore */ }
const stored = await chrome.storage.local.get(['clerkSessionToken', 'clerkTokenSyncedAt']);
if (stored.clerkSessionToken) { // Grab token
const age = Date.now() - (stored.clerkTokenSyncedAt || 0); const t = await grabTokenFromTabs();
if (age < 5 * 60 * 1000) { // less than 5 minutes old setIsConnected(!!t);
return stored.clerkSessionToken; setIsLoading(false);
} })();
} }, []);
} catch { /* storage failed */ }
const getFreshToken = useCallback(async (): Promise<string | null> => {
return null; const fresh = await grabTokenFromTabs();
} setIsConnected(!!fresh);
return fresh;
// ========== Main App ========== }, []);
function App() { const handleOpenApp = () => {
const { cycleTheme, icon: themeIcon } = useTheme(); chrome.tabs.create({ url: `${WEB_APP_URL}` });
};
const [status, setStatus] = useState<'loading' | 'connected' | 'disconnected'>('loading');
const [currentUrl, setCurrentUrl] = useState(''); return (
const [currentTitle, setCurrentTitle] = useState(''); <div className="popup-container">
const [pendingText, setPendingText] = useState(''); {/* Header */}
<header className="header">
// Initialize: grab token + tab info <div className="header-brand">
useEffect(() => { <img src="/image.png" alt="Canifa" className="header-brand-logo" />
(async () => { <span className="header-brand-name">CANIFA</span>
// Get current tab info <span className="header-brand-suffix">Note</span>
try { </div>
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); <div className="header-actions">
if (tab) { <div className="header-status">
setCurrentUrl(tab.url || ''); {isLoading ? (
setCurrentTitle(tab.title || ''); <span className="spinner" style={{ width: 10, height: 10 }} />
} ) : (
} catch { /* ignore */ } <>
<span className={`status-dot ${isConnected ? 'connected' : 'disconnected'}`} />
// Get pending note from context menu / content script <span className="status-text">{isConnected ? 'Synced' : 'Offline'}</span>
try { </>
const result = await chrome.storage.local.get(['pendingNote']); )}
if (result.pendingNote) { </div>
setPendingText(result.pendingNote.text || ''); <button className="btn-icon" onClick={handleOpenApp} title="Open Canifa Note">
chrome.storage.local.remove(['pendingNote']);
} </button>
} catch { /* ignore */ } </div>
</header>
// Grab token
const t = await grabTokenFromTabs(); {/* Connection Banner (only when disconnected) */}
setStatus(t ? 'connected' : 'disconnected'); {!isLoading && !isConnected && (
})(); <div className="content">
}, []); <div className="inline-banner">
<span className="inline-banner-icon"></span>
// Fresh token getter for NoteForm — re-grabs from tab each time <span>
const getFreshToken = useCallback(async (): Promise<string | null> => { <a onClick={handleOpenApp}>Mở Canifa Note</a> trên trình duyệt và đăng nhập để đồng bộ.
const fresh = await grabTokenFromTabs(); </span>
if (fresh) setStatus('connected'); </div>
return fresh; </div>
}, []); )}
// Retry connection {/* Always show NoteForm */}
const handleRetry = async () => { <NoteForm
setStatus('loading'); initialText={pendingText}
const t = await grabTokenFromTabs(); initialUrl={currentUrl}
setStatus(t ? 'connected' : 'disconnected'); initialTitle={currentTitle}
}; getToken={getFreshToken}
/>
return ( </div>
<div className="popup-container"> );
{/* Header */} }
<header className="header">
<div className="header-brand"> // Mount
<span className="header-brand-icon">🐣</span> const root = document.getElementById('popup-root');
<span>CuCu Note</span> if (root) {
<button className="btn btn-ghost btn-sm" onClick={cycleTheme} title="Toggle theme"> createRoot(root).render(<App />);
{themeIcon} }
</button>
</div>
<div className="header-status">
{status === 'loading' && (
<>
<span className="spinner" style={{ width: 10, height: 10 }} />
<span className="status-text">Connecting...</span>
</>
)}
{status === 'connected' && (
<>
<span className="status-dot status-connected" />
<span className="status-text">Connected</span>
</>
)}
{status === 'disconnected' && (
<>
<span className="status-dot status-offline" />
<span className="status-text">Offline</span>
</>
)}
</div>
</header>
{/* Loading */}
{status === 'loading' && (
<div className="content" style={{ textAlign: 'center', padding: '40px' }}>
<span className="spinner" /> Connecting...
</div>
)}
{/* Connected → NoteForm */}
{status === 'connected' && (
<NoteForm
initialText={pendingText}
initialUrl={currentUrl}
initialTitle={currentTitle}
getToken={getFreshToken}
/>
)}
{/* Disconnected → Sign in prompt */}
{status === 'disconnected' && (
<div className="content" style={{ padding: '24px', textAlign: 'center' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🔐</div>
<h3 style={{ margin: '0 0 8px', fontSize: '16px', color: 'var(--text-primary)' }}>
Sign in to CuCu Note
</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginBottom: '20px', lineHeight: 1.5 }}>
Open the web app ad sign in, then come back here
</p>
<button
className="btn btn-primary"
onClick={() => chrome.tabs.create({ url: `${WEB_APP_URL}/auth` })}
style={{ width: '100%', padding: '10px', fontSize: '14px', marginBottom: '10px' }}
>
🚀 Open CuCu Note to Sign In
</button>
<button
className="btn btn-ghost"
onClick={handleRetry}
style={{ width: '100%', padding: '8px', fontSize: '13px' }}
>
🔄 Retry Connection
</button>
</div>
)}
</div>
);
}
// Mount
const root = document.getElementById('popup-root');
if (root) {
createRoot(root).render(<App />);
}
/** /**
* API Client - Gọi backend CuCu Note * API Client — Canifa Note (Memos backend)
* Configurable server URL + tag/workspace fetching * Uses Memos access-token for auth
*/ */
// Default API base URL — empty means user must configure in Settings // Default API base URL
const DEFAULT_API_BASE_URL = 'http://172.16.2.210:5230'; const DEFAULT_API_BASE_URL = 'http://172.16.2.210:5230';
// ========== Config ========== // ========== Config ==========
async function getApiBase(): Promise<string> { async function getApiBase(): Promise<string> {
const result = await chrome.storage.local.get(['apiBaseUrl']); const result = await chrome.storage.local.get(['apiBaseUrl']);
const baseUrl = result.apiBaseUrl || DEFAULT_API_BASE_URL; const baseUrl = result.apiBaseUrl || DEFAULT_API_BASE_URL;
return `${baseUrl}/api/v1`; return `${baseUrl}/api/v1`;
} }
export async function getApiBaseUrl(): Promise<string> { export async function getApiBaseUrl(): Promise<string> {
const result = await chrome.storage.local.get(['apiBaseUrl']); const result = await chrome.storage.local.get(['apiBaseUrl']);
return result.apiBaseUrl || DEFAULT_API_BASE_URL; return result.apiBaseUrl || DEFAULT_API_BASE_URL;
} }
export async function setApiBaseUrl(url: string): Promise<void> { export async function setApiBaseUrl(url: string): Promise<void> {
// Basic URL validation const cleaned = url.trim().replace(/\/+$/, '');
const cleaned = url.trim().replace(/\/+$/, ''); if (cleaned && !cleaned.startsWith('http://') && !cleaned.startsWith('https://')) {
if (cleaned && !cleaned.startsWith('http://') && !cleaned.startsWith('https://')) { throw new Error('URL must start with http:// or https://');
throw new Error('URL must start with http:// or https://'); }
} await chrome.storage.local.set({ apiBaseUrl: cleaned });
await chrome.storage.local.set({ apiBaseUrl: cleaned }); }
}
// ========== Auth ==========
// ========== Auth ==========
async function getAuthToken(): Promise<string | null> {
async function getAuthToken(): Promise<string | null> { const result = await chrome.storage.local.get(['memosAccessToken', 'clerkSessionToken', 'authToken']);
const result = await chrome.storage.local.get(['clerkSessionToken', 'authToken']); // Priority: Memos token > Clerk token > manual token
if (result.clerkSessionToken) return result.clerkSessionToken; if (result.memosAccessToken) return result.memosAccessToken;
return result.authToken || null; if (result.clerkSessionToken) return result.clerkSessionToken;
} return result.authToken || null;
}
export async function setAuthToken(token: string): Promise<void> {
await chrome.storage.local.set({ authToken: token }); export async function setAuthToken(token: string): Promise<void> {
} await chrome.storage.local.set({ authToken: token });
}
export async function getAuthStatus(): Promise<{ isConnected: boolean; tokenLength: number }> {
const token = await getAuthToken(); export async function getAuthStatus(): Promise<{ isConnected: boolean; tokenLength: number }> {
return { const token = await getAuthToken();
isConnected: !!token && token.length > 0, return {
tokenLength: token?.length || 0, isConnected: !!token && token.length > 0,
}; tokenLength: token?.length || 0,
} };
}
/**
* Sync Clerk token from the currently active page (must be the frontend app) // ========== API Helpers ==========
*/
export async function syncClerkTokenFromPage(): Promise<string | null> { async function apiHeaders(): Promise<Record<string, string>> {
try { const token = await getAuthToken();
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (!tab.id) return null; if (token) {
headers['Authorization'] = `Bearer ${token}`;
const results = await chrome.scripting.executeScript({ }
target: { tabId: tab.id }, return headers;
func: async () => { }
if (typeof window !== 'undefined' && (window as any).Clerk?.session?.getToken) {
try { // ========== Memo CRUD ==========
const token = await (window as any).Clerk.session.getToken();
return token || null; export interface CreateMemoRequest {
} catch { content: string;
return null; tags?: string[];
} visibility?: string;
} }
return null;
}, export interface MemoResponse {
}); id: string;
content: string;
const token = results[0]?.result || null; tags: string[];
if (token) { created_at: string;
await chrome.storage.local.set({ clerkSessionToken: token }); }
}
return token; export async function createMemo(data: CreateMemoRequest): Promise<MemoResponse> {
} catch { const apiBase = await getApiBase();
return null; const headers = await apiHeaders();
}
} const response = await fetch(`${apiBase}/memos`, {
method: 'POST',
// ========== API Helpers ========== headers,
body: JSON.stringify({
// Token is stale if synced more than 45s ago (Clerk JWT expires ~60s) content: data.content,
const TOKEN_MAX_AGE_MS = 45_000; tags: data.tags || [],
visibility: data.visibility || 'PRIVATE',
async function tryRefreshToken(): Promise<void> { }),
try { });
const result = await chrome.storage.local.get(['clerkTokenSyncedAt']);
const syncedAt = result.clerkTokenSyncedAt || 0; if (!response.ok) {
const age = Date.now() - syncedAt; await response.text();
throw new Error(`Failed to create memo (${response.status})`);
// Only refresh if token is stale }
if (age > TOKEN_MAX_AGE_MS) {
console.log('[CuCu API] Token stale, refreshing...', { age: Math.round(age / 1000) + 's' }); const result = await response.json();
const response = await new Promise<any>((resolve) => { return result;
chrome.runtime.sendMessage({ type: 'REFRESH_TOKEN' }, (res) => { }
resolve(res);
}); /**
}); * Create a memo using a direct token
*/
// Background returned fresh token — save it export async function createMemoWithToken(token: string, data: CreateMemoRequest): Promise<MemoResponse> {
if (response?.success && response?.token) { const apiBase = await getApiBase();
await chrome.storage.local.set({
clerkSessionToken: response.token, const response = await fetch(`${apiBase}/memos`, {
clerkTokenSyncedAt: Date.now(), method: 'POST',
}); headers: {
console.log('[CuCu API] ✅ Token refreshed', response.token.length, 'chars'); 'Content-Type': 'application/json',
} else { 'Authorization': `Bearer ${token}`,
console.log('[CuCu API] ⚠️ Token refresh failed:', response?.reason || 'unknown'); },
} body: JSON.stringify({
} content: data.content,
} catch (err: any) { tags: data.tags || [],
console.log('[CuCu API] ❌ Token refresh error:', err?.message); visibility: data.visibility || 'PRIVATE',
} }),
} });
async function apiHeaders(): Promise<Record<string, string>> { if (!response.ok) {
// Try to refresh token if stale const errorText = await response.text();
await tryRefreshToken(); throw new Error(`Failed to save (${response.status}): ${errorText.substring(0, 100)}`);
}
const token = await getAuthToken();
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; return await response.json();
if (token) { }
headers['Authorization'] = `Bearer ${token}`;
} // ========== Tags ==========
return headers;
} export async function fetchTags(): Promise<string[]> {
try {
// ========== Memo CRUD ========== const apiBase = await getApiBase();
const headers = await apiHeaders();
export interface CreateMemoRequest {
content: string; const response = await fetch(`${apiBase}/memos`, { headers });
tags?: string[]; if (!response.ok) return [];
visibility?: string;
} const memos: any[] = await response.json();
export interface MemoResponse { const tagSet = new Set<string>();
id: string; for (const memo of memos) {
content: string; if (Array.isArray(memo.tags)) {
tags: string[]; memo.tags.forEach((t: string) => tagSet.add(t));
created_at: string; }
} const hashTags = (memo.content || '').match(/#([a-zA-Z0-9_\u00C0-\u024F\u1E00-\u1EFF]+)/g);
if (hashTags) {
export async function createMemo(data: CreateMemoRequest): Promise<MemoResponse> { hashTags.forEach((t: string) => tagSet.add(t.slice(1)));
const apiBase = await getApiBase(); }
const headers = await apiHeaders(); }
const response = await fetch(`${apiBase}/memos`, { return Array.from(tagSet).sort();
method: 'POST', } catch {
headers, return [];
body: JSON.stringify({ }
content: data.content, }
tags: data.tags || [],
visibility: data.visibility || 'PRIVATE', // ========== Workspaces ==========
}),
}); export interface WorkspaceItem {
id: number;
if (!response.ok) { title: string;
await response.text(); // consume body filter: string;
throw new Error(`Failed to create memo (${response.status})`); }
}
export async function fetchWorkspaces(): Promise<WorkspaceItem[]> {
const result = await response.json(); try {
return result; const apiBase = await getApiBase();
} const headers = await apiHeaders();
/** const response = await fetch(`${apiBase}/shortcuts`, { headers });
* Create a memo using a direct token (from Clerk getToken()) if (!response.ok) return [];
* Bypasses storage-based token reading — always uses fresh JWT
*/ const data = await response.json();
export async function createMemoWithToken(token: string, data: CreateMemoRequest): Promise<MemoResponse> { const shortcuts = Array.isArray(data) ? data : data.shortcuts || [];
const apiBase = await getApiBase(); return shortcuts.map((s: any) => ({
id: s.id,
const response = await fetch(`${apiBase}/memos`, { title: s.title || s.name || `Workspace ${s.id}`,
method: 'POST', filter: s.filter || '',
headers: { }));
'Content-Type': 'application/json', } catch {
'Authorization': `Bearer ${token}`, return [];
}, }
body: JSON.stringify({ }
content: data.content,
tags: data.tags || [], // ========== Connection Test ==========
visibility: data.visibility || 'PRIVATE',
}), export async function testConnection(): Promise<{ ok: boolean; message: string }> {
}); try {
const apiBase = await getApiBase();
if (!response.ok) { const headers = await apiHeaders();
const errorText = await response.text();
throw new Error(`Failed to save (${response.status}): ${errorText.substring(0, 100)}`); const response = await fetch(`${apiBase}/memos`, {
} method: 'GET',
headers,
return await response.json(); signal: AbortSignal.timeout(5000),
} });
// ========== Tags ========== if (response.ok) {
return { ok: true, message: 'Connected successfully!' };
export async function fetchTags(): Promise<string[]> { } else {
try { return { ok: false, message: `Server error: ${response.status}` };
const apiBase = await getApiBase(); }
const headers = await apiHeaders(); } catch (error) {
return { ok: false, message: `Cannot reach server: ${(error as Error).message}` };
const response = await fetch(`${apiBase}/memos`, { headers }); }
if (!response.ok) return []; }
const memos: any[] = await response.json(); // ========== Recently Used Tags (local) ==========
// Extract unique tags from all memos export async function getRecentTags(): Promise<string[]> {
const tagSet = new Set<string>(); const result = await chrome.storage.local.get(['recentTags']);
for (const memo of memos) { return result.recentTags || [];
if (Array.isArray(memo.tags)) { }
memo.tags.forEach((t: string) => tagSet.add(t));
} export async function saveRecentTags(tags: string[]): Promise<void> {
// Also parse #tag from content const unique = [...new Set(tags)].slice(0, 20);
const hashTags = (memo.content || '').match(/#([a-zA-Z0-9_\u00C0-\u024F\u1E00-\u1EFF]+)/g); await chrome.storage.local.set({ recentTags: unique });
if (hashTags) { }
hashTags.forEach((t: string) => tagSet.add(t.slice(1))); // remove #
}
}
return Array.from(tagSet).sort();
} catch {
return [];
}
}
// ========== Workspaces (Shortcuts) ==========
export interface WorkspaceItem {
id: number;
title: string;
filter: string;
}
export async function fetchWorkspaces(): Promise<WorkspaceItem[]> {
try {
const apiBase = await getApiBase();
const headers = await apiHeaders();
const response = await fetch(`${apiBase}/shortcuts`, { headers });
if (!response.ok) return [];
const data = await response.json();
// API may return array directly or { shortcuts: [...] }
const shortcuts = Array.isArray(data) ? data : data.shortcuts || [];
return shortcuts.map((s: any) => ({
id: s.id,
title: s.title || s.name || `Workspace ${s.id}`,
filter: s.filter || '',
}));
} catch {
return [];
}
}
// ========== Connection Test ==========
export async function testConnection(): Promise<{ ok: boolean; message: string }> {
try {
const apiBase = await getApiBase();
const headers = await apiHeaders();
const response = await fetch(`${apiBase}/memos`, {
method: 'GET',
headers,
signal: AbortSignal.timeout(5000),
});
if (response.ok) {
return { ok: true, message: 'Connected successfully!' };
} else {
return { ok: false, message: `Server error: ${response.status}` };
}
} catch (error) {
return { ok: false, message: `Cannot reach server: ${(error as Error).message}` };
}
}
// ========== Recently Used Tags (local) ==========
export async function getRecentTags(): Promise<string[]> {
const result = await chrome.storage.local.get(['recentTags']);
return result.recentTags || [];
}
export async function saveRecentTags(tags: string[]): Promise<void> {
// Keep only last 20 unique tags
const unique = [...new Set(tags)].slice(0, 20);
await chrome.storage.local.set({ recentTags: unique });
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment