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>
......
{ {
"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 {
...@@ -19,9 +18,9 @@ async function grabFreshToken(): Promise<string | null> { ...@@ -19,9 +18,9 @@ async function grabFreshToken(): Promise<string | null> {
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:3001') || url.includes('localhost:5230') ||
url.includes('opennotion') || url.includes('cucunote') ||
url.includes('cucunote') url.includes('canifa')
); );
}); });
...@@ -31,28 +30,20 @@ async function grabFreshToken(): Promise<string | null> { ...@@ -31,28 +30,20 @@ async function grabFreshToken(): Promise<string | null> {
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: async () => { func: () => {
try { try {
// 1. Try Memos cookie // 1. Memos access-token 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. Try localStorage // 2. 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('token') || key.includes('session'))) { if (key && (key.includes('access_token') || key.includes('token'))) {
const val = localStorage.getItem(key); const val = localStorage.getItem(key);
if (val && typeof val === 'string' && val.length > 50 && !val.startsWith('{')) { if (val && val.length > 20 && !val.startsWith('{')) return val;
return val;
} }
} }
}
// 3. Try Clerk
const clerk = (window as any).Clerk;
if (clerk?.session?.getToken) {
return await clerk.session.getToken();
}
} catch { /* ignore */ } } catch { /* ignore */ }
return null; return null;
}, },
...@@ -60,14 +51,14 @@ async function grabFreshToken(): Promise<string | null> { ...@@ -60,14 +51,14 @@ async function grabFreshToken(): Promise<string | null> {
const token = results[0]?.result; const token = results[0]?.result;
if (token) { if (token) {
await chrome.storage.local.set({ await chrome.storage.local.set({
clerkSessionToken: token, memosAccessToken: token,
clerkTokenSyncedAt: Date.now(), tokenSyncedAt: Date.now(),
}); });
console.log('[CuCu BG] ✅ Fresh token from tab', tab.id, `(${token.length} chars)`); console.log('[Canifa Note] ✅ Token synced from tab', tab.id);
return token; return token;
} }
} catch { } catch {
// This tab didn't work, try next // Try next tab
} }
} }
} catch { } catch {
...@@ -79,62 +70,49 @@ async function grabFreshToken(): Promise<string | null> { ...@@ -79,62 +70,49 @@ async function grabFreshToken(): Promise<string | null> {
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
// ============ AUTO-SYNC AUTH TOKEN ============ // ============ AUTO-SYNC AUTH TOKEN ============
if (message.type === 'SYNC_AUTH') { if (message.type === 'SYNC_AUTH') {
const { clerkSessionToken } = message.data || {}; const token = message.data?.memosAccessToken || message.data?.clerkSessionToken;
if (clerkSessionToken) { if (token) {
chrome.storage.local.set({ chrome.storage.local.set({
clerkSessionToken, memosAccessToken: token,
clerkTokenSyncedAt: Date.now(), tokenSyncedAt: Date.now(),
}); });
console.log('[CuCu BG] ✅ Clerk token auto-synced from content script'); console.log('[Canifa Note] ✅ Token auto-synced from content script');
} }
sendResponse({ success: true }); sendResponse({ success: true });
return false; // synchronous response return false;
} }
// ============ REFRESH TOKEN (popup requests fresh token) ============ // ============ REFRESH TOKEN ============
if (message.type === 'REFRESH_TOKEN') { if (message.type === 'REFRESH_TOKEN') {
// Scan ALL OpenNotion tabs to get a fresh Clerk JWT
grabFreshToken() grabFreshToken()
.then((token) => { .then((token) => {
if (token) { if (token) {
sendResponse({ success: true, token }); sendResponse({ success: true, token });
} else { } else {
sendResponse({ success: false, reason: 'no_opennotion_tab' }); sendResponse({ success: false, reason: 'no_canifa_note_tab' });
} }
}) })
.catch((err) => { .catch((err) => {
console.log('[CuCu BG] ❌ Token refresh failed:', err?.message);
sendResponse({ success: false, reason: err?.message }); sendResponse({ success: false, reason: err?.message });
}); });
return true; // keep channel open for async return true;
} }
// ============ SHOW NOTE FORM ============ // ============ SHOW NOTE FORM ============
if (message.type === 'SHOW_NOTE_FORM') { if (message.type === 'SHOW_NOTE_FORM') {
// Mở popup với note form
chrome.action.openPopup(); chrome.action.openPopup();
chrome.storage.local.set({ pendingNote: message.data });
// Lưu data vào storage để popup có thể lấy
chrome.storage.local.set({
pendingNote: message.data,
});
sendResponse({ success: true }); sendResponse({ success: true });
} }
// ============ SAVE NOTE ============ // ============ SAVE NOTE ============
else if (message.type === 'SAVE_NOTE') { else if (message.type === 'SAVE_NOTE') {
// Auto save note ngay lập tức
const { text, url, title } = message.data; const { text, url, title } = message.data;
// Parse tags từ URL
const domain = new URL(url).hostname.replace('www.', ''); const domain = new URL(url).hostname.replace('www.', '');
const tagList = [domain, 'web-highlight']; const tagList = [domain, 'web-highlight'];
// Add source info vào content
const contentWithSource = `${text}\n\n---\nSource: [${title}](${url})`; const contentWithSource = `${text}\n\n---\nSource: [${title}](${url})`;
// Gọi API để save (có gửi kèm Clerk token trong Authorization header)
const memoData = { const memoData = {
content: contentWithSource, content: contentWithSource,
tags: tagList, tags: tagList,
...@@ -149,8 +127,8 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { ...@@ -149,8 +127,8 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
sendResponse({ success: false, error: error.message }); sendResponse({ success: false, error: error.message });
}); });
return true; // Keep channel open for async return true;
} }
return true; // Keep channel open for async response return true;
}); });
/** /**
* 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;
...@@ -29,7 +28,6 @@ export function NoteForm({ ...@@ -29,7 +28,6 @@ export function NoteForm({
}: NoteFormProps) { }: NoteFormProps) {
const [text, setText] = useState(initialText); const [text, setText] = useState(initialText);
const [tags, setTags] = useState<string[]>([]); const [tags, setTags] = useState<string[]>([]);
const [workspace, setWorkspace] = useState('');
const [visibility, setVisibility] = useState<'PRIVATE' | 'PUBLIC'>('PRIVATE'); const [visibility, setVisibility] = useState<'PRIVATE' | 'PUBLIC'>('PRIVATE');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
...@@ -37,7 +35,7 @@ export function NoteForm({ ...@@ -37,7 +35,7 @@ export function NoteForm({
const handleSave = async () => { const handleSave = async () => {
if (!text.trim()) { if (!text.trim()) {
setError('Note cannot be empty'); setError('Chưa nhập nội dung ghi chú');
return; return;
} }
...@@ -46,42 +44,33 @@ export function NoteForm({ ...@@ -46,42 +44,33 @@ export function NoteForm({
setSaved(false); setSaved(false);
try { try {
// Get FRESH token from Clerk
const token = await getToken(); const token = await getToken();
if (!token) { if (!token) {
setError('Not authenticated. Please sign in again.'); setError('Chưa đồng bộ. Mở Canifa Note trên trình duyệt và đăng nhập.');
setLoading(false); setLoading(false);
return; return;
} }
// Build content with source info
let content = text; let content = text;
if (initialUrl && initialUrl !== 'about:blank') { if (initialUrl && initialUrl !== 'about:blank') {
content += `\n\n---\nSource: [${initialTitle || initialUrl}](${initialUrl})`; content += `\n\n---\nSource: [${initialTitle || initialUrl}](${initialUrl})`;
} }
// Call API with fresh token
await createMemoWithToken(token, { await createMemoWithToken(token, {
content, content,
tags, tags,
visibility, visibility,
}); });
// Success! Show message but KEEP popup open
setLoading(false); setLoading(false);
setSaved(true); setSaved(true);
// Clear form for next note
setText(''); setText('');
setTags([]); setTags([]);
setError(null); setError(null);
// Auto-hide success after 3s
setTimeout(() => setSaved(false), 3000); setTimeout(() => setSaved(false), 3000);
if (onSave) onSave(); if (onSave) onSave();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save note'); setError(err instanceof Error ? err.message : 'Lưu thất bại');
setLoading(false); setLoading(false);
} }
}; };
...@@ -89,54 +78,41 @@ export function NoteForm({ ...@@ -89,54 +78,41 @@ export function NoteForm({
return ( return (
<div className="note-form"> <div className="note-form">
<div className="content"> <div className="content">
{/* Success Banner */} {/* Success */}
{saved && ( {saved && (
<div className="badge badge-success" style={{ <div className="success" style={{ textAlign: 'center', animation: 'fadeIn 0.2s ease' }}>
textAlign: 'center', ✓ Đã lưu vào Canifa Note
padding: '10px',
fontSize: '14px',
marginBottom: '8px',
animation: 'fadeIn 0.3s ease'
}}>
✅ Saved to CuCu Note!
</div> </div>
)} )}
{/* Text Area */} {/* Textarea */}
<textarea <textarea
className="note-textarea" className="note-textarea"
placeholder="Any thoughts..." placeholder="Ghi chú..."
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
rows={4} rows={4}
autoFocus autoFocus
/> />
{/* Source Info */} {/* Source */}
{initialUrl && initialUrl !== 'about:blank' && ( {initialUrl && initialUrl !== 'about:blank' && (
<div className="source-info" style={{ <div className="source-info">
fontSize: '12px', <svg className="source-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
color: 'var(--text-secondary)', <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
padding: '6px 10px', <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
backgroundColor: 'var(--surface)', </svg>
borderRadius: '8px', <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
marginTop: '6px', {initialTitle || initialUrl}
overflow: 'hidden', </span>
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
🔗 {initialTitle || initialUrl}
</div> </div>
)} )}
{/* Tag Selector */} {/* Tags */}
<div style={{ marginTop: '12px' }}> <div style={{ marginTop: '10px' }}>
<TagSelector selectedTags={tags} onChange={setTags} /> <TagSelector selectedTags={tags} onChange={setTags} />
</div> </div>
{/* Workspace Selector */}
<WorkspaceSelector value={workspace} onChange={setWorkspace} />
{/* Error */} {/* Error */}
{error && <div className="error">{error}</div>} {error && <div className="error">{error}</div>}
</div> </div>
...@@ -146,12 +122,21 @@ export function NoteForm({ ...@@ -146,12 +122,21 @@ export function NoteForm({
<div className="action-bar-left"> <div className="action-bar-left">
<button <button
className="visibility-selector" className="visibility-selector"
onClick={() => onClick={() => setVisibility(visibility === 'PRIVATE' ? 'PUBLIC' : 'PRIVATE')}
setVisibility(visibility === 'PRIVATE' ? 'PUBLIC' : 'PRIVATE') title={`Toggle: ${visibility}`}
}
title={`Click to toggle: ${visibility}`}
> >
{visibility === 'PRIVATE' ? '🔒' : '🌐'}{' '} {visibility === 'PRIVATE' ? (
<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" />
</svg>
) : (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<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" />
</svg>
)}
{visibility === 'PRIVATE' ? 'Private' : 'Public'} {visibility === 'PRIVATE' ? 'Private' : 'Public'}
</button> </button>
</div> </div>
...@@ -169,10 +154,10 @@ export function NoteForm({ ...@@ -169,10 +154,10 @@ export function NoteForm({
disabled={loading || !text.trim()} disabled={loading || !text.trim()}
> >
{saved ? ( {saved ? (
'✅ Saved!' '✓ Saved'
) : loading ? ( ) : loading ? (
<> <>
<span className="spinner" /> Saving... <span className="spinner" style={{ width: 12, height: 12 }} /> Saving...
</> </>
) : ( ) : (
'Save' 'Save'
......
/** /**
* Settings Component * Settings Component — shadcn/ui styled
* Server URL config, auth sync, connection test
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
...@@ -8,7 +7,6 @@ import { ...@@ -8,7 +7,6 @@ import {
getApiBaseUrl, getApiBaseUrl,
setApiBaseUrl, setApiBaseUrl,
getAuthStatus, getAuthStatus,
syncClerkTokenFromPage,
testConnection, testConnection,
} from '../shared/api-client'; } from '../shared/api-client';
...@@ -19,7 +17,6 @@ export function Settings() { ...@@ -19,7 +17,6 @@ export function Settings() {
tokenLength: 0, tokenLength: 0,
}); });
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null); const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const [syncing, setSyncing] = useState(false);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
...@@ -35,7 +32,7 @@ export function Settings() { ...@@ -35,7 +32,7 @@ export function Settings() {
}; };
const handleSaveUrl = async () => { const handleSaveUrl = async () => {
const cleaned = serverUrl.trim().replace(/\/+$/, ''); // remove trailing slashes const cleaned = serverUrl.trim().replace(/\/+$/, '');
await setApiBaseUrl(cleaned); await setApiBaseUrl(cleaned);
setServerUrl(cleaned); setServerUrl(cleaned);
setSaved(true); setSaved(true);
...@@ -43,21 +40,6 @@ export function Settings() { ...@@ -43,21 +40,6 @@ export function Settings() {
setTimeout(() => setSaved(false), 2000); setTimeout(() => setSaved(false), 2000);
}; };
const handleSyncAuth = async () => {
setSyncing(true);
try {
const token = await syncClerkTokenFromPage();
if (token) {
setAuthStatus({ isConnected: true, tokenLength: token.length });
} else {
setAuthStatus({ isConnected: false, tokenLength: 0 });
}
} catch {
setAuthStatus({ isConnected: false, tokenLength: 0 });
}
setSyncing(false);
};
const handleTestConnection = async () => { const handleTestConnection = async () => {
setTesting(true); setTesting(true);
setTestResult(null); setTestResult(null);
...@@ -68,10 +50,9 @@ export function Settings() { ...@@ -68,10 +50,9 @@ export function Settings() {
return ( return (
<div className="content"> <div className="content">
{/* Server URL */} {/* Server */}
<div className="settings-section"> <div className="settings-section">
<div className="settings-title">🌐 Server</div> <div className="settings-title">Server</div>
<div className="form-group"> <div className="form-group">
<label className="label">API URL</label> <label className="label">API URL</label>
<div className="settings-input-group"> <div className="settings-input-group">
...@@ -84,18 +65,16 @@ export function Settings() { ...@@ -84,18 +65,16 @@ export function Settings() {
setSaved(false); setSaved(false);
setTestResult(null); setTestResult(null);
}} }}
placeholder="https://your-domain.com" placeholder="http://172.16.2.210:5230"
/> />
<button className="btn btn-primary btn-sm" onClick={handleSaveUrl}> <button className="btn btn-primary btn-sm" onClick={handleSaveUrl}>
{saved ? '✓' : 'Save'} {saved ? '✓' : 'Save'}
</button> </button>
</div> </div>
<div className="label-hint"> <div className="label-hint">Backend server URL (without /api/v1)</div>
Backend server URL (without /api/v1)
</div>
</div> </div>
<div style={{ marginTop: '8px' }}> <div style={{ marginTop: '6px' }}>
<button <button
className="btn btn-secondary btn-sm btn-full" className="btn btn-secondary btn-sm btn-full"
onClick={handleTestConnection} onClick={handleTestConnection}
...@@ -103,78 +82,45 @@ export function Settings() { ...@@ -103,78 +82,45 @@ export function Settings() {
> >
{testing ? ( {testing ? (
<> <>
<span className="spinner" /> Testing... <span className="spinner" style={{ width: 12, height: 12 }} /> Testing...
</> </>
) : ( ) : (
'🔌 Test Connection' 'Test Connection'
)} )}
</button> </button>
</div> </div>
{testResult && ( {testResult && (
<div <div className={testResult.ok ? 'success' : 'error'} style={{ marginTop: '6px' }}>
className={testResult.ok ? 'success' : 'error'}
style={{ marginTop: '8px' }}
>
{testResult.message} {testResult.message}
</div> </div>
)} )}
</div> </div>
{/* Auth */} {/* Auth Status */}
<div className="settings-section"> <div className="settings-section">
<div className="settings-title">🔑 Authentication</div> <div className="settings-title">Authentication</div>
<div className="settings-item"> <div className="settings-item">
<div> <div>
<div className="settings-item-label">Clerk Session</div> <div className="settings-item-label">Memos Access Token</div>
<div className="settings-item-desc"> <div className="settings-item-desc">Auto-synced from Canifa Note tab</div>
Sync auth from your OpenNotion app
</div>
</div> </div>
<span className={`badge ${authStatus.isConnected ? 'badge-success' : 'badge-error'}`}> <span className={`badge ${authStatus.isConnected ? 'badge-success' : 'badge-error'}`}>
<span <span className={`status-dot ${authStatus.isConnected ? 'connected' : 'disconnected'}`} />
className={`status-dot ${authStatus.isConnected ? 'connected' : 'disconnected'}`}
/>
{authStatus.isConnected ? 'Active' : 'Inactive'} {authStatus.isConnected ? 'Active' : 'Inactive'}
</span> </span>
</div> </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> </div>
{/* About */} {/* About */}
<div className="settings-section"> <div className="settings-section">
<div className="settings-title">ℹ️ About</div> <div className="settings-title">About</div>
<div <div style={{ fontSize: '12px', color: 'var(--muted-foreground)', lineHeight: '1.6' }}>
style={{ <strong>Canifa Note</strong> v1.0.0
fontSize: '12px',
color: 'var(--muted-foreground)',
lineHeight: '1.6',
}}
>
<strong>CuCu Note</strong> v1.0.0
<br /> <br />
Quick note-taking from any web page. Ghi chú nhanh từ bất kỳ trang web nào.
<br /> <br />
Highlight text → Press Space → Auto save! Bôi đen → Nhấn Space → Lưu tự động
</div> </div>
</div> </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';
...@@ -35,11 +34,10 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) { ...@@ -35,11 +34,10 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
fetchTags(), fetchTags(),
getRecentTags(), getRecentTags(),
]); ]);
// Merge: recent first, then server tags
const merged = [...new Set([...recentTags, ...serverTags])]; const merged = [...new Set([...recentTags, ...serverTags])];
setAvailableTags(merged); setAvailableTags(merged);
} catch { } catch {
// Fallback to empty // Fallback
} }
setLoading(false); setLoading(false);
}; };
...@@ -49,7 +47,6 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) { ...@@ -49,7 +47,6 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
onChange(selectedTags.filter((t) => t !== tag)); onChange(selectedTags.filter((t) => t !== tag));
} else { } else {
onChange([...selectedTags, tag]); onChange([...selectedTags, tag]);
// Save to recent
saveRecentTags([tag, ...selectedTags]); saveRecentTags([tag, ...selectedTags]);
} }
}; };
...@@ -77,8 +74,7 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) { ...@@ -77,8 +74,7 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
} }
}; };
// Show max 12 tags to keep UI compact const displayTags = availableTags.slice(0, 10);
const displayTags = availableTags.slice(0, 12);
return ( return (
<div className="form-group"> <div className="form-group">
...@@ -86,7 +82,7 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) { ...@@ -86,7 +82,7 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
<div className="tag-chips"> <div className="tag-chips">
{loading ? ( {loading ? (
<span style={{ fontSize: '11px', color: 'var(--muted-foreground)' }}> <span style={{ fontSize: '11px', color: 'var(--muted-foreground)' }}>
Loading tags... Loading...
</span> </span>
) : ( ) : (
<> <>
...@@ -113,7 +109,7 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) { ...@@ -113,7 +109,7 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
onChange={(e) => setNewTag(e.target.value)} onChange={(e) => setNewTag(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={addNewTag} onBlur={addNewTag}
placeholder="new tag" placeholder="tag"
/> />
</div> </div>
) : ( ) : (
...@@ -128,11 +124,6 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) { ...@@ -128,11 +124,6 @@ export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
</> </>
)} )}
</div> </div>
{selectedTags.length > 0 && (
<div className="label-hint">
{selectedTags.length} tag{selectedTags.length > 1 ? 's' : ''} selected
</div>
)}
</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="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">
<link rel="stylesheet" href="./popup.css"> <link rel="stylesheet" href="./popup.css">
<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;
} }
......
This diff is collapsed.
/** /**
* 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 ==========
...@@ -20,7 +20,6 @@ export async function getApiBaseUrl(): Promise<string> { ...@@ -20,7 +20,6 @@ export async function getApiBaseUrl(): Promise<string> {
} }
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://');
...@@ -31,7 +30,9 @@ export async function setApiBaseUrl(url: string): Promise<void> { ...@@ -31,7 +30,9 @@ export async function setApiBaseUrl(url: string): Promise<void> {
// ========== Auth ========== // ========== Auth ==========
async function getAuthToken(): Promise<string | null> { async function getAuthToken(): Promise<string | null> {
const result = await chrome.storage.local.get(['clerkSessionToken', 'authToken']); const result = await chrome.storage.local.get(['memosAccessToken', 'clerkSessionToken', 'authToken']);
// Priority: Memos token > Clerk token > manual token
if (result.memosAccessToken) return result.memosAccessToken;
if (result.clerkSessionToken) return result.clerkSessionToken; if (result.clerkSessionToken) return result.clerkSessionToken;
return result.authToken || null; return result.authToken || null;
} }
...@@ -48,79 +49,9 @@ export async function getAuthStatus(): Promise<{ isConnected: boolean; tokenLeng ...@@ -48,79 +49,9 @@ export async function getAuthStatus(): Promise<{ isConnected: boolean; tokenLeng
}; };
} }
/**
* Sync Clerk token from the currently active page (must be the frontend app)
*/
export async function syncClerkTokenFromPage(): Promise<string | null> {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab.id) return null;
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: async () => {
if (typeof window !== 'undefined' && (window as any).Clerk?.session?.getToken) {
try {
const token = await (window as any).Clerk.session.getToken();
return token || null;
} catch {
return null;
}
}
return null;
},
});
const token = results[0]?.result || null;
if (token) {
await chrome.storage.local.set({ clerkSessionToken: token });
}
return token;
} catch {
return null;
}
}
// ========== API Helpers ========== // ========== API Helpers ==========
// Token is stale if synced more than 45s ago (Clerk JWT expires ~60s)
const TOKEN_MAX_AGE_MS = 45_000;
async function tryRefreshToken(): Promise<void> {
try {
const result = await chrome.storage.local.get(['clerkTokenSyncedAt']);
const syncedAt = result.clerkTokenSyncedAt || 0;
const age = Date.now() - syncedAt;
// 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 response = await new Promise<any>((resolve) => {
chrome.runtime.sendMessage({ type: 'REFRESH_TOKEN' }, (res) => {
resolve(res);
});
});
// Background returned fresh token — save it
if (response?.success && response?.token) {
await chrome.storage.local.set({
clerkSessionToken: response.token,
clerkTokenSyncedAt: Date.now(),
});
console.log('[CuCu API] ✅ Token refreshed', response.token.length, 'chars');
} else {
console.log('[CuCu API] ⚠️ Token refresh failed:', response?.reason || 'unknown');
}
}
} catch (err: any) {
console.log('[CuCu API] ❌ Token refresh error:', err?.message);
}
}
async function apiHeaders(): Promise<Record<string, string>> { async function apiHeaders(): Promise<Record<string, string>> {
// Try to refresh token if stale
await tryRefreshToken();
const token = await getAuthToken(); const token = await getAuthToken();
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) { if (token) {
...@@ -159,7 +90,7 @@ export async function createMemo(data: CreateMemoRequest): Promise<MemoResponse> ...@@ -159,7 +90,7 @@ export async function createMemo(data: CreateMemoRequest): Promise<MemoResponse>
}); });
if (!response.ok) { if (!response.ok) {
await response.text(); // consume body await response.text();
throw new Error(`Failed to create memo (${response.status})`); throw new Error(`Failed to create memo (${response.status})`);
} }
...@@ -168,8 +99,7 @@ export async function createMemo(data: CreateMemoRequest): Promise<MemoResponse> ...@@ -168,8 +99,7 @@ export async function createMemo(data: CreateMemoRequest): Promise<MemoResponse>
} }
/** /**
* Create a memo using a direct token (from Clerk getToken()) * Create a memo using a direct token
* Bypasses storage-based token reading — always uses fresh JWT
*/ */
export async function createMemoWithToken(token: string, data: CreateMemoRequest): Promise<MemoResponse> { export async function createMemoWithToken(token: string, data: CreateMemoRequest): Promise<MemoResponse> {
const apiBase = await getApiBase(); const apiBase = await getApiBase();
...@@ -207,16 +137,14 @@ export async function fetchTags(): Promise<string[]> { ...@@ -207,16 +137,14 @@ export async function fetchTags(): Promise<string[]> {
const memos: any[] = await response.json(); const memos: any[] = await response.json();
// Extract unique tags from all memos
const tagSet = new Set<string>(); const tagSet = new Set<string>();
for (const memo of memos) { for (const memo of memos) {
if (Array.isArray(memo.tags)) { if (Array.isArray(memo.tags)) {
memo.tags.forEach((t: string) => tagSet.add(t)); memo.tags.forEach((t: string) => tagSet.add(t));
} }
// Also parse #tag from content
const hashTags = (memo.content || '').match(/#([a-zA-Z0-9_\u00C0-\u024F\u1E00-\u1EFF]+)/g); const hashTags = (memo.content || '').match(/#([a-zA-Z0-9_\u00C0-\u024F\u1E00-\u1EFF]+)/g);
if (hashTags) { if (hashTags) {
hashTags.forEach((t: string) => tagSet.add(t.slice(1))); // remove # hashTags.forEach((t: string) => tagSet.add(t.slice(1)));
} }
} }
...@@ -226,7 +154,7 @@ export async function fetchTags(): Promise<string[]> { ...@@ -226,7 +154,7 @@ export async function fetchTags(): Promise<string[]> {
} }
} }
// ========== Workspaces (Shortcuts) ========== // ========== Workspaces ==========
export interface WorkspaceItem { export interface WorkspaceItem {
id: number; id: number;
...@@ -243,7 +171,6 @@ export async function fetchWorkspaces(): Promise<WorkspaceItem[]> { ...@@ -243,7 +171,6 @@ export async function fetchWorkspaces(): Promise<WorkspaceItem[]> {
if (!response.ok) return []; if (!response.ok) return [];
const data = await response.json(); const data = await response.json();
// API may return array directly or { shortcuts: [...] }
const shortcuts = Array.isArray(data) ? data : data.shortcuts || []; const shortcuts = Array.isArray(data) ? data : data.shortcuts || [];
return shortcuts.map((s: any) => ({ return shortcuts.map((s: any) => ({
id: s.id, id: s.id,
...@@ -286,7 +213,6 @@ export async function getRecentTags(): Promise<string[]> { ...@@ -286,7 +213,6 @@ export async function getRecentTags(): Promise<string[]> {
} }
export async function saveRecentTags(tags: string[]): Promise<void> { export async function saveRecentTags(tags: string[]): Promise<void> {
// Keep only last 20 unique tags
const unique = [...new Set(tags)].slice(0, 20); const unique = [...new Set(tags)].slice(0, 20);
await chrome.storage.local.set({ recentTags: unique }); 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