Commit fe844925 authored by Hoanganhvu123's avatar Hoanganhvu123

feat(extension): polish UI, remove Clerk SDK, direct token auth

parent 30891fb7
...@@ -44,6 +44,11 @@ frontend/node_modules/ ...@@ -44,6 +44,11 @@ frontend/node_modules/
frontend/dist/ frontend/dist/
frontend/.vite/ frontend/.vite/
# Extension specifically
extension/node_modules/
extension/dist/
extension/.vite/
# Preference folder (development/temporary) # Preference folder (development/temporary)
preference/ preference/
......
""" """
Fashion Q&A Agent Package Fashion Q&A Agent Package
""" """
from .graph import build_graph from .graph import build_graph
from .models import AgentConfig, AgentState, get_config from .models import AgentConfig, AgentState, get_config
......
extension/icons/icon128.png

1.21 KB | W: | H:

extension/icons/icon128.png

24.4 KB | W: | H:

extension/icons/icon128.png
extension/icons/icon128.png
extension/icons/icon128.png
extension/icons/icon128.png
  • 2-up
  • Swipe
  • Onion skin
extension/icons/icon16.png

259 Bytes | W: | H:

extension/icons/icon16.png

24.4 KB | W: | H:

extension/icons/icon16.png
extension/icons/icon16.png
extension/icons/icon16.png
extension/icons/icon16.png
  • 2-up
  • Swipe
  • Onion skin
extension/icons/icon48.png

481 Bytes | W: | H:

extension/icons/icon48.png

24.4 KB | W: | H:

extension/icons/icon48.png
extension/icons/icon48.png
extension/icons/icon48.png
extension/icons/icon48.png
  • 2-up
  • Swipe
  • Onion skin
...@@ -10,7 +10,6 @@ ...@@ -10,7 +10,6 @@
"tabs" "tabs"
], ],
"host_permissions": [ "host_permissions": [
"http://localhost:5000/*",
"http://*/*", "http://*/*",
"https://*/*" "https://*/*"
], ],
...@@ -20,8 +19,12 @@ ...@@ -20,8 +19,12 @@
}, },
"content_scripts": [ "content_scripts": [
{ {
"matches": ["<all_urls>"], "matches": [
"js": ["src/content/content-script.ts"], "<all_urls>"
],
"js": [
"src/content/content-script.ts"
],
"run_at": "document_idle", "run_at": "document_idle",
"all_frames": false "all_frames": false
} }
...@@ -40,4 +43,3 @@ ...@@ -40,4 +43,3 @@
"128": "icons/icon128.png" "128": "icons/icon128.png"
} }
} }
\ No newline at end of file
This diff is collapsed.
...@@ -22,4 +22,3 @@ ...@@ -22,4 +22,3 @@
"vite": "^7.2.4" "vite": "^7.2.4"
} }
} }
// Quick script to resize OpenNotion icon for extension
// Uses Node.js canvas (no external deps, just file manipulation)
const fs = require('fs');
const { createCanvas, loadImage } = require('canvas');
async function main() {
const src = 'e:/opennotion/frontend/public/android-chrome-192x192.png';
const sizes = [16, 48, 128];
const img = await loadImage(src);
for (const size of sizes) {
const canvas = createCanvas(size, size);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, size, size);
const buf = canvas.toBuffer('image/png');
fs.writeFileSync(`e:/opennotion/extension/icons/icon${size}.png`, buf);
console.log(`icon${size}.png created`);
}
}
main().catch(console.error);
/** /**
* Background Service Worker * Background Service Worker
* Nhiệm vụ: Handle messages từ content script và gọi API * Nhiệm vụ:
* 1. Handle SYNC_AUTH từ content script (auto-sync Clerk token)
* 2. Handle SAVE_NOTE từ content script (gọi API save memo)
* 3. Handle REFRESH_TOKEN từ popup (refresh Clerk JWT trước khi gọi API)
*/ */
import { createMemo } from '../shared/api-client'; import { createMemo } from '../shared/api-client';
/**
* Grab fresh Clerk token from ANY OpenNotion tab
* Scans all open tabs, finds OpenNotion, injects script to get token
*/
async function grabFreshToken(): Promise<string | null> {
try {
const allTabs = await chrome.tabs.query({});
const candidates = allTabs.filter((t) => {
const url = t.url || '';
return (
url.includes('160.191.50.138') ||
url.includes('localhost:3001') ||
url.includes('opennotion') ||
url.includes('cucunote')
);
});
for (const tab of candidates) {
if (!tab.id) continue;
try {
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
world: 'MAIN',
func: async () => {
try {
const clerk = (window as any).Clerk;
if (clerk?.session?.getToken) {
return await clerk.session.getToken();
}
} catch { /* Clerk not available */ }
return null;
},
});
const token = results[0]?.result;
if (token) {
await chrome.storage.local.set({
clerkSessionToken: token,
clerkTokenSyncedAt: Date.now(),
});
console.log('[CuCu BG] ✅ Fresh token from tab', tab.id, `(${token.length} chars)`);
return token;
}
} catch {
// This tab didn't work, try next
}
}
} catch {
// Tab scanning failed
}
return null;
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
// ============ AUTO-SYNC AUTH TOKEN ============
if (message.type === 'SYNC_AUTH') {
const { clerkSessionToken } = message.data || {};
if (clerkSessionToken) {
chrome.storage.local.set({
clerkSessionToken,
clerkTokenSyncedAt: Date.now(),
});
console.log('[CuCu BG] ✅ Clerk token auto-synced from content script');
}
sendResponse({ success: true });
return false; // synchronous response
}
// ============ REFRESH TOKEN (popup requests fresh token) ============
if (message.type === 'REFRESH_TOKEN') {
// Scan ALL OpenNotion tabs to get a fresh Clerk JWT
grabFreshToken()
.then((token) => {
if (token) {
sendResponse({ success: true, token });
} else {
sendResponse({ success: false, reason: 'no_opennotion_tab' });
}
})
.catch((err) => {
console.log('[CuCu BG] ❌ Token refresh failed:', err?.message);
sendResponse({ success: false, reason: err?.message });
});
return true; // keep channel open for async
}
// ============ SHOW NOTE FORM ============
if (message.type === 'SHOW_NOTE_FORM') { if (message.type === 'SHOW_NOTE_FORM') {
// Mở popup với note form // Mở popup với note form
chrome.action.openPopup(); chrome.action.openPopup();
...@@ -16,12 +104,13 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { ...@@ -16,12 +104,13 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
}); });
sendResponse({ success: true }); sendResponse({ success: true });
} else if (message.type === 'SAVE_NOTE') { }
// ============ SAVE NOTE ============
else if (message.type === 'SAVE_NOTE') {
// Auto save note ngay lập tức // Auto save note ngay lập tức
const { text, url, title } = message.data; const { text, url, title } = message.data;
console.log('[CuCu Note] 📝 Saving note:', { text: text.substring(0, 50) + '...', url, title });
// Parse tags từ URL // 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'];
...@@ -36,11 +125,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { ...@@ -36,11 +125,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
visibility: 'PRIVATE', visibility: 'PRIVATE',
}) })
.then((memo) => { .then((memo) => {
console.log('[CuCu Note] ✅ Note saved with ID:', memo.id);
sendResponse({ success: true, memo }); sendResponse({ success: true, memo });
}) })
.catch((error) => { .catch((error) => {
console.error('[CuCu Note] ❌ Error saving note:', error);
sendResponse({ success: false, error: error.message }); sendResponse({ success: false, error: error.message });
}); });
...@@ -49,4 +136,3 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { ...@@ -49,4 +136,3 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
return true; // Keep channel open for async response return true; // Keep channel open for async response
}); });
/** /**
* Note Form Component * NoteForm Component
* Form để user điền tag và edit note *
* The main note-taking form. Accepts a getToken prop from Clerk
* to get fresh JWTs for API calls. Does NOT close the popup after save.
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { createMemo } from '../shared/api-client'; import { createMemoWithToken } from '../shared/api-client';
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>;
onSave?: () => void; onSave?: () => void;
onCancel?: () => void; onCancel?: () => void;
} }
export function NoteForm({ export function NoteForm({
initialText, initialText = '',
initialUrl, initialUrl = '',
initialTitle, initialTitle = '',
getToken,
onSave, onSave,
onCancel, onCancel,
}: NoteFormProps) { }: NoteFormProps) {
const [text, setText] = useState(initialText); const [text, setText] = useState(initialText);
const [tags, setTags] = useState(''); const [tags, setTags] = useState<string[]>([]);
const [workspace, setWorkspace] = useState('');
const [visibility, setVisibility] = useState<'PRIVATE' | 'PUBLIC'>('PRIVATE');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const handleSave = async () => { const handleSave = async () => {
if (!text.trim()) { if (!text.trim()) {
setError('Note không được để trống'); setError('Note cannot be empty');
return; return;
} }
setLoading(true); setLoading(true);
setError(null); setError(null);
setSaved(false);
try { try {
// Parse tags (comma-separated) // Get FRESH token from Clerk
const tagList = tags const token = await getToken();
.split(',') if (!token) {
.map((t) => t.trim()) setError('Not authenticated. Please sign in again.');
.filter((t) => t.length > 0); setLoading(false);
return;
// Add source info vào content }
const contentWithSource = `${text}\n\n---\nSource: [${initialTitle}](${initialUrl})`;
await createMemo({
content: contentWithSource,
tags: tagList,
});
// Success // Build content with source info
if (onSave) { let content = text;
onSave(); if (initialUrl && initialUrl !== 'about:blank') {
} else { content += `\n\n---\nSource: [${initialTitle || initialUrl}](${initialUrl})`;
// Default: show success message
alert('✅ Đã lưu vào CuCu Note!');
window.close();
} }
// Call API with fresh token
await createMemoWithToken(token, {
content,
tags,
visibility,
});
// Success! Show message but KEEP popup open
setLoading(false);
setSaved(true);
// Clear form for next note
setText('');
setTags([]);
setError(null);
// Auto-hide success after 3s
setTimeout(() => setSaved(false), 3000);
if (onSave) onSave();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Lỗi khi lưu note'); setError(err instanceof Error ? err.message : 'Failed to save note');
} finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<div className="card"> <div className="note-form">
<h2 style={{ marginTop: 0, marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}> <div className="content">
💾 Save to CuCu Note {/* Success Banner */}
</h2> {saved && (
<div className="badge badge-success" style={{
textAlign: 'center',
padding: '10px',
fontSize: '14px',
marginBottom: '8px',
animation: 'fadeIn 0.3s ease'
}}>
✅ Saved to CuCu Note!
</div>
)}
<div style={{ marginBottom: '20px' }}> {/* Text Area */}
<label className="label">Note:</label>
<textarea <textarea
className="textarea" className="note-textarea"
placeholder="Any thoughts..."
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
placeholder="Nhập note của bạn..." rows={4}
autoFocus
/> />
</div>
<div style={{ marginBottom: '20px' }}> {/* Source Info */}
<label className="label">Tags (phân cách bằng dấu phẩy):</label> {initialUrl && initialUrl !== 'about:blank' && (
<input <div className="source-info" style={{
type="text" fontSize: '12px',
className="input" color: 'var(--text-secondary)',
value={tags} padding: '6px 10px',
onChange={(e) => setTags(e.target.value)} backgroundColor: 'var(--surface)',
placeholder="important, article, todo" borderRadius: '8px',
/> marginTop: '6px',
<div className="label-hint">Ví dụ: important, article, todo</div> overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
🔗 {initialTitle || initialUrl}
</div> </div>
)}
<div className="source-info"> {/* Tag Selector */}
<div style={{ fontWeight: '500', marginBottom: '4px' }}>Source:</div> <div style={{ marginTop: '12px' }}>
<div style={{ wordBreak: 'break-all', marginBottom: '4px' }}> <TagSelector selectedTags={tags} onChange={setTags} />
<a href={initialUrl} target="_blank" rel="noopener noreferrer">
{initialTitle}
</a>
</div>
<div style={{ fontSize: '11px', opacity: 0.7, wordBreak: 'break-all' }}>
{initialUrl}
</div>
</div> </div>
{/* Workspace Selector */}
<WorkspaceSelector value={workspace} onChange={setWorkspace} />
{/* Error */}
{error && <div className="error">{error}</div>} {error && <div className="error">{error}</div>}
</div>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '20px' }}> {/* Action Bar */}
<div className="action-bar">
<div className="action-bar-left">
<button <button
className="btn btn-secondary" className="visibility-selector"
onClick={() =>
setVisibility(visibility === 'PRIVATE' ? 'PUBLIC' : 'PRIVATE')
}
title={`Click to toggle: ${visibility}`}
>
{visibility === 'PRIVATE' ? '🔒' : '🌐'}{' '}
{visibility === 'PRIVATE' ? 'Private' : 'Public'}
</button>
</div>
<div className="action-bar-right">
<button
className="btn btn-ghost btn-sm"
onClick={onCancel || (() => window.close())} onClick={onCancel || (() => window.close())}
disabled={loading} disabled={loading}
> >
Cancel Cancel
</button> </button>
<button <button
className="btn btn-primary" className="btn btn-primary btn-sm"
onClick={handleSave} onClick={handleSave}
disabled={loading} disabled={loading || !text.trim()}
> >
{loading ? 'Đang lưu...' : 'Save'} {saved ? (
'✅ Saved!'
) : loading ? (
<>
<span className="spinner" /> Saving...
</>
) : (
'Save'
)}
</button> </button>
</div> </div>
</div> </div>
</div>
); );
} }
/**
* Settings Component
* Server URL config, auth sync, connection test
*/
import { useState, useEffect } from 'react';
import {
getApiBaseUrl,
setApiBaseUrl,
getAuthStatus,
syncClerkTokenFromPage,
testConnection,
} from '../shared/api-client';
export function Settings() {
const [serverUrl, setServerUrl] = useState('');
const [authStatus, setAuthStatus] = useState<{ isConnected: boolean; tokenLength: number }>({
isConnected: false,
tokenLength: 0,
});
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
const [syncing, setSyncing] = useState(false);
const [testing, setTesting] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
const url = await getApiBaseUrl();
setServerUrl(url);
const status = await getAuthStatus();
setAuthStatus(status);
};
const handleSaveUrl = async () => {
const cleaned = serverUrl.trim().replace(/\/+$/, ''); // remove trailing slashes
await setApiBaseUrl(cleaned);
setServerUrl(cleaned);
setSaved(true);
setTestResult(null);
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 () => {
setTesting(true);
setTestResult(null);
const result = await testConnection();
setTestResult(result);
setTesting(false);
};
return (
<div className="content">
{/* Server URL */}
<div className="settings-section">
<div className="settings-title">🌐 Server</div>
<div className="form-group">
<label className="label">API URL</label>
<div className="settings-input-group">
<input
type="url"
className="input"
value={serverUrl}
onChange={(e) => {
setServerUrl(e.target.value);
setSaved(false);
setTestResult(null);
}}
placeholder="https://your-domain.com"
/>
<button className="btn btn-primary btn-sm" onClick={handleSaveUrl}>
{saved ? '✓' : 'Save'}
</button>
</div>
<div className="label-hint">
Backend server URL (without /api/v1)
</div>
</div>
<div style={{ marginTop: '8px' }}>
<button
className="btn btn-secondary btn-sm btn-full"
onClick={handleTestConnection}
disabled={testing}
>
{testing ? (
<>
<span className="spinner" /> Testing...
</>
) : (
'🔌 Test Connection'
)}
</button>
</div>
{testResult && (
<div
className={testResult.ok ? 'success' : 'error'}
style={{ marginTop: '8px' }}
>
{testResult.message}
</div>
)}
</div>
{/* Auth */}
<div className="settings-section">
<div className="settings-title">🔑 Authentication</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
* Chip-based tag selector with server-fetched tags + inline new tag input
*/
import { useState, useEffect, useRef } from 'react';
import { fetchTags, getRecentTags, saveRecentTags } from '../shared/api-client';
interface TagSelectorProps {
selectedTags: string[];
onChange: (tags: string[]) => void;
}
export function TagSelector({ selectedTags, onChange }: TagSelectorProps) {
const [availableTags, setAvailableTags] = useState<string[]>([]);
const [isAdding, setIsAdding] = useState(false);
const [newTag, setNewTag] = useState('');
const [loading, setLoading] = useState(true);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
loadTags();
}, []);
useEffect(() => {
if (isAdding && inputRef.current) {
inputRef.current.focus();
}
}, [isAdding]);
const loadTags = async () => {
setLoading(true);
try {
const [serverTags, recentTags] = await Promise.all([
fetchTags(),
getRecentTags(),
]);
// Merge: recent first, then server tags
const merged = [...new Set([...recentTags, ...serverTags])];
setAvailableTags(merged);
} catch {
// Fallback to empty
}
setLoading(false);
};
const toggleTag = (tag: string) => {
if (selectedTags.includes(tag)) {
onChange(selectedTags.filter((t) => t !== tag));
} else {
onChange([...selectedTags, tag]);
// Save to recent
saveRecentTags([tag, ...selectedTags]);
}
};
const addNewTag = () => {
const cleaned = newTag.trim().replace(/^#/, '');
if (cleaned && !selectedTags.includes(cleaned)) {
onChange([...selectedTags, cleaned]);
if (!availableTags.includes(cleaned)) {
setAvailableTags([cleaned, ...availableTags]);
}
saveRecentTags([cleaned, ...selectedTags]);
}
setNewTag('');
setIsAdding(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
addNewTag();
} else if (e.key === 'Escape') {
setNewTag('');
setIsAdding(false);
}
};
// Show max 12 tags to keep UI compact
const displayTags = availableTags.slice(0, 12);
return (
<div className="form-group">
<label className="label">Tags</label>
<div className="tag-chips">
{loading ? (
<span style={{ fontSize: '11px', color: 'var(--muted-foreground)' }}>
Loading tags...
</span>
) : (
<>
{displayTags.map((tag) => (
<button
key={tag}
type="button"
className={`tag-chip ${selectedTags.includes(tag) ? 'selected' : ''}`}
onClick={() => toggleTag(tag)}
>
<span className="tag-chip-icon">#</span>
{tag}
</button>
))}
{isAdding ? (
<div className="tag-input-inline">
<span className="tag-chip-icon">#</span>
<input
ref={inputRef}
type="text"
className="tag-input-field"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={addNewTag}
placeholder="new tag"
/>
</div>
) : (
<button
type="button"
className="tag-input-inline"
onClick={() => setIsAdding(true)}
>
+ Add
</button>
)}
</>
)}
</div>
{selectedTags.length > 0 && (
<div className="label-hint">
{selectedTags.length} tag{selectedTags.length > 1 ? 's' : ''} selected
</div>
)}
</div>
);
}
/**
* Workspace Selector Component
* Dropdown to select workspace/shortcut from server
*/
import { useState, useEffect } from 'react';
import { fetchWorkspaces, WorkspaceItem } from '../shared/api-client';
interface WorkspaceSelectorProps {
value: string;
onChange: (workspaceId: string) => void;
}
export function WorkspaceSelector({ value, onChange }: WorkspaceSelectorProps) {
const [workspaces, setWorkspaces] = useState<WorkspaceItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadWorkspaces();
}, []);
const loadWorkspaces = async () => {
setLoading(true);
try {
const items = await fetchWorkspaces();
setWorkspaces(items);
// Restore last used workspace
const stored = await chrome.storage.local.get(['lastWorkspace']);
if (stored.lastWorkspace && !value) {
onChange(stored.lastWorkspace);
}
} catch {
// No workspaces available
}
setLoading(false);
};
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const selected = e.target.value;
onChange(selected);
chrome.storage.local.set({ lastWorkspace: selected });
};
if (loading) {
return (
<div className="form-group">
<label className="label">Workspace</label>
<div style={{ fontSize: '11px', color: 'var(--muted-foreground)', padding: '8px 0' }}>
Loading...
</div>
</div>
);
}
if (workspaces.length === 0) {
return null; // Don't show if no workspaces
}
return (
<div className="form-group">
<label className="label">Workspace</label>
<select className="select" value={value} onChange={handleChange}>
<option value="">Default (no workspace)</option>
{workspaces.map((ws) => (
<option key={ws.id} value={String(ws.id)}>
{ws.title}
</option>
))}
</select>
</div>
);
}
/** /**
* Content Script - Chạy trên mọi web page * Content Script - Chạy trên mọi web page
* Nhiệm vụ: Detect text selection → Space key → Auto save → Toast notification * Nhiệm vụ:
* 1. Auto-sync Clerk auth token khi đang ở trang OpenNotion
* 2. Detect text selection → Space key → Auto save → Toast notification
*/ */
let selectedText = ''; let selectedText = '';
let selectedUrl = ''; let selectedUrl = '';
let selectedTitle = ''; let selectedTitle = '';
// ========== AUTO CLERK TOKEN SYNC ==========
/**
* Detect nếu đang ở trang OpenNotion frontend → auto lấy Clerk token
* Clerk SDK loads async, nên cần poll chờ nó sẵn sàng
*/
async function tryExtractClerkToken(): Promise<string | null> {
const maxRetries = 5;
const retryDelay = 800; // ms
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const clerk = (window as any).Clerk;
// Skip if Clerk loaded but user not signed in
if (clerk && !clerk.user) return null;
if (clerk?.session?.getToken) {
const token = await clerk.session.getToken();
if (token) return token;
}
} catch {
// Clerk chưa ready, thử lại
}
if (attempt < maxRetries - 1) {
await new Promise((resolve) => setTimeout(resolve, retryDelay));
}
}
return null;
}
/**
* Auto-sync: nếu đang ở trang OpenNotion, lấy Clerk token rồi gửi về background
*/
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 =
hostname === '160.191.50.138' ||
hostname === 'localhost' ||
hostname.includes('opennotion') ||
hostname.includes('cucunote') ||
hostname.includes('cucu-note');
if (!isOpenNotionDomain) return;
const token = await tryExtractClerkToken();
if (token) {
// Gửi token về background service worker để lưu vào chrome.storage.local
chrome.runtime.sendMessage({
type: 'SYNC_AUTH',
data: { clerkSessionToken: token },
});
}
}
// Chạy auto-sync khi content script load
autoSyncClerkToken();
// Cũng lắng nghe request từ popup để sync on-demand
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'GET_CLERK_TOKEN') {
tryExtractClerkToken().then((token) => {
sendResponse({ token });
});
return true; // keep channel open for async
}
});
// Listen khi user bôi đen text // Listen khi user bôi đen text
document.addEventListener('mouseup', handleTextSelection); document.addEventListener('mouseup', handleTextSelection);
document.addEventListener('keydown', async (e) => { document.addEventListener('keydown', async (e) => {
...@@ -52,8 +122,8 @@ function handleTextSelection() { ...@@ -52,8 +122,8 @@ function handleTextSelection() {
// Hiện hint nhỏ để user biết có thể nhấn Space/Enter // Hiện hint nhỏ để user biết có thể nhấn Space/Enter
showQuickHint(); showQuickHint();
}, 50); }, 50);
} catch (error) { } catch {
console.error('[CuCu Note] Error in handleTextSelection:', error); // Silently ignore selection errors
} }
} }
...@@ -74,7 +144,7 @@ async function handleAutoSave() { ...@@ -74,7 +144,7 @@ async function handleAutoSave() {
}, },
}, (response) => { }, (response) => {
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
showToast('❌ Lỗi khi lưu note', 'error'); showToast(`❌ Lỗi: ${chrome.runtime.lastError.message || 'Extension error'}`, 'error');
return; return;
} }
...@@ -85,12 +155,12 @@ async function handleAutoSave() { ...@@ -85,12 +155,12 @@ async function handleAutoSave() {
selectedText = ''; selectedText = '';
removeQuickHint(); removeQuickHint();
} else { } else {
showToast('❌ Lỗi khi lưu note', 'error'); const reason = response?.error || 'Không thể lưu note';
showToast(`❌ ${reason}`, 'error');
} }
}); });
} catch (error) { } catch (err: any) {
console.error('[CuCu Note] Error saving:', error); showToast(`❌ ${err?.message || 'Lỗi khi lưu note'}`, 'error');
showToast('❌ Lỗi khi lưu note', 'error');
} }
} }
...@@ -107,7 +177,7 @@ function showQuickHint() { ...@@ -107,7 +177,7 @@ function showQuickHint() {
position: fixed; position: fixed;
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #a0845c 0%, #8b6914 100%);
color: white; color: white;
padding: 12px 20px; padding: 12px 20px;
border-radius: 8px; border-radius: 8px;
...@@ -181,7 +251,7 @@ function showToast(message: string, type: 'success' | 'error' | 'loading' = 'suc ...@@ -181,7 +251,7 @@ function showToast(message: string, type: 'success' | 'error' | 'loading' = 'suc
const colors = { const colors = {
success: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', success: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
error: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)', error: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
loading: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)', loading: 'linear-gradient(135deg, #a0845c 0%, #8b6914 100%)',
}; };
toast.style.cssText = ` toast.style.cssText = `
...@@ -231,7 +301,6 @@ function showToast(message: string, type: 'success' | 'error' | 'loading' = 'suc ...@@ -231,7 +301,6 @@ function showToast(message: string, type: 'success' | 'error' | 'loading' = 'suc
try { try {
document.documentElement.appendChild(toast); document.documentElement.appendChild(toast);
} catch (e2) { } catch (e2) {
console.error('[CuCu Note] Cannot append toast:', e2);
return; return;
} }
} }
......
This diff is collapsed.
...@@ -4,13 +4,18 @@ ...@@ -4,13 +4,18 @@
<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>CuCu 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: 500px; width: 380px;
min-height: 400px; min-height: 420px;
max-height: 560px;
overflow-y: auto;
} }
</style> </style>
</head> </head>
...@@ -19,4 +24,3 @@ ...@@ -19,4 +24,3 @@
<script type="module" src="./popup.tsx"></script> <script type="module" src="./popup.tsx"></script>
</body> </body>
</html> </html>
This diff is collapsed.
This diff is collapsed.
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