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
});
This diff is collapsed.
This diff is collapsed.
/** /**
* 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>
);
}
This diff is collapsed.
This diff is collapsed.
<!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>
This diff is collapsed.
This diff is collapsed.
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