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/
frontend/dist/
frontend/.vite/
# Extension specifically
extension/node_modules/
extension/dist/
extension/.vite/
# Preference folder (development/temporary)
preference/
......
"""
Fashion Q&A Agent Package
"""
from .graph import build_graph
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 @@
"tabs"
],
"host_permissions": [
"http://localhost:5000/*",
"http://*/*",
"https://*/*"
],
......@@ -20,8 +19,12 @@
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["src/content/content-script.ts"],
"matches": [
"<all_urls>"
],
"js": [
"src/content/content-script.ts"
],
"run_at": "document_idle",
"all_frames": false
}
......@@ -39,5 +42,4 @@
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
}
\ No newline at end of file
This diff is collapsed.
......@@ -21,5 +21,4 @@
"typescript": "^5.9.3",
"vite": "^7.2.4"
}
}
}
\ No newline at end of file
// 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
* 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';
/**
* 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) => {
// ============ 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') {
// Mở popup với note form
chrome.action.openPopup();
// Lưu data vào storage để popup có thể lấy
chrome.storage.local.set({
pendingNote: message.data,
});
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
const { text, url, title } = message.data;
console.log('[CuCu Note] 📝 Saving note:', { text: text.substring(0, 50) + '...', url, title });
// Parse tags từ URL
const domain = new URL(url).hostname.replace('www.', '');
const tagList = [domain, 'web-highlight'];
// 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)
createMemo({
content: contentWithSource,
......@@ -36,17 +125,14 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
visibility: 'PRIVATE',
})
.then((memo) => {
console.log('[CuCu Note] ✅ Note saved with ID:', memo.id);
sendResponse({ success: true, memo });
})
.catch((error) => {
console.error('[CuCu Note] ❌ Error saving note:', error);
sendResponse({ success: false, error: error.message });
});
return true; // Keep channel open for async
}
return true; // Keep channel open for async response
});
/**
* Note Form Component
* Form để user điền tag và edit note
* NoteForm Component
*
* 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 { createMemo } from '../shared/api-client';
import { createMemoWithToken } from '../shared/api-client';
import { TagSelector } from './TagSelector';
import { WorkspaceSelector } from './WorkspaceSelector';
interface NoteFormProps {
initialText: string;
initialUrl: string;
initialTitle: string;
initialText?: string;
initialUrl?: string;
initialTitle?: string;
getToken: () => Promise<string | null>;
onSave?: () => void;
onCancel?: () => void;
}
export function NoteForm({
initialText,
initialUrl,
initialTitle,
initialText = '',
initialUrl = '',
initialTitle = '',
getToken,
onSave,
onCancel,
}: NoteFormProps) {
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 [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSave = async () => {
if (!text.trim()) {
setError('Note không được để trống');
setError('Note cannot be empty');
return;
}
setLoading(true);
setError(null);
setSaved(false);
try {
// Parse tags (comma-separated)
const tagList = tags
.split(',')
.map((t) => t.trim())
.filter((t) => t.length > 0);
// Add source info vào content
const contentWithSource = `${text}\n\n---\nSource: [${initialTitle}](${initialUrl})`;
await createMemo({
content: contentWithSource,
tags: tagList,
});
// Get FRESH token from Clerk
const token = await getToken();
if (!token) {
setError('Not authenticated. Please sign in again.');
setLoading(false);
return;
}
// Success
if (onSave) {
onSave();
} else {
// Default: show success message
alert('✅ Đã lưu vào CuCu Note!');
window.close();
// Build content with source info
let content = text;
if (initialUrl && initialUrl !== 'about:blank') {
content += `\n\n---\nSource: [${initialTitle || initialUrl}](${initialUrl})`;
}
// 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) {
setError(err instanceof Error ? err.message : 'Lỗi khi lưu note');
} finally {
setError(err instanceof Error ? err.message : 'Failed to save note');
setLoading(false);
}
};
return (
<div className="card">
<h2 style={{ marginTop: 0, marginBottom: '20px', fontSize: '20px', fontWeight: '600' }}>
💾 Save to CuCu Note
</h2>
<div style={{ marginBottom: '20px' }}>
<label className="label">Note:</label>
<div className="note-form">
<div className="content">
{/* Success Banner */}
{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>
)}
{/* Text Area */}
<textarea
className="textarea"
className="note-textarea"
placeholder="Any thoughts..."
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Nhập note của bạn..."
rows={4}
autoFocus
/>
</div>
<div style={{ marginBottom: '20px' }}>
<label className="label">Tags (phân cách bằng dấu phẩy):</label>
<input
type="text"
className="input"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="important, article, todo"
/>
<div className="label-hint">Ví dụ: important, article, todo</div>
{/* Source Info */}
{initialUrl && initialUrl !== 'about:blank' && (
<div className="source-info" style={{
fontSize: '12px',
color: 'var(--text-secondary)',
padding: '6px 10px',
backgroundColor: 'var(--surface)',
borderRadius: '8px',
marginTop: '6px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
🔗 {initialTitle || initialUrl}
</div>
)}
{/* Tag Selector */}
<div style={{ marginTop: '12px' }}>
<TagSelector selectedTags={tags} onChange={setTags} />
</div>
{/* Workspace Selector */}
<WorkspaceSelector value={workspace} onChange={setWorkspace} />
{/* Error */}
{error && <div className="error">{error}</div>}
</div>
<div className="source-info">
<div style={{ fontWeight: '500', marginBottom: '4px' }}>Source:</div>
<div style={{ wordBreak: 'break-all', marginBottom: '4px' }}>
<a href={initialUrl} target="_blank" rel="noopener noreferrer">
{initialTitle}
</a>
{/* Action Bar */}
<div className="action-bar">
<div className="action-bar-left">
<button
className="visibility-selector"
onClick={() =>
setVisibility(visibility === 'PRIVATE' ? 'PUBLIC' : 'PRIVATE')
}
title={`Click to toggle: ${visibility}`}
>
{visibility === 'PRIVATE' ? '🔒' : '🌐'}{' '}
{visibility === 'PRIVATE' ? 'Private' : 'Public'}
</button>
</div>
<div style={{ fontSize: '11px', opacity: 0.7, wordBreak: 'break-all' }}>
{initialUrl}
<div className="action-bar-right">
<button
className="btn btn-ghost btn-sm"
onClick={onCancel || (() => window.close())}
disabled={loading}
>
Cancel
</button>
<button
className="btn btn-primary btn-sm"
onClick={handleSave}
disabled={loading || !text.trim()}
>
{saved ? (
'✅ Saved!'
) : loading ? (
<>
<span className="spinner" /> Saving...
</>
) : (
'Save'
)}
</button>
</div>
</div>
{error && <div className="error">{error}</div>}
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '20px' }}>
<button
className="btn btn-secondary"
onClick={onCancel || (() => window.close())}
disabled={loading}
>
Cancel
</button>
<button
className="btn btn-primary"
onClick={handleSave}
disabled={loading}
>
{loading ? 'Đang lưu...' : 'Save'}
</button>
</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
* 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 selectedUrl = '';
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
document.addEventListener('mouseup', handleTextSelection);
document.addEventListener('keydown', async (e) => {
......@@ -29,7 +99,7 @@ function handleTextSelection() {
// Delay một chút để đảm bảo selection đã hoàn tất
setTimeout(() => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
selectedText = '';
removeQuickHint();
......@@ -37,7 +107,7 @@ function handleTextSelection() {
}
const text = selection.toString().trim();
if (text.length === 0) {
selectedText = '';
removeQuickHint();
......@@ -48,22 +118,22 @@ function handleTextSelection() {
selectedText = text;
selectedUrl = window.location.href;
selectedTitle = document.title;
// Hiện hint nhỏ để user biết có thể nhấn Space/Enter
showQuickHint();
}, 50);
} catch (error) {
console.error('[CuCu Note] Error in handleTextSelection:', error);
} catch {
// Silently ignore selection errors
}
}
async function handleAutoSave() {
if (!selectedText) return;
try {
// Show loading toast
showToast('Đang lưu...', 'loading');
// Gửi message đến background để save
chrome.runtime.sendMessage({
type: 'SAVE_NOTE',
......@@ -74,10 +144,10 @@ async function handleAutoSave() {
},
}, (response) => {
if (chrome.runtime.lastError) {
showToast('❌ Lỗi khi lưu note', 'error');
showToast(`❌ Lỗi: ${chrome.runtime.lastError.message || 'Extension error'}`, 'error');
return;
}
if (response?.success) {
showToast('✅ Đã lưu vào CuCu Note!', 'success');
// Clear selection
......@@ -85,29 +155,29 @@ async function handleAutoSave() {
selectedText = '';
removeQuickHint();
} else {
showToast('❌ Lỗi khi lưu note', 'error');
const reason = response?.error || 'Không thể lưu note';
showToast(`❌ ${reason}`, 'error');
}
});
} catch (error) {
console.error('[CuCu Note] Error saving:', error);
showToast('❌ Lỗi khi lưu note', 'error');
} catch (err: any) {
showToast(`❌ ${err?.message || 'Lỗi khi lưu note'}`, 'error');
}
}
function showQuickHint() {
// Hiện hint nhỏ "Nhấn Space để lưu" với gradient đẹp
removeQuickHint();
const hint = document.createElement('div');
hint.id = 'cucu-quick-hint';
hint.textContent = '💡 Nhấn Space hoặc Enter để lưu nhanh';
// Gradient màu đẹp
hint.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #a0845c 0%, #8b6914 100%);
color: white;
padding: 12px 20px;
border-radius: 8px;
......@@ -119,7 +189,7 @@ function showQuickHint() {
animation: cucuSlideUp 0.3s ease-out;
pointer-events: none;
`;
// Inject animation CSS
if (!document.getElementById('cucu-hint-style')) {
const style = document.createElement('style');
......@@ -138,13 +208,13 @@ function showQuickHint() {
`;
document.head.appendChild(style);
}
try {
document.body.appendChild(hint);
} catch (e) {
document.documentElement.appendChild(hint);
}
// Auto ẩn sau 3 giây
setTimeout(() => {
removeQuickHint();
......@@ -172,18 +242,18 @@ function showToast(message: string, type: 'success' | 'error' | 'loading' = 'suc
// Ignore
}
}
const toast = document.createElement('div');
toast.id = 'cucu-toast';
toast.textContent = message;
// Màu sắc đẹp theo type (gradient)
const colors = {
success: 'linear-gradient(135deg, #10b981 0%, #059669 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 = `
position: fixed;
top: 20px;
......@@ -201,7 +271,7 @@ function showToast(message: string, type: 'success' | 'error' | 'loading' = 'suc
max-width: 350px;
word-wrap: break-word;
`;
// Inject animation CSS
if (!document.getElementById('cucu-toast-style')) {
const style = document.createElement('style');
......@@ -224,18 +294,17 @@ function showToast(message: string, type: 'success' | 'error' | 'loading' = 'suc
// Ignore
}
}
try {
document.body.appendChild(toast);
} catch (e) {
try {
document.documentElement.appendChild(toast);
} catch (e2) {
console.error('[CuCu Note] Cannot append toast:', e2);
return;
}
}
// Auto ẩn sau 3 giây (trừ loading)
if (type !== 'loading') {
setTimeout(() => {
......
This diff is collapsed.
......@@ -4,13 +4,18 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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">
<style>
body {
margin: 0;
padding: 0;
width: 500px;
min-height: 400px;
width: 380px;
min-height: 420px;
max-height: 560px;
overflow-y: auto;
}
</style>
</head>
......@@ -19,4 +24,3 @@
<script type="module" src="./popup.tsx"></script>
</body>
</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