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
}
......@@ -40,4 +43,3 @@
"128": "icons/icon128.png"
}
}
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -22,4 +22,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();
......@@ -16,12 +104,13 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
});
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'];
......@@ -36,11 +125,9 @@ 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 });
});
......@@ -49,4 +136,3 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
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 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>
)}
<div style={{ marginBottom: '20px' }}>
<label className="label">Note:</label>
{/* 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>
)}
<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>
</div>
<div style={{ fontSize: '11px', opacity: 0.7, wordBreak: 'break-all' }}>
{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 style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '20px' }}>
{/* Action Bar */}
<div className="action-bar">
<div className="action-bar-left">
<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())}
disabled={loading}
>
Cancel
</button>
<button
className="btn btn-primary"
className="btn btn-primary btn-sm"
onClick={handleSave}
disabled={loading}
disabled={loading || !text.trim()}
>
{loading ? 'Đang lưu...' : 'Save'}
{saved ? (
'✅ Saved!'
) : loading ? (
<>
<span className="spinner" /> Saving...
</>
) : (
'Save'
)}
</button>
</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
* 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) => {
......@@ -52,8 +122,8 @@ function handleTextSelection() {
// 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
}
}
......@@ -74,7 +144,7 @@ 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;
}
......@@ -85,12 +155,12 @@ 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');
}
}
......@@ -107,7 +177,7 @@ function showQuickHint() {
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;
......@@ -181,7 +251,7 @@ function showToast(message: string, type: 'success' | 'error' | 'loading' = 'suc
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 = `
......@@ -231,7 +301,6 @@ function showToast(message: string, type: 'success' | 'error' | 'loading' = 'suc
try {
document.documentElement.appendChild(toast);
} catch (e2) {
console.error('[CuCu Note] Cannot append toast:', e2);
return;
}
}
......
/* CuCu Note Extension Popup Styles - Reuse từ frontend */
/* ================================================================
CuCu Note Extension — Premium Design System
Modern, rounded, glassmorphic UI with warm brown palette
================================================================ */
/* ---- Light Theme (Paper — warm brown/amber) ---- */
:root {
--background: oklch(0.9818 0.0054 95.0986);
--foreground: oklch(0.2438 0.0269 95.7226);
--primary: oklch(0.45 0.08 250);
--primary-foreground: oklch(0.9818 0.0054 95.0986);
--muted: oklch(0.9341 0.0153 90.239);
--muted-foreground: oklch(0.5559 0.0075 97.4233);
--border: oklch(0.8847 0.0069 97.3627);
--input: oklch(0.7621 0.0156 98.3528);
--ring: oklch(0.45 0.08 250);
--radius: 0.5rem;
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--background: oklch(0.95 0.015 75);
--foreground: oklch(0.25 0.02 65);
--card: oklch(0.98 0.008 80);
--card-foreground: oklch(0.22 0.015 68);
--popover: oklch(0.98 0.008 80);
--popover-foreground: oklch(0.25 0.02 65);
--primary: oklch(0.45 0.08 45);
--primary-foreground: oklch(0.98 0.008 80);
--secondary: oklch(0.92 0.025 70);
--secondary-foreground: oklch(0.35 0.03 60);
--muted: oklch(0.9 0.025 75);
--muted-foreground: oklch(0.5 0.02 68);
--accent: oklch(0.88 0.035 55);
--accent-foreground: oklch(0.25 0.02 65);
--destructive: oklch(0.48 0.15 25);
--border: oklch(0.88 0.018 72);
--input: oklch(0.8 0.03 75);
--ring: oklch(0.45 0.08 45);
--radius: 12px;
--success: oklch(0.6 0.15 145);
--warning: oklch(0.7 0.12 75);
--shadow-sm: 0 1px 2px oklch(0 0 0 / 0.04);
--shadow-md: 0 4px 12px oklch(0 0 0 / 0.06);
--shadow-lg: 0 8px 24px oklch(0 0 0 / 0.08);
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* ---- Dark Theme ---- */
[data-theme="dark"] {
--background: oklch(0.16 0.008 60);
--foreground: oklch(0.9 0.012 75);
--card: oklch(0.20 0.01 62);
--card-foreground: oklch(0.9 0.012 75);
--popover: oklch(0.20 0.01 62);
--popover-foreground: oklch(0.88 0.01 72);
--primary: oklch(0.65 0.1 45);
--primary-foreground: oklch(0.15 0.008 60);
--secondary: oklch(0.26 0.012 65);
--secondary-foreground: oklch(0.85 0.01 72);
--muted: oklch(0.23 0.01 62);
--muted-foreground: oklch(0.6 0.015 70);
--accent: oklch(0.28 0.015 55);
--accent-foreground: oklch(0.82 0.012 68);
--destructive: oklch(0.55 0.1 25);
--border: oklch(0.30 0.012 62);
--input: oklch(0.35 0.015 65);
--ring: oklch(0.65 0.1 45);
--success: oklch(0.65 0.15 145);
--warning: oklch(0.75 0.12 75);
--shadow-sm: 0 1px 2px oklch(0 0 0 / 0.15);
--shadow-md: 0 4px 12px oklch(0 0 0 / 0.2);
--shadow-lg: 0 8px 24px oklch(0 0 0 / 0.25);
}
/* ================================================================
Base Reset
================================================================ */
* {
box-sizing: border-box;
margin: 0;
......@@ -24,149 +72,548 @@ body {
font-family: var(--font-sans);
background: var(--background);
color: var(--foreground);
font-size: 14px;
font-size: 13px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
width: 380px;
min-height: 200px;
overflow-x: hidden;
}
/* Button Styles (từ frontend button.tsx) */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
border-radius: calc(var(--radius) - 2px);
font-size: 14px;
font-weight: 500;
transition: all 150ms;
cursor: pointer;
border: none;
padding: 8px 16px;
/* ================================================================
Animations
================================================================ */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.btn-primary {
background: var(--primary);
color: var(--primary-foreground);
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.btn-primary:hover {
opacity: 0.9;
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.btn-primary:active {
opacity: 0.8;
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.btn-secondary {
background: transparent;
@keyframes glow {
0%,
100% {
box-shadow: 0 0 4px oklch(0.6 0.15 145 / 0.3);
}
50% {
box-shadow: 0 0 10px oklch(0.6 0.15 145 / 0.5);
}
}
@keyframes successPop {
0% {
transform: scale(0.9);
opacity: 0;
}
50% {
transform: scale(1.02);
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* ================================================================
Popup Container
================================================================ */
.popup-container {
display: flex;
flex-direction: column;
min-height: 100%;
animation: fadeIn 0.2s ease-out;
}
/* ================================================================
Header
================================================================ */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background: var(--card);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(10px);
}
.header-brand {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 15px;
color: var(--foreground);
border: 1px solid var(--border);
letter-spacing: -0.01em;
}
.btn-secondary:hover {
background: var(--muted);
.header-brand-icon {
font-size: 20px;
filter: drop-shadow(0 1px 2px oklch(0 0 0 / 0.1));
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
.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
================================================================ */
.content {
padding: 16px 18px;
animation: fadeIn 0.3s ease-out;
}
/* Input Styles */
.input {
/* ================================================================
Note Form
================================================================ */
.note-form {
display: flex;
flex-direction: column;
animation: slideUp 0.25s ease-out;
}
/* ---- Textarea ---- */
.note-textarea {
display: block;
width: 100%;
border-radius: calc(var(--radius) - 2px);
border: 1px solid var(--input);
min-height: 100px;
max-height: 200px;
resize: vertical;
border: 1.5px solid var(--border);
border-radius: var(--radius);
background: var(--background);
padding: 8px 12px;
color: var(--foreground);
padding: 12px 14px;
font-size: 14px;
transition: border-color 150ms;
font-family: inherit;
line-height: 1.6;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.input:focus {
.note-textarea::placeholder {
color: var(--muted-foreground);
font-style: italic;
}
.note-textarea:focus {
outline: none;
border-color: var(--ring);
box-shadow: 0 0 0 2px var(--ring);
box-shadow: 0 0 0 3px oklch(0.45 0.08 45 / 0.1);
}
.input::placeholder {
/* ================================================================
Source Info
================================================================ */
.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);
}
/* Textarea Styles */
.textarea {
/* ================================================================
Tags Section
================================================================ */
.label {
display: block;
font-size: 11px;
font-weight: 600;
margin-bottom: 8px;
color: var(--muted-foreground);
text-transform: uppercase;
letter-spacing: 0.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 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border: 1.5px solid var(--border);
background: var(--card);
color: var(--muted-foreground);
font-family: inherit;
user-select: none;
}
.tag-chip:hover {
border-color: var(--primary);
color: var(--primary);
background: oklch(0.45 0.08 45 / 0.06);
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 oklch(0.45 0.08 45 / 0.2);
}
.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 0.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;
}
/* ================================================================
Workspace / Select
================================================================ */
.form-group {
margin-bottom: 14px;
}
.select {
display: block;
width: 100%;
min-height: 100px;
border-radius: calc(var(--radius) - 2px);
border: 1px solid var(--input);
border: 1.5px solid var(--border);
background: var(--background);
padding: 8px 12px;
font-size: 14px;
transition: border-color 150ms;
color: var(--foreground);
padding: 10px 36px 10px 14px;
font-size: 13px;
font-family: inherit;
resize: vertical;
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 0.2s ease, box-shadow 0.2s ease;
}
.textarea:focus {
.select:focus {
outline: none;
border-color: var(--ring);
box-shadow: 0 0 0 2px var(--ring);
box-shadow: 0 0 0 3px oklch(0.45 0.08 45 / 0.1);
}
.textarea::placeholder {
/* ================================================================
Visibility Selector
================================================================ */
.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 0.2s ease;
font-family: inherit;
}
/* Label Styles */
.label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
.visibility-selector:hover {
border-color: var(--primary);
color: var(--primary);
}
/* ================================================================
Buttons
================================================================ */
.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 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.btn:active {
transform: scale(0.96);
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), oklch(0.5 0.1 50));
color: var(--primary-foreground);
box-shadow: 0 2px 8px oklch(0.45 0.08 45 / 0.25);
}
.btn-primary:hover {
filter: brightness(1.08);
box-shadow: 0 4px 14px oklch(0.45 0.08 45 / 0.35);
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);
}
.label-hint {
.btn-sm {
padding: 6px 14px;
font-size: 12px;
color: var(--muted-foreground);
margin-top: 4px;
border-radius: calc(var(--radius) - 4px);
}
/* Card/Container Styles */
.card {
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
filter: none !important;
}
/* ================================================================
Action Bar (bottom)
================================================================ */
.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;
}
/* ================================================================
Badges & Messages
================================================================ */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 100px;
font-size: 12px;
font-weight: 600;
}
.badge-success {
background: oklch(0.6 0.15 145 / 0.12);
color: var(--success);
border: 1px solid oklch(0.6 0.15 145 / 0.2);
animation: successPop 0.3s ease-out;
}
/* Error Message */
.error {
padding: 12px;
margin-bottom: 16px;
background: oklch(0.35 0.02 250 / 0.1);
color: oklch(0.35 0.02 250);
padding: 10px 14px;
margin-top: 10px;
background: oklch(0.55 0.15 25 / 0.08);
color: var(--destructive);
border-radius: calc(var(--radius) - 2px);
font-size: 14px;
font-size: 12px;
border: 1px solid oklch(0.55 0.15 25 / 0.15);
animation: fadeIn 0.2s ease-out;
}
/* Source Info */
.source-info {
.success {
padding: 10px 14px;
margin-bottom: 12px;
background: oklch(0.6 0.15 145 / 0.08);
color: var(--success);
border-radius: calc(var(--radius) - 2px);
font-size: 12px;
color: var(--muted-foreground);
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
border: 1px solid oklch(0.6 0.15 145 / 0.15);
}
.source-info a {
color: var(--primary);
text-decoration: none;
/* ================================================================
Loading Spinner
================================================================ */
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
vertical-align: middle;
}
/* ================================================================
Scrollbar
================================================================ */
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 100px;
}
.source-info a:hover {
text-decoration: underline;
::-webkit-scrollbar-thumb:hover {
background: var(--muted-foreground);
}
/* ================================================================
Smooth transitions on all themed elements
================================================================ */
*,
*::before,
*::after {
transition-property: background-color, color, border-color, box-shadow, opacity;
transition-duration: 0.2s;
transition-timing-function: ease;
}
/* Opt-out for animation elements */
.spinner,
.spinner *,
[class*="animate-"] {
transition: none !important;
}
\ No newline at end of file
......@@ -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>
/**
* Popup Component - Hiện khi click icon extension
* CuCu Note Extension Popup
*
* Simple, reliable approach:
* 1. On open → grab fresh Clerk token from any OpenNotion tab
* 2. If token found → show NoteForm
* 3. If no token → show "Sign in" button
* 4. Popup stays OPEN after save
*/
import { useEffect, useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { createRoot } from 'react-dom/client';
import { NoteForm } from '../components/NoteForm';
import './popup.css';
interface PendingNote {
text: string;
url: string;
title: string;
}
const WEB_APP_URL = 'http://160.191.50.138:3001';
// ========== Theme Hook ==========
function Popup() {
const [pendingNote, setPendingNote] = useState<PendingNote | null>(null);
const [loading, setLoading] = useState(true);
function useTheme() {
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('dark');
useEffect(() => {
// Lấy pending note từ storage (nếu có)
chrome.storage.local.get(['pendingNote'], async (result) => {
if (result.pendingNote) {
const note = result.pendingNote;
setPendingNote(note);
const saved = localStorage.getItem('cucu-theme') as 'light' | 'dark' | 'system' | null;
if (saved) setTheme(saved);
}, []);
// Auto save ngay khi popup mở (nếu là từ Space key)
try {
const { createMemo } = await import('../shared/api-client');
useEffect(() => {
localStorage.setItem('cucu-theme', theme);
const root = document.documentElement;
if (theme === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
root.setAttribute('data-theme', isDark ? 'dark' : 'light');
} else {
root.setAttribute('data-theme', theme);
}
}, [theme]);
// Parse tags từ URL hoặc domain
const domain = new URL(note.url).hostname.replace('www.', '');
const tagList = [domain, 'web-highlight'];
const cycleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : prev === 'dark' ? 'system' : 'light'));
};
// Add source info vào content
const contentWithSource = `${note.text}\n\n---\nSource: [${note.title}](${note.url})`;
const icon = theme === 'light' ? '☀️' : theme === 'dark' ? '🌙' : '🌓';
return { theme, cycleTheme, icon };
}
// ========== Token Grabber ==========
await createMemo({
content: contentWithSource,
tags: tagList,
async function grabTokenFromTabs(): 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')
);
});
// Success - close popup
chrome.storage.local.remove(['pendingNote']);
window.close();
} catch (error) {
console.error('Error auto-saving:', error);
// Nếu lỗi, hiện form để user save thủ công
setLoading(false);
}
} else {
setLoading(false);
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 && typeof token === 'string' && token.length > 50) {
// Save to storage for other parts of extension
await chrome.storage.local.set({
clerkSessionToken: token,
clerkTokenSyncedAt: Date.now(),
});
return token;
}
} catch {
// This tab didn't work, try next
}
}
} catch { /* Tab query failed */ }
// Fallback: check storage
try {
const stored = await chrome.storage.local.get(['clerkSessionToken', 'clerkTokenSyncedAt']);
if (stored.clerkSessionToken) {
const age = Date.now() - (stored.clerkTokenSyncedAt || 0);
if (age < 5 * 60 * 1000) { // less than 5 minutes old
return stored.clerkSessionToken;
}
}
} catch { /* storage failed */ }
return null;
}
// ========== Main App ==========
function App() {
const { cycleTheme, icon: themeIcon } = useTheme();
const [status, setStatus] = useState<'loading' | 'connected' | 'disconnected'>('loading');
const [currentUrl, setCurrentUrl] = useState('');
const [currentTitle, setCurrentTitle] = useState('');
const [pendingText, setPendingText] = useState('');
// Initialize: grab token + tab info
useEffect(() => {
(async () => {
// Get current tab info
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (tab) {
setCurrentUrl(tab.url || '');
setCurrentTitle(tab.title || '');
}
} catch { /* ignore */ }
// Get pending note from context menu / content script
try {
const result = await chrome.storage.local.get(['pendingNote']);
if (result.pendingNote) {
setPendingText(result.pendingNote.text || '');
chrome.storage.local.remove(['pendingNote']);
}
} catch { /* ignore */ }
// Grab token
const t = await grabTokenFromTabs();
setStatus(t ? 'connected' : 'disconnected');
})();
}, []);
// Fresh token getter for NoteForm — re-grabs from tab each time
const getFreshToken = useCallback(async (): Promise<string | null> => {
const fresh = await grabTokenFromTabs();
if (fresh) setStatus('connected');
return fresh;
}, []);
if (loading) {
// Retry connection
const handleRetry = async () => {
setStatus('loading');
const t = await grabTokenFromTabs();
setStatus(t ? 'connected' : 'disconnected');
};
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<div>Loading...</div>
<div className="popup-container">
{/* Header */}
<header className="header">
<div className="header-brand">
<span className="header-brand-icon">🐣</span>
<span>CuCu Note</span>
<button className="btn btn-ghost btn-sm" onClick={cycleTheme} title="Toggle theme">
{themeIcon}
</button>
</div>
);
}
<div className="header-status">
{status === 'loading' && (
<>
<span className="spinner" style={{ width: 10, height: 10 }} />
<span className="status-text">Connecting...</span>
</>
)}
{status === 'connected' && (
<>
<span className="status-dot status-connected" />
<span className="status-text">Connected</span>
</>
)}
{status === 'disconnected' && (
<>
<span className="status-dot status-offline" />
<span className="status-text">Offline</span>
</>
)}
</div>
</header>
if (pendingNote) {
// Hiện form với data từ content script
return (
{/* Loading */}
{status === 'loading' && (
<div className="content" style={{ textAlign: 'center', padding: '40px' }}>
<span className="spinner" /> Connecting...
</div>
)}
{/* Connected → NoteForm */}
{status === 'connected' && (
<NoteForm
initialText={pendingNote.text}
initialUrl={pendingNote.url}
initialTitle={pendingNote.title}
onSave={() => {
// Success - có thể show message hoặc close
window.close();
}}
onCancel={() => {
window.close();
}}
initialText={pendingText}
initialUrl={currentUrl}
initialTitle={currentTitle}
getToken={getFreshToken}
/>
);
}
)}
// Default: Quick note form (không có text từ selection)
return (
<div className="card">
<h2 style={{ marginTop: 0, marginBottom: '12px', fontSize: '20px', fontWeight: '600' }}>
📝 CuCu Note
</h2>
<p style={{ color: 'var(--muted-foreground)', fontSize: '14px', marginBottom: '20px' }}>
Bôi đen text trên web → nhấn Space → tự động lưu note!
{/* Disconnected → Sign in prompt */}
{status === 'disconnected' && (
<div className="content" style={{ padding: '24px', textAlign: 'center' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🔐</div>
<h3 style={{ margin: '0 0 8px', fontSize: '16px', color: 'var(--text-primary)' }}>
Sign in to CuCu Note
</h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', marginBottom: '20px', lineHeight: 1.5 }}>
Open the web app ad sign in, then come back here
</p>
<NoteForm
initialText=""
initialUrl={typeof window !== 'undefined' ? window.location.href : ''}
initialTitle={typeof document !== 'undefined' ? document.title : 'CuCu Note'}
onSave={() => window.close()}
onCancel={() => window.close()}
/>
<button
className="btn btn-primary"
onClick={() => chrome.tabs.create({ url: `${WEB_APP_URL}/auth` })}
style={{ width: '100%', padding: '10px', fontSize: '14px', marginBottom: '10px' }}
>
🚀 Open CuCu Note to Sign In
</button>
<button
className="btn btn-ghost"
onClick={handleRetry}
style={{ width: '100%', padding: '8px', fontSize: '13px' }}
>
🔄 Retry Connection
</button>
</div>
)}
</div>
);
}
// Render
const container = document.getElementById('popup-root');
if (container) {
const root = createRoot(container);
root.render(<Popup />);
// Mount
const root = document.getElementById('popup-root');
if (root) {
createRoot(root).render(<App />);
}
/**
* API Client - Gọi backend CuCu Note
* Reuse logic từ frontend nếu có thể
* Configurable server URL + tag/workspace fetching
*/
// Default API base URL - can be overridden via chrome.storage
// In production, this should be set via extension settings or build-time config
const DEFAULT_API_BASE_URL = 'http://localhost:5000';
// Default API base URL — empty means user must configure in Settings
const DEFAULT_API_BASE_URL = 'http://160.191.50.138:3001';
// ========== Config ==========
// Lấy API base từ storage hoặc dùng default
async function getApiBase(): Promise<string> {
const result = await chrome.storage.local.get(['apiBaseUrl']);
const baseUrl = result.apiBaseUrl || DEFAULT_API_BASE_URL;
return `${baseUrl}/api/v1`;
}
// Export để có thể set từ popup/settings
export async function getApiBaseUrl(): Promise<string> {
const result = await chrome.storage.local.get(['apiBaseUrl']);
return result.apiBaseUrl || DEFAULT_API_BASE_URL;
}
export async function setApiBaseUrl(url: string): Promise<void> {
await chrome.storage.local.set({ apiBaseUrl: url });
// Basic URL validation
const cleaned = url.trim().replace(/\/+$/, '');
if (cleaned && !cleaned.startsWith('http://') && !cleaned.startsWith('https://')) {
throw new Error('URL must start with http:// or https://');
}
await chrome.storage.local.set({ apiBaseUrl: cleaned });
}
// ========== Auth ==========
async function getAuthToken(): Promise<string | null> {
const result = await chrome.storage.local.get(['clerkSessionToken', 'authToken']);
if (result.clerkSessionToken) return result.clerkSessionToken;
return result.authToken || null;
}
export async function setAuthToken(token: string): Promise<void> {
await chrome.storage.local.set({ authToken: token });
}
export async function getAuthStatus(): Promise<{ isConnected: boolean; tokenLength: number }> {
const token = await getAuthToken();
return {
isConnected: !!token && token.length > 0,
tokenLength: token?.length || 0,
};
}
/**
* 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 ==========
// 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>> {
// Try to refresh token if stale
await tryRefreshToken();
const token = await getAuthToken();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
// ========== Memo CRUD ==========
export interface CreateMemoRequest {
content: string;
tags?: string[];
......@@ -33,24 +145,8 @@ export interface MemoResponse {
}
export async function createMemo(data: CreateMemoRequest): Promise<MemoResponse> {
const token = await getAuthToken();
const apiBase = await getApiBase();
// Debug: Log token status (không log full token vì security)
if (token) {
console.log('[CuCu Note] ✅ Auth token found, length:', token.length);
} else {
console.warn('[CuCu Note] ⚠️ No auth token found! Request will be unauthenticated.');
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Gửi Clerk token trong Authorization header (format: Bearer <token>)
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const headers = await apiHeaders();
const response = await fetch(`${apiBase}/memos`, {
method: 'POST',
......@@ -63,69 +159,134 @@ export async function createMemo(data: CreateMemoRequest): Promise<MemoResponse>
});
if (!response.ok) {
const error = await response.text();
console.error('[CuCu Note] ❌ API Error:', response.status, error);
throw new Error(`Failed to create memo: ${error}`);
await response.text(); // consume body
throw new Error(`Failed to create memo (${response.status})`);
}
const result = await response.json();
console.log('[CuCu Note] ✅ Memo saved successfully:', result.id);
return result;
}
async function getAuthToken(): Promise<string | null> {
// Lấy Clerk session token từ storage
// Frontend có thể lưu token vào chrome.storage.local khi user login
const result = await chrome.storage.local.get(['clerkSessionToken', 'authToken']);
/**
* Create a memo using a direct token (from Clerk getToken())
* Bypasses storage-based token reading — always uses fresh JWT
*/
export async function createMemoWithToken(token: string, data: CreateMemoRequest): Promise<MemoResponse> {
const apiBase = await getApiBase();
// Ưu tiên Clerk token
if (result.clerkSessionToken) {
return result.clerkSessionToken;
const response = await fetch(`${apiBase}/memos`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
content: data.content,
tags: data.tags || [],
visibility: data.visibility || 'PRIVATE',
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to save (${response.status}): ${errorText.substring(0, 100)}`);
}
// Fallback về authToken cũ
return result.authToken || null;
return await response.json();
}
/**
* Lấy Clerk token từ frontend page (nếu đang ở trang frontend)
* Inject script vào page để access window.Clerk
*/
export async function syncClerkTokenFromPage(): Promise<string | null> {
try {
// Inject script vào page để lấy Clerk token
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab.id) return null;
// ========== Tags ==========
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: async () => {
// Access window.Clerk từ page context
if (typeof window !== 'undefined' && (window as any).Clerk?.session?.getToken) {
export async function fetchTags(): Promise<string[]> {
try {
const token = await (window as any).Clerk.session.getToken();
return token || null;
const apiBase = await getApiBase();
const headers = await apiHeaders();
const response = await fetch(`${apiBase}/memos`, { headers });
if (!response.ok) return [];
const memos: any[] = await response.json();
// Extract unique tags from all memos
const tagSet = new Set<string>();
for (const memo of memos) {
if (Array.isArray(memo.tags)) {
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);
if (hashTags) {
hashTags.forEach((t: string) => tagSet.add(t.slice(1))); // remove #
}
}
return Array.from(tagSet).sort();
} catch {
return null;
return [];
}
}
// ========== Workspaces (Shortcuts) ==========
export interface WorkspaceItem {
id: number;
title: string;
filter: string;
}
export async function fetchWorkspaces(): Promise<WorkspaceItem[]> {
try {
const apiBase = await getApiBase();
const headers = await apiHeaders();
const response = await fetch(`${apiBase}/shortcuts`, { headers });
if (!response.ok) return [];
const data = await response.json();
// API may return array directly or { shortcuts: [...] }
const shortcuts = Array.isArray(data) ? data : data.shortcuts || [];
return shortcuts.map((s: any) => ({
id: s.id,
title: s.title || s.name || `Workspace ${s.id}`,
filter: s.filter || '',
}));
} catch {
return [];
}
return null;
},
}
// ========== Connection Test ==========
export async function testConnection(): Promise<{ ok: boolean; message: string }> {
try {
const apiBase = await getApiBase();
const headers = await apiHeaders();
const response = await fetch(`${apiBase}/memos`, {
method: 'GET',
headers,
signal: AbortSignal.timeout(5000),
});
const token = results[0]?.result || null;
if (token) {
// Lưu token vào storage
await chrome.storage.local.set({ clerkSessionToken: token });
if (response.ok) {
return { ok: true, message: 'Connected successfully!' };
} else {
return { ok: false, message: `Server error: ${response.status}` };
}
return token;
} catch (error) {
console.error('[CuCu Note] Error syncing Clerk token:', error);
return null;
return { ok: false, message: `Cannot reach server: ${(error as Error).message}` };
}
}
export async function setAuthToken(token: string): Promise<void> {
await chrome.storage.local.set({ authToken: token });
// ========== Recently Used Tags (local) ==========
export async function getRecentTags(): Promise<string[]> {
const result = await chrome.storage.local.get(['recentTags']);
return result.recentTags || [];
}
export async function saveRecentTags(tags: string[]): Promise<void> {
// Keep only last 20 unique tags
const unique = [...new Set(tags)].slice(0, 20);
await chrome.storage.local.set({ recentTags: unique });
}
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CuCu Note — Bộ Nhớ Thứ Hai Của Bạn</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@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Progress Bar -->
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<!-- Slide Counter -->
<div class="slide-counter" id="slideCounter">1 / 18</div>
<!-- Navigation -->
<button class="nav-btn nav-prev" id="prevBtn" onclick="prevSlide()"></button>
<button class="nav-btn nav-next" id="nextBtn" onclick="nextSlide()"></button>
<!-- ===== SLIDE 1: Opening Hook ===== -->
<section class="slide active" id="slide-1" data-bg="dark">
<div class="slide-content center-content">
<div class="floating-emoji">💡</div>
<h1 class="mega-title fade-in">
Bạn đã bao giờ <span class="text-gradient">quên</span>...
<br>chính thứ bạn <span class="text-gradient">vừa nghĩ ra</span> chưa?
</h1>
<div class="stat-highlight fade-in delay-1">
<span class="stat-number counter" data-target="6200">0</span>
<span class="stat-label">suy nghĩ mỗi ngày</span>
<span class="stat-source">— Nghiên cứu Queen's University, 2020</span>
</div>
<p class="subtitle fade-in delay-2">Bao nhiêu trong số đó bạn còn nhớ vào cuối ngày?</p>
</div>
<div class="particle-bg"></div>
</section>
<!-- ===== SLIDE 2: Travel Dreams ===== -->
<section class="slide" id="slide-2" data-bg="travel">
<div class="slide-content">
<p class="section-label fade-in">CHƯƠNG 1</p>
<h1 class="slide-title fade-in">Ai cũng mơ về <span class="text-gradient-warm">những chuyến đi</span>...</h1>
<div class="cards-grid fade-in delay-1">
<div class="dream-card">
<div class="dream-emoji">🏖️</div>
<p>Nằm trên bãi biển Bali<br>nhâm nhi cocktail</p>
</div>
<div class="dream-card">
<div class="dream-emoji">⛰️</div>
<p>Chinh phục đỉnh Fansipan<br>vào sáng sớm</p>
</div>
<div class="dream-card">
<div class="dream-emoji">🗼</div>
<p>Selfie dưới<br>tháp Eiffel</p>
</div>
<div class="dream-card">
<div class="dream-emoji">✈️</div>
<p>Work from anywhere<br>Digital Nomad life</p>
</div>
</div>
<p class="bottom-hook fade-in delay-2">Nhưng... để đi được thì cần gì? 🤔</p>
</div>
</section>
<!-- ===== SLIDE 3: Money Reality ===== -->
<section class="slide" id="slide-3" data-bg="dark">
<div class="slide-content">
<p class="section-label fade-in">CHƯƠNG 1</p>
<h1 class="slide-title fade-in">Reality check: Muốn đi thì phải có... <span class="text-gradient-gold">💸</span></h1>
<div class="money-grid fade-in delay-1">
<div class="money-card">
<div class="money-dest">🏝️ Maldives</div>
<div class="money-amount danger">50–100 triệu VNĐ</div>
</div>
<div class="money-card">
<div class="money-dest">🇪🇺 Châu Âu 10 ngày</div>
<div class="money-amount danger">80–150 triệu VNĐ</div>
</div>
<div class="money-card">
<div class="money-dest">🌸 Đà Lạt</div>
<div class="money-amount warning">5–10 triệu VNĐ</div>
</div>
<div class="money-card">
<div class="money-dest">🇯🇵 Nhật Bản</div>
<div class="money-amount danger">30–60 triệu VNĐ</div>
</div>
</div>
<div class="callout fade-in delay-2">
<span class="callout-icon">💼</span>
<span>Tiền không tự nhiên mà có — nó đến từ <strong>CÔNG VIỆC</strong></span>
</div>
</div>
</section>
<!-- ===== SLIDE 4: Work Efficiency ===== -->
<section class="slide" id="slide-4" data-bg="dark">
<div class="slide-content">
<p class="section-label fade-in">CHƯƠNG 2</p>
<h1 class="slide-title fade-in">Làm nhiều <span class="text-red"></span> Làm <span class="text-gradient">hiệu quả</span></h1>
<div class="comparison-grid fade-in delay-1">
<div class="compare-card bad">
<div class="compare-header">😩 Người A</div>
<ul>
<li>Làm <strong>12h/ngày</strong></li>
<li>Stress, hết năng lượng</li>
<li>Quên deadline liên tục</li>
<li>Lương: <strong class="text-red">10tr/tháng</strong></li>
</ul>
</div>
<div class="compare-vs">VS</div>
<div class="compare-card good">
<div class="compare-header">😎 Người B</div>
<ul>
<li>Làm <strong>6h/ngày</strong></li>
<li>Thoải mái, có thời gian riêng</li>
<li>Ghi chú rõ ràng, hệ thống</li>
<li>Lương: <strong class="text-green">30tr/tháng</strong></li>
</ul>
</div>
</div>
<p class="insight fade-in delay-2">
Khác biệt không phải <em>thời gian</em> — mà là <strong>cách quản lý kiến thức</strong>
</p>
</div>
</section>
<!-- ===== SLIDE 5: The Silent Killer ===== -->
<section class="slide" id="slide-5" data-bg="danger">
<div class="slide-content">
<p class="section-label fade-in">CHƯƠNG 3 — VẤN ĐỀ</p>
<h1 class="slide-title fade-in">Kẻ Thù Số 1 của Hiệu Quả: <span class="text-red">QUÊN</span></h1>
<div class="ebbinghaus fade-in delay-1">
<h3>📉 Đường Cong Quên Lãng Ebbinghaus</h3>
<div class="curve-container">
<div class="curve-bar" style="--height: 58%; --delay: 0.1s">
<span class="curve-value">58%</span>
<span class="curve-label">20 phút</span>
</div>
<div class="curve-bar" style="--height: 44%; --delay: 0.3s">
<span class="curve-value">44%</span>
<span class="curve-label">1 giờ</span>
</div>
<div class="curve-bar" style="--height: 33%; --delay: 0.5s">
<span class="curve-value">33%</span>
<span class="curve-label">1 ngày</span>
</div>
<div class="curve-bar" style="--height: 25%; --delay: 0.7s">
<span class="curve-value">25%</span>
<span class="curve-label">1 tuần</span>
</div>
<div class="curve-bar" style="--height: 21%; --delay: 0.9s">
<span class="curve-value">21%</span>
<span class="curve-label">1 tháng</span>
</div>
</div>
<p class="curve-note">% thông tin còn nhớ sau thời gian</p>
</div>
</div>
</section>
<!-- ===== SLIDE 6: Real Impact ===== -->
<section class="slide" id="slide-6" data-bg="dark">
<div class="slide-content">
<p class="section-label fade-in">CHƯƠNG 3 — VẤN ĐỀ</p>
<h1 class="slide-title fade-in">Quên = <span class="text-red">Mất tiền</span>, mất thời gian, mất cơ hội</h1>
<div class="impact-grid fade-in delay-1">
<div class="impact-card">
<div class="impact-icon">🕐</div>
<div class="impact-stat">2.5 giờ/ngày</div>
<div class="impact-desc">Thời gian dân văn phòng tốn để <strong>tìm lại</strong> thông tin</div>
<div class="impact-source">McKinsey Research</div>
</div>
<div class="impact-card">
<div class="impact-icon">💰</div>
<div class="impact-stat">$47M/năm</div>
<div class="impact-desc">Thiệt hại doanh nghiệp do <strong>kiến thức bị mất</strong></div>
<div class="impact-source">Panopto Study</div>
</div>
<div class="impact-card">
<div class="impact-icon">🤯</div>
<div class="impact-stat">83%</div>
<div class="impact-desc">Nhân viên cảm thấy <strong>quá tải</strong> thông tin</div>
<div class="impact-source">Workplace Survey</div>
</div>
</div>
<div class="callout callout-warning fade-in delay-2">
<span class="callout-icon">💡</span>
<span>2.5 giờ/ngày × 365 = <strong>30 ngày mất trắng mỗi năm</strong>. Đủ đi 3 chuyến du lịch!</span>
</div>
</div>
</section>
<!-- ===== SLIDE 7: The Solution ===== -->
<section class="slide" id="slide-7" data-bg="hero">
<div class="slide-content center-content">
<div class="logo-reveal">
<div class="logo-icon">🐦</div>
<h1 class="brand-title">CuCu Note</h1>
</div>
<p class="tagline fade-in delay-1">Bộ Nhớ Thứ Hai Của Bạn 🧠</p>
<p class="tagline-sub fade-in delay-1">Capture · Organize · Never Forget</p>
<div class="three-pillars fade-in delay-2">
<div class="pillar">
<span class="pillar-icon">📝</span>
<span class="pillar-text">Ghi nhanh</span>
<span class="pillar-desc">Mọi lúc, mọi nơi</span>
</div>
<div class="pillar">
<span class="pillar-icon">🤖</span>
<span class="pillar-text">AI hỗ trợ</span>
<span class="pillar-desc">Tìm kiếm thông minh</span>
</div>
<div class="pillar">
<span class="pillar-icon">🔒</span>
<span class="pillar-text">Bảo mật</span>
<span class="pillar-desc">Dữ liệu của bạn</span>
</div>
</div>
</div>
<div class="hero-glow"></div>
</section>
<!-- ===== SLIDE 8: Feature 1 - Memo System ===== -->
<section class="slide" id="slide-8" data-bg="feature">
<div class="slide-content">
<div class="feature-badge fade-in">FEATURE 01</div>
<h1 class="slide-title fade-in">Viết. Ngay. Lập. Tức. <span class="text-gradient">✍️</span></h1>
<div class="feature-showcase fade-in delay-1">
<div class="feature-list">
<div class="feature-item">
<span class="fi-icon">✍️</span>
<div>
<strong>Rich Markdown Editor</strong>
<p>Viết nhanh với format đẹp, code blocks, lists, tables</p>
</div>
</div>
<div class="feature-item">
<span class="fi-icon">📌</span>
<div>
<strong>Pin & Archive</strong>
<p>Ghim quan trọng, lưu trữ gọn gàng</p>
</div>
</div>
<div class="feature-item">
<span class="fi-icon">🏷️</span>
<div>
<strong>#Tags System</strong>
<p>Phân loại tự động, tìm lại trong 1 giây</p>
</div>
</div>
<div class="feature-item">
<span class="fi-icon">📎</span>
<div>
<strong>Đính kèm file</strong>
<p>Ảnh, PDF, tài liệu đều gắn được</p>
</div>
</div>
<div class="feature-item">
<span class="fi-icon">🎨</span>
<div>
<strong>Masonry Layout</strong>
<p>Giao diện dạng lưới như Pinterest, đẹp mắt</p>
</div>
</div>
</div>
<div class="feature-mockup memo-mockup">
<div class="mockup-bar">
<span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span>
<span class="mockup-title">CuCu Note</span>
</div>
<div class="mockup-body">
<div class="mock-memo">
<div class="mock-tag">#project</div>
<div class="mock-text">Ý tưởng cho dự án Q2...</div>
</div>
<div class="mock-memo pinned">
<div class="mock-pin">📌</div>
<div class="mock-tag">#meeting</div>
<div class="mock-text">Ghi chú cuộc họp team...</div>
</div>
<div class="mock-memo">
<div class="mock-tag">#idea</div>
<div class="mock-text">Feature mới: AI search...</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ===== SLIDE 9: Feature 2 - AI Assistant ===== -->
<section class="slide" id="slide-9" data-bg="feature-ai">
<div class="slide-content">
<div class="feature-badge fade-in">FEATURE 02</div>
<h1 class="slide-title fade-in">Your Personal <span class="text-gradient">AI Brain</span> 🤖</h1>
<div class="feature-showcase fade-in delay-1">
<div class="feature-list">
<div class="feature-item">
<span class="fi-icon">🧠</span>
<div>
<strong>LangGraph Agent</strong>
<p>AI thông minh điều phối qua graph workflow</p>
</div>
</div>
<div class="feature-item">
<span class="fi-icon">🔍</span>
<div>
<strong>Semantic Search</strong>
<p>Tìm kiếm bằng "ý nghĩa", không phải keyword</p>
</div>
</div>
<div class="feature-item">
<span class="fi-icon">💬</span>
<div>
<strong>Chat tự nhiên</strong>
<p>Hỏi AI về nội dung notes của bạn</p>
</div>
</div>
<div class="feature-item">
<span class="fi-icon">🔑</span>
<div>
<strong>BYOK</strong>
<p>Bring Your Own Key — kiểm soát chi phí</p>
</div>
</div>
</div>
<div class="feature-mockup chat-mockup">
<div class="mockup-bar">
<span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span>
<span class="mockup-title">AI Assistant</span>
</div>
<div class="mockup-body chat-body">
<div class="chat-msg user">Tìm giúp tôi note về cuộc họp dự án ABC tuần trước</div>
<div class="chat-msg ai">
<div class="ai-thinking">🔍 Đang tìm trong notes...</div>
Tôi tìm thấy 2 notes liên quan:
<br><br>
📝 <strong>"Meeting Q2 Planning"</strong> — ngày 17/02
<br>
📝 <strong>"ABC Project Update"</strong> — ngày 18/02
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ===== SLIDE 10: Feature 3 - Memo Relations ===== -->
<section class="slide" id="slide-10" data-bg="feature">
<div class="slide-content">
<div class="feature-badge fade-in">FEATURE 03</div>
<h1 class="slide-title fade-in">Notes không đứng một mình — chúng <span class="text-gradient">KẾT NỐI</span></h1>
<div class="feature-showcase fade-in delay-1">
<div class="feature-list">
<div class="feature-item">
<span class="fi-icon">🔗</span>
<div>
<strong>Link Notes</strong>
<p>Tạo mạng lưới kiến thức giữa các ghi chú</p>
</div>
</div>
<div class="feature-item">
<span class="fi-icon">🌐</span>
<div>
<strong>Force Graph</strong>
<p>Visualize mối quan hệ bằng đồ thị tương tác</p>
</div>
</div>
<div class="feature-item">
<span class="fi-icon">🧩</span>
<div>
<strong>Zettelkasten</strong>
<p>Phương pháp ghi chú của các thiên tài</p>
</div>
</div>
<div class="feature-item">
<span class="fi-icon">🤖</span>
<div>
<strong>AI gợi ý liên kết</strong>
<p>Tự động phát hiện notes liên quan</p>
</div>
</div>
</div>
<div class="feature-mockup graph-mockup">
<div class="mockup-bar">
<span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span>
<span class="mockup-title">Knowledge Graph</span>
</div>
<div class="mockup-body graph-body">
<svg viewBox="0 0 300 200" class="graph-svg">
<line x1="150" y1="100" x2="60" y2="40" class="graph-line"/>
<line x1="150" y1="100" x2="240" y2="50" class="graph-line"/>
<line x1="150" y1="100" x2="80" y2="160" class="graph-line"/>
<line x1="150" y1="100" x2="230" y2="150" class="graph-line"/>
<line x1="60" y1="40" x2="240" y2="50" class="graph-line secondary"/>
<line x1="80" y1="160" x2="230" y2="150" class="graph-line secondary"/>
<circle cx="150" cy="100" r="18" class="graph-node center"/>
<circle cx="60" cy="40" r="12" class="graph-node"/>
<circle cx="240" cy="50" r="14" class="graph-node"/>
<circle cx="80" cy="160" r="11" class="graph-node"/>
<circle cx="230" cy="150" r="13" class="graph-node"/>
<text x="150" y="105" class="graph-label center-label">Main</text>
<text x="60" y="44" class="graph-label">Ideas</text>
<text x="240" y="54" class="graph-label">Projects</text>
<text x="80" y="164" class="graph-label">Meetings</text>
<text x="230" y="154" class="graph-label">Research</text>
</svg>
</div>
</div>
</div>
</div>
</section>
<!-- ===== SLIDE 11: Feature 4 - Semantic Search ===== -->
<section class="slide" id="slide-11" data-bg="feature-ai">
<div class="slide-content">
<div class="feature-badge fade-in">FEATURE 04</div>
<h1 class="slide-title fade-in">Tìm bằng <span class="text-gradient">ý nghĩa</span>, không phải keyword 🔍</h1>
<div class="search-compare fade-in delay-1">
<div class="search-before">
<h3>❌ Keyword Search truyền thống</h3>
<div class="search-box">
<span class="search-query">"meeting"</span>
<span class="search-arrow"></span>
<span class="search-result bad">500 kết quả không liên quan</span>
</div>
</div>
<div class="search-after">
<h3>✅ CuCu Note Semantic Search</h3>
<div class="search-box good">
<span class="search-query">"cuộc họp về dự án ABC tuần trước"</span>
<span class="search-arrow"></span>
<span class="search-result good">Đúng 1 note cần tìm ✨</span>
</div>
</div>
</div>
<div class="tech-pills fade-in delay-2">
<span class="pill">🧬 OpenAI Embeddings</span>
<span class="pill">🎯 Cosine Similarity</span>
<span class="pill">&lt; 0.1 giây</span>
<span class="pill">🌍 Đa ngôn ngữ</span>
</div>
</div>
</section>
<!-- ===== SLIDE 12: Feature 5 - Chrome Extension ===== -->
<section class="slide" id="slide-12" data-bg="feature">
<div class="slide-content">
<div class="feature-badge fade-in">FEATURE 05</div>
<h1 class="slide-title fade-in">Capture ideas ngay trên <span class="text-gradient">trình duyệt</span> 🌐</h1>
<div class="feature-showcase fade-in delay-1">
<div class="feature-list">
<div class="feature-item">
<span class="fi-icon">🖱️</span>
<div>
<strong>1-Click Capture</strong>
<p>Thấy gì hay trên web? Lưu ngay!</p>
</div>
</div>
<div class="feature-item">
<span class="fi-icon">✂️</span>
<div>
<strong>Web Clipper</strong>
<p>Cắt đoạn text, hình ảnh từ trang web</p>
</div>
</div>
<div class="feature-item">
<span class="fi-icon">📱</span>
<div>
<strong>Popup nhanh</strong>
<p>Không cần switch tab, ghi ngay</p>
</div>
</div>
<div class="feature-item">
<span class="fi-icon">🔄</span>
<div>
<strong>Đồng bộ real-time</strong>
<p>Lưu trên extension → hiện trên app ngay</p>
</div>
</div>
</div>
<div class="feature-mockup ext-mockup">
<div class="mockup-bar ext-bar">
<span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span>
<span class="mockup-title">Chrome Extension</span>
</div>
<div class="mockup-body ext-body">
<div class="ext-header">
<span>🐦 CuCu Note</span>
<span class="ext-status">● Connected</span>
</div>
<textarea class="ext-textarea" readonly>Đang đọc bài blog về AI trends 2026. Những điểm chính:
- Multi-modal AI sẽ phổ biến
- Edge computing + AI
- Privacy-first approach...</textarea>
<div class="ext-actions">
<button class="ext-btn">💾 Lưu Memo</button>
<button class="ext-btn secondary">🏷️ Thêm Tag</button>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ===== SLIDE 13: Feature 6 - Security ===== -->
<section class="slide" id="slide-13" data-bg="feature-ai">
<div class="slide-content">
<div class="feature-badge fade-in">FEATURE 06</div>
<h1 class="slide-title fade-in">Your data. Your rules. <span class="text-gradient">🔒</span></h1>
<div class="security-grid fade-in delay-1">
<div class="security-layer">
<div class="shield-ring ring-1">
<div class="shield-ring ring-2">
<div class="shield-ring ring-3">
<div class="shield-core">🛡️</div>
</div>
</div>
</div>
</div>
<div class="security-features">
<div class="sec-item">
<span class="sec-icon">🔐</span>
<div>
<strong>Clerk Authentication</strong>
<p>Đăng nhập an toàn, hỗ trợ SSO, OAuth</p>
</div>
</div>
<div class="sec-item">
<span class="sec-icon">🔒</span>
<div>
<strong>Fernet Encryption</strong>
<p>API keys được mã hóa end-to-end</p>
</div>
</div>
<div class="sec-item">
<span class="sec-icon">👁️</span>
<div>
<strong>Access Control</strong>
<p>Private / Protected / Public cho từng note</p>
</div>
</div>
<div class="sec-item">
<span class="sec-icon">🚦</span>
<div>
<strong>Rate Limiting</strong>
<p>Chống spam, chống DDoS tự động</p>
</div>
</div>
<div class="sec-item">
<span class="sec-icon">🏠</span>
<div>
<strong>Self-Hostable</strong>
<p>Deploy trên server riêng với Docker</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ===== SLIDE 14: Feature 7 - Multi-language ===== -->
<section class="slide" id="slide-14" data-bg="feature">
<div class="slide-content">
<div class="feature-badge fade-in">FEATURE 07</div>
<h1 class="slide-title fade-in">Anywhere. Any language. <span class="text-gradient-warm">🌍</span></h1>
<div class="world-features fade-in delay-1">
<div class="lang-globe">
<div class="globe-ring">
<span class="flag" style="--angle: 0deg">🇻🇳</span>
<span class="flag" style="--angle: 45deg">🇺🇸</span>
<span class="flag" style="--angle: 90deg">🇯🇵</span>
<span class="flag" style="--angle: 135deg">🇰🇷</span>
<span class="flag" style="--angle: 180deg">🇫🇷</span>
<span class="flag" style="--angle: 225deg">🇩🇪</span>
<span class="flag" style="--angle: 270deg">🇪🇸</span>
<span class="flag" style="--angle: 315deg">🇨🇳</span>
</div>
<div class="globe-center">30+<br>ngôn ngữ</div>
</div>
<div class="platform-features">
<div class="pf-item">
<span>📱</span>
<strong>Responsive Design</strong>
<p>Desktop, Tablet, Mobile</p>
</div>
<div class="pf-item">
<span>🌓</span>
<strong>Dark / Light Mode</strong>
<p>Bảo vệ mắt, theo ý thích</p>
</div>
<div class="pf-item">
<span>📊</span>
<strong>Activity Calendar</strong>
<p>Heatmap theo dõi thói quen ghi chú</p>
</div>
<div class="pf-item">
<span>⌨️</span>
<strong>Command Palette</strong>
<p>Ctrl+K mở nhanh mọi thứ</p>
</div>
</div>
</div>
</div>
</section>
<!-- ===== SLIDE 15: Tech Stack ===== -->
<section class="slide" id="slide-15" data-bg="tech">
<div class="slide-content">
<p class="section-label fade-in">KIẾN TRÚC</p>
<h1 class="slide-title fade-in">Built with <span class="text-gradient">modern tech</span>, for modern people</h1>
<div class="arch-diagram fade-in delay-1">
<div class="arch-layer frontend-layer">
<h3>⚛️ Frontend</h3>
<div class="tech-tags">
<span>React 18</span>
<span>TypeScript</span>
<span>Vite</span>
<span>TailwindCSS</span>
<span>Radix UI</span>
<span>TanStack Query</span>
</div>
</div>
<div class="arch-arrow">↕️</div>
<div class="arch-layer backend-layer">
<h3>🐍 Backend</h3>
<div class="tech-tags">
<span>FastAPI</span>
<span>LangGraph</span>
<span>LangChain</span>
<span>OpenAI</span>
</div>
</div>
<div class="arch-arrow">↕️</div>
<div class="arch-layer data-layer">
<h3>💾 Data Layer</h3>
<div class="tech-tags">
<span>MongoDB</span>
<span>Redis</span>
</div>
</div>
<div class="arch-ext">
<h3>🌐 Extension</h3>
<div class="tech-tags">
<span>Chrome API</span>
<span>TypeScript</span>
</div>
</div>
</div>
<div class="perf-metrics fade-in delay-2">
<div class="metric"><span></span> Search &lt; 0.1s</div>
<div class="metric"><span>🔄</span> Auto-scaling Docker</div>
<div class="metric"><span>📊</span> 99.9% Uptime</div>
<div class="metric"><span>🛡️</span> Production-ready</div>
</div>
</div>
</section>
<!-- ===== SLIDE 16: Demo ===== -->
<section class="slide" id="slide-16" data-bg="demo">
<div class="slide-content center-content">
<div class="demo-icon fade-in">👀</div>
<h1 class="mega-title fade-in">Live Demo</h1>
<p class="tagline fade-in delay-1">Seeing is believing</p>
<div class="demo-steps fade-in delay-2">
<div class="demo-step">
<span class="step-num">01</span>
<span>✍️ Tạo memo mới + thêm tags</span>
</div>
<div class="demo-step">
<span class="step-num">02</span>
<span>🔍 Semantic search tìm memo</span>
</div>
<div class="demo-step">
<span class="step-num">03</span>
<span>🤖 Hỏi AI chatbot về notes</span>
</div>
<div class="demo-step">
<span class="step-num">04</span>
<span>🔗 Xem memo relations (Force Graph)</span>
</div>
<div class="demo-step">
<span class="step-num">05</span>
<span>🌐 Chrome Extension capture</span>
</div>
</div>
</div>
</section>
<!-- ===== SLIDE 17: Roadmap ===== -->
<section class="slide" id="slide-17" data-bg="dark">
<div class="slide-content">
<p class="section-label fade-in">TƯƠNG LAI</p>
<h1 class="slide-title fade-in">Where we're going <span class="text-gradient">🚀</span></h1>
<div class="roadmap fade-in delay-1">
<div class="roadmap-item active">
<div class="rm-marker"></div>
<div class="rm-content">
<span class="rm-date">Hiện tại</span>
<strong>Core Platform</strong>
<p>Memo, AI Chat, Search, Extension, Security</p>
</div>
</div>
<div class="roadmap-item">
<div class="rm-marker"></div>
<div class="rm-content">
<span class="rm-date">Q2 2026</span>
<strong>AI Auto-Organize</strong>
<p>Tự phân loại notes, gợi ý tags thông minh</p>
</div>
</div>
<div class="roadmap-item">
<div class="rm-marker"></div>
<div class="rm-content">
<span class="rm-date">Q3 2026</span>
<strong>Collaboration</strong>
<p>Chia sẻ workspace, real-time editing</p>
</div>
</div>
<div class="roadmap-item">
<div class="rm-marker"></div>
<div class="rm-content">
<span class="rm-date">Q4 2026</span>
<strong>Mobile App</strong>
<p>iOS & Android native app</p>
</div>
</div>
<div class="roadmap-item future">
<div class="rm-marker"></div>
<div class="rm-content">
<span class="rm-date">2027</span>
<strong>AI Research Agent + Marketplace</strong>
<p>Tự động nghiên cứu, Plugin store</p>
</div>
</div>
</div>
<div class="vision-statement fade-in delay-2">
"Biến CuCu Note thành <strong>Second Brain</strong> toàn diện nhất cho người Việt"
</div>
</div>
</section>
<!-- ===== SLIDE 18: Closing ===== -->
<section class="slide" id="slide-18" data-bg="closing">
<div class="slide-content center-content">
<h1 class="mega-title fade-in">
Đừng để ý tưởng hay nhất<br>
<span class="text-gradient">chết trong im lặng</span> 💡
</h1>
<div class="cta-grid fade-in delay-1">
<div class="cta-card">
<span>🎯</span>
<strong>Miễn phí</strong>
<p>Không cần credit card</p>
</div>
<div class="cta-card">
<span>🌟</span>
<strong>Open Source</strong>
<p>Đóng góp trên GitHub</p>
</div>
<div class="cta-card">
<span>🌐</span>
<strong>Extension</strong>
<p>Chrome Web Store</p>
</div>
</div>
<div class="github-link fade-in delay-2">
<span class="gh-icon"></span>
<span>github.com/Hoanganhvu123/cuccu_note</span>
</div>
<p class="closing-quote fade-in delay-3">
"Bạn không cần bộ nhớ siêu phàm.<br>
Bạn chỉ cần <strong>CuCu Note</strong>."
</p>
<p class="qa-text fade-in delay-3">Q & A 🎤</p>
</div>
</section>
<!-- Keyboard shortcut hint -->
<div class="keyboard-hint" id="keyHint">
← → hoặc Space để chuyển slide | F để fullscreen
</div>
<script src="script.js"></script>
</body>
</html>
# 🐦 CuCu Note — Pitch Deck
# ⚔️ *"Thiên Hạ Tri Thức Ký"*
> **"Người quân tử chẳng cần trí nhớ vạn năm. Chỉ cần CuCu Note."**
---
## 📑 Mục Lục
| Slide | Chủ đề | Trang |
|-------|--------|-------|
| 1 | Opening — Hook gây sốc | [](#slide-1--opening-hook) |
| 2 | Ai cũng muốn du lịch | [](#slide-2--ai-cũng-muốn-du-lịch) |
| 3 | Reality check: Cần tiền | [](#slide-3--reality-check-tiền-từ-đâu) |
| 4 | Làm nhiều ≠ Hiệu quả | [](#slide-4--làm-nhiều--làm-hiệu-quả) |
| 5 | Kẻ giết thầm lặng: QUÊN | [](#slide-5--kẻ-thù-số-1-quên) |
| 6 | Hệ quả thực tế | [](#slide-6--hệ-quả-thực-tế) |
| 7 | Giải pháp: CuCu Note | [](#slide-7--the-solution-cucu-note) |
| 8 | Ghi chú linh hoạt | [](#slide-8--feature-1-ghi-chú-linh-hoạt) |
| 9 | AI Assistant | [](#slide-9--feature-2-ai-assistant) |
| 10 | Liên kết ghi chú | [](#slide-10--feature-3-memo-relations) |
| 11 | Tìm kiếm ngữ nghĩa | [](#slide-11--feature-4-semantic-search) |
| 12 | Chrome Extension | [](#slide-12--feature-5-chrome-extension) |
| 13 | Bảo mật & Riêng tư | [](#slide-13--feature-6-bảo-mật) |
| 14 | Đa ngôn ngữ & Đa nền tảng | [](#slide-14--feature-7-đa-ngôn-ngữ) |
| 15 | Kiến trúc hệ thống | [](#slide-15--kiến-trúc-hệ-thống) |
| 16 | Live Demo | [](#slide-16--live-demo) |
| 17 | Roadmap & Vision | [](#slide-17--roadmap--vision) |
| 18 | Closing & CTA | [](#slide-18--closing) |
---
## Slide 1 — 🏮 Khai Màn: Lời Vấn Thiên Hạ
### 💡 *"Ký ức vạn dặm, một phút tiêu tan — ai người chẳng từng đánh rơi ý hay?"*
**Số liệu shock:**
- Mỗi ngày chúng ta có **6,200 suy nghĩ** *(Queen's University, 2020)*
- Bao nhiêu trong số đó bạn còn nhớ vào cuối ngày?
- Câu trả lời: **gần như không cái nào**
> 🎤 *"Chào mọi người! Trước khi nói về sản phẩm, tôi muốn hỏi các bạn một câu... Có ai ở đây từng nghĩ ra một ý tưởng cực hay lúc 2 giờ sáng, rồi sáng dậy quên sạch không? Giơ tay lên nào..."*
---
## Slide 2 — 🏮 Chương I: Giấc Mộng Giang Hồ
### ✈️ *"Ai người chẳng mơ dạo bước tứ phương, ngắm trăng rơi trên biển lạ..."*
| | Đích đến | Cảm giác |
|---|----------|----------|
| 🏖️ | Bali, Indonesia | Nằm trên bãi biển, nhâm nhi cocktail |
| ⛰️ | Fansipan, Sapa | Chinh phục đỉnh núi vào sáng sớm |
| 🗼 | Paris, Pháp | Selfie dưới tháp Eiffel |
| 🌸 | Tokyo, Nhật | Cherry blossom season |
| ✈️ | Anywhere | Work from anywhere — Digital Nomad life |
**Twist:** *"Nhưng... để đi được thì cần gì? 🤔"*
> 🎤 *"Ai trong room này mà không thích du lịch? Mơ về Maldives, Santorini... Instagram toàn ảnh đẹp. Nhưng để mà đi được thì..."*
---
## Slide 3 — 🏮 Chương I: Phũ Phàng Hiện Thực
### 💸 *"Muốn dạo giang hồ — trước hết túi phải nặng bạc vàng"*
| Điểm đến | Chi phí ước tính |
|-----------|-----------------|
| 🏝️ Maldives | 50 – 100 triệu VNĐ |
| 🇪🇺 Châu Âu 10 ngày | 80 – 150 triệu VNĐ |
| 🇯🇵 Nhật Bản | 30 – 60 triệu VNĐ |
| 🌸 Đà Lạt cuối tuần | 5 – 10 triệu VNĐ |
**Key message:**
> 💼 Tiền không tự nhiên mà có — nó đến từ **CÔNG VIỆC**.
> Muốn du lịch nhiều hơn = Phải kiếm tiền nhiều hơn = Phải **làm việc hiệu quả** hơn.
> 🎤 *"OK giấc mơ thì đẹp. Nhưng thực tế thì ví hay nói 'không đủ tiền đâu bạn ơi'. Vậy tiền đến từ đâu? CÔNG VIỆC!"*
---
## Slide 4 — 🏮 Chương II: Luận Về Hai Phái Võ Lâm
### ⚡ *"Kẻ cùn kiếm chém trăm nhát chẳng bằng cao thủ một chiêu hạ gục"*
| | 😩 Người A | 😎 Người B |
|---|-----------|-----------|
| Giờ làm | 12h/ngày | 6h/ngày |
| Trạng thái | Stress, hết pin | Thoải mái, cân bằng |
| Quản lý | Quên deadline liên tục | Ghi chú rõ ràng, hệ thống |
| Thu nhập | ❌ 10 triệu/tháng | ✅ 30 triệu/tháng |
**Key insight:**
> Khác biệt không phải **thời gian** — mà là **cách quản lý kiến thức**.
> "Kiến thức là sức mạnh — nhưng chỉ khi bạn còn **NHỚ** nó."
> 🎤 *"Có 2 kiểu: người cày 12 tiếng nhưng lương bèo, và người làm 6 tiếng nhưng output gấp 3 lần. Khác biệt ở đâu? Không phải siêng năng, mà là HIỆU QUẢ."*
---
## Slide 5 — 🏮 Chương III: Kẻ Thù Vô Hình — Vong Quên Đại Ma
### 🧠 *"Đường cong tử vong của ký ức — Ebbinghaus Thất Truyền Phổ"*
| Sau bao lâu | % thông tin CÒN NHỚ | % ĐÃ QUÊN |
|-------------|---------------------|-----------|
| 20 phút | 58% | 42% |
| 1 giờ | 44% | 56% |
| 1 ngày | 33% | **67%** |
| 1 tuần | 25% | **75%** |
| 1 tháng | 21% | **79%** |
**Ví dụ thực tế ai cũng gặp:**
- 📝 Note meeting rồi quên đặt ở đâu
- 💡 Ý tưởng hay lúc 2h sáng → sáng dậy bay sạch
- 🔍 Google cái gì hay ho → bookmark rồi không bao giờ mở lại
- 📱 Tab trình duyệt chất đống 50+ cái
- 📧 Email quan trọng bị chìm trong inbox
> 🎤 *"Hermann Ebbinghaus chứng minh: não bộ giống cái rây — thông tin chảy qua và MẤT. Sau 1 ngày bạn đã quên 67%. Bao nhiêu ý tưởng tỷ đô đã chết vì... QUÊN?"*
---
## Slide 6 — 🏮 Chương III: Cái Giá Của Vong Quên
### 📊 *"Quên — là mất bạc, mất thời, mất cả giang sơn cơ đồ"*
| Metric | Số liệu | Nguồn |
|--------|---------|-------|
| 🕐 **Thời gian lãng phí** | **2.5 giờ/ngày** tìm lại thông tin | McKinsey |
| 💰 **Thiệt hại doanh nghiệp** | **$47 triệu/năm** do mất kiến thức | Panopto |
| 🤯 **Quá tải thông tin** | **83%** nhân viên cảm thấy overwhelmed | Workplace Survey |
**Tính nhanh:**
> 2.5 giờ/ngày × 365 ngày = **912 giờ = 38 ngày mất trắng mỗi năm**
>
> 38 ngày đủ để đi **3 chuyến du lịch châu Âu** ☀️
> 🎤 *"2.5 tiếng mỗi ngày chỉ để TÌM LẠI cái mình đã biết. Cả năm = hơn 1 tháng mất trắng. Đủ đi 3 chuyến du lịch rồi! Vậy... làm sao?"*
---
## Slide 7 — 🏮 Chương IV: Thần Khí Xuất Thế — CuCu Note
### 🐦 *"Thiên hạ hỗn loạn, một thần khí giáng trần — Bộ Nhớ Thứ Hai ra đời"*
**Tagline:** *"Capture. Organize. Never Forget."*
**One-liner:** Ứng dụng ghi chú thông minh tích hợp AI, giúp bạn không bao giờ mất kiến thức nữa.
**3 trụ cột:**
| | Trụ cột | Mô tả |
|---|---------|-------|
| 📝 | **Ghi nhanh** | Mọi lúc, mọi nơi — web, extension, mobile |
| 🤖 | **AI hỗ trợ** | Tìm kiếm thông minh, gợi ý liên kết, chat tự nhiên |
| 🔒 | **Bảo mật** | Dữ liệu của bạn, chỉ mình bạn truy cập |
> 🎤 *"Và đó chính là lý do CuCu Note ra đời. Không phải một app ghi chú nữa — mà là BỘ NHỚ THỨ HAI của bạn. Để tôi show cho các bạn nó làm được gì."*
---
## Slide 8 — ⚔️ Chiêu Thức I: Tốc Ký Thần Chưởng
### ✍️ *"Viết. Ngay. Lập. Tức. — Nhanh như kiếm khách rút kiếm"*
| Tính năng | Mô tả |
|-----------|-------|
| ✍️ **Rich Markdown Editor** | Viết nhanh với format đẹp — bold, italic, code blocks, tables, lists |
| 📌 **Pin & Archive** | Ghim note quan trọng lên đầu, archive note cũ gọn gàng |
| 🏷️ **#Tags System** | Gõ `#tag` để phân loại, tree view cho tags lồng nhau |
| 📎 **Đính kèm file** | Ảnh, PDF, tài liệu — drag & drop |
| 🎨 **Masonry Layout** | Giao diện dạng lưới như Pinterest, đẹp mắt |
| 📊 **Activity Calendar** | Heatmap theo dõi thói quen ghi chú (giống GitHub) |
| ⌨️ **Command Palette** | `Ctrl+K` mở nhanh mọi thứ |
**So sánh với đối thủ:**
| Feature | Notion | Obsidian | CuCu Note |
|---------|--------|----------|-----------|
| Speed khởi động | 🐢 Chậm | ⚡ Nhanh | ⚡ Nhanh |
| AI tích hợp | 💰 Trả phí | ❌ Không | ✅ Miễn phí (BYOK) |
| Self-host | ❌ | ❌ | ✅ Docker |
| Offline-first | ❌ | ✅ | ✅ |
| Open Source | ❌ | ❌ | ✅ MIT License |
> 🎤 *"Ghi chú phải NHANH. Mở app → gõ → xong. Không cần folder phức tạp, không cần 10 phút setup."*
---
## Slide 9 — ⚔️ Chiêu Thức II: Trí Tuệ Nhân Tạo Thần Công
### 🤖 *"Quân sư AI tại hạ — hỏi gì đáp nấy, trăm trận trăm thắng"*
**Cách hoạt động:**
```
Bạn hỏi: "Tìm giúp tôi note về cuộc họp dự án ABC tuần trước"
🧠 LangGraph Agent nhận câu hỏi
🔍 Routing → Tìm trong Notes (Semantic Search)
📝 Tìm thấy 2 notes liên quan:
• "Meeting Q2 Planning" — 17/02
• "ABC Project Update" — 18/02
💬 Trả lời tự nhiên kèm nội dung tóm tắt
```
| Tính năng AI | Mô tả |
|-------------|-------|
| 🧠 **LangGraph Agent** | AI thông minh điều phối workflow — không phải chatbot đơn giản |
| 🔍 **Semantic Search** | Tìm bằng "ý nghĩa", không phải keyword ‒ hiểu context |
| 💬 **Chat tự nhiên** | Hỏi gì cũng được — AI tìm trong toàn bộ notes của bạn |
| 📚 **Nhớ lịch sử** | AI nhớ ngữ cảnh cuộc hội thoại, đưa câu trả lời chính xác |
| 🔑 **BYOK** | Bring Your Own Key — dùng API key riêng, kiểm soát chi phí |
| 🔄 **Multi-model** | Hỗ trợ GPT-4o, Gemini, Groq — chọn model theo ý thích |
**Điểm khác biệt lớn nhất:**
> CuCu Note AI không phải chatGPT wrapper — nó là **Agent thực sự** với khả năng:
> - Tìm trong notes của bạn
> - Phân tích dữ liệu
> - Tự động lưu thông tin mới
> - Gợi ý liên kết giữa các notes
> 🎤 *"Đây là killer feature. Bạn không cần nhớ note ở đâu — chỉ cần HỎI. AI sẽ tìm trong toàn bộ notes và trả lời. Nó không phải ChatGPT wrapper, nó hiểu DATA CỦA BẠN."*
---
## Slide 10 — ⚔️ Chiêu Thức III: Vạn Mạch Liên Kết Trận
### 🔗 *"Ghi chú không đứng cô đơn — chúng kết thành TRẬN PHÁP"*
**Ý tưởng:**
> Một note về "Machine Learning" liên kết với note "Python Project", liên kết với "Team Meeting Q2", liên kết với "Budget Plan"...
> → Bạn nhìn thấy **BIG PICTURE** mà trước đây không thể thấy.
| Tính năng | Mô tả |
|-----------|-------|
| 🔗 **Link Notes** | Tạo mạng lưới kiến thức giữa các ghi chú |
| 🌐 **Force Graph** | Đồ thị tương tác 3D — kéo thả, zoom, click để mở note |
| 🧩 **Zettelkasten** | Áp dụng phương pháp ghi chú của Niklas Luhmann (70,000+ notes → 70+ sách) |
| 🤖 **AI gợi ý liên kết** | "Note này có vẻ liên quan đến 3 notes khác — muốn link không?" |
**Tại sao quan trọng:**
> *"Sáng tạo không phải tạo ra cái mới từ con số 0 — mà là KẾT NỐI những thứ đã có theo cách mới."* — Steve Jobs
> 🎤 *"Ý tưởng hay nhất thường xuất hiện khi bạn KẾT NỐI 2 ý tưởng cũ. CuCu Note giúp bạn nhìn thấy những connection mà bạn chưa từng nghĩ tới."*
---
## Slide 11 — ⚔️ Chiêu Thức IV: Ngữ Nghĩa Thiên Lý Nhãn
### 🔍 *"Tìm bằng TÂM Ý, không bằng chữ suông — thiên lý nhãn thấu hiểu vạn vật"*
**Trước vs. Sau:**
| | ❌ Keyword Search truyền thống | ✅ CuCu Note Semantic Search |
|---|-------------------------------|------------------------------|
| Query | "meeting" | "cuộc họp về dự án ABC tuần trước" |
| Kết quả | 500 notes chứa chữ "meeting" | **Đúng 1 note** cần tìm |
| Thời gian | Lượn qua 500 kết quả: 10 phút | **< 0.1 giây** |
| Đa ngôn ngữ | ❌ Phải search đúng ngôn ngữ | ✅ Viết tiếng Việt, tìm tiếng Anh |
**Công nghệ đằng sau:**
- 🧬 **OpenAI Embeddings** — Chuyển text thành vector 1,536 chiều
- 🎯 **Cosine Similarity** — So sánh "khoảng cách ngữ nghĩa"
-**MongoDB Vector Search** — Tối ưu hoá cho tốc độ
- 🌍 **Cross-lingual** — Hiểu ngữ nghĩa bất kể ngôn ngữ
> 🎤 *"Bạn không cần nhớ mình đã viết GÌ — chỉ cần mô tả ý tưởng, AI sẽ tìm hộ. Dưới 0.1 giây. Nhanh hơn cả bạn nhấp chuột."*
---
## Slide 12 — ⚔️ Chiêu Thức V: Thiên La Địa Võng — Bắt Ý Khắp Giang Hồ
### 🌐 *"Dạo khắp giang hồ mạng — thấy gì hay, thu vào túi thần ngay"*
**Use cases thực tế:**
| Tình huống | Hành động |
|-----------|----------|
| Đang đọc bài blog hay | 🖱️ Click extension → Lưu ngay |
| Thấy tweet thú vị | ✂️ Select text → Save to CuCu |
| Research cho dự án | 📋 Clip toàn bộ trang + URL nguồn |
| Ý tưởng bất chợt | 📝 Mở popup → Ghi nhanh 3 giây |
**Tính năng:**
- 🖱️ **1-Click Capture** — Không cần switch tab
- ✂️ **Web Clipper** — Cắt đoạn text, hình ảnh
- 📝 **Quick Memo** — Popup ghi nhanh
- 🔄 **Đồng bộ real-time** — Extension → App ngay lập tức
- 🏷️ **Auto-tag** — Tự thêm tag dựa trên URL/nội dung
> 🎤 *"Đang đọc bài blog hay? Tweet thú vị? Click 1 cái, done. Không cần copy-paste, không cần remember anything. CuCu Note nhớ giùm bạn."*
---
## Slide 13 — ⚔️ Chiêu Thức VI: Kim Chung Thiết Bích — Bất Khả Xâm Phạm
### 🔒 *"Dữ liệu của ngươi — chỉ mình ngươi nắm giữ. Thiên hạ bất khả xâm"*
**5 lớp bảo mật:**
| Lớp | Công nghệ | Chức năng |
|-----|-----------|----------|
| 🔐 Layer 1 | **Clerk Auth** | SSO, OAuth, MFA — đăng nhập an toàn |
| 🔒 Layer 2 | **Fernet Encryption** | API keys mã hóa cấp quân sự |
| 👁️ Layer 3 | **Access Control** | Private / Protected / Public cho từng note |
| 🚦 Layer 4 | **Rate Limiting** | Redis-backed — chống spam, DDoS |
| 🏠 Layer 5 | **Self-Hostable** | Docker deploy trên server riêng |
**Cam kết:**
> - ✅ Không bán dữ liệu cho bên thứ 3
> - ✅ Không đọc notes của bạn
> - ✅ Bạn có thể self-host 100%
> - ✅ Open source = Minh bạch hoàn toàn
> 🎤 *"Privacy-first. Dữ liệu note của bạn không ai có quyền đọc trừ bạn. Thậm chí bạn có thể tải source code về và chạy trên server riêng."*
---
## Slide 14 — ⚔️ Chiêu Thức VII: Vạn Quốc Ngữ — Thông Thiên Hạ
### 🌍 *"Dù thân tại Đại Việt hay viễn du Nhật Bản — ngôn ngữ nào cũng thông"*
**Hỗ trợ 30+ ngôn ngữ:**
🇻🇳 Tiếng Việt · 🇺🇸 English · 🇯🇵 日本語 · 🇰🇷 한국어 · 🇨🇳 中文 · 🇫🇷 Français · 🇩🇪 Deutsch · 🇪🇸 Español · 🇮🇹 Italiano · 🇧🇷 Português · 🇷🇺 Русский · 🇹🇭 ไทย · 🇮🇩 Bahasa · 🇸🇦 العربية · *và nhiều hơn...*
**Đa nền tảng:**
| Platform | Trạng thái |
|----------|-----------|
| 🖥️ Desktop Web | ✅ Responsive |
| 📱 Mobile Web | ✅ Responsive |
| 🌐 Chrome Extension | ✅ Available |
| 🌓 Dark / Light Mode | ✅ Auto-detect |
| 📊 Activity Calendar | ✅ GitHub-style heatmap |
| ⌨️ Command Palette | ✅ `Ctrl+K` – mọi thứ trong tầm tay |
> 🎤 *"Dùng ở VN thì tiếng Việt, đi Nhật chuyển tiếng Nhật. Dark mode cho đêm khuya code, light mode cho buổi sáng cafe."*
---
## Slide 15 — 🏯 Nội Công Tâm Pháp: Kiến Trúc Hệ Thống
### 🏗️ *"Nội công thâm hậu — kiến trúc hiện đại, vận hành vạn năm bất hoại"*
```
┌─────────────────────────────────────────────────┐
│ FRONTEND │
│ ⚛️ React 18 · TypeScript · Vite · TailwindCSS │
│ Radix UI · TanStack Query · i18next │
└──────────────────────┬──────────────────────────┘
│ REST API + SSE Streaming
┌──────────────────────┴──────────────────────────┐
│ BACKEND │
│ 🐍 FastAPI · LangGraph Agent · LangChain │
│ OpenAI Embeddings · Clerk Auth · Langfuse │
└──────┬───────────────────────────────┬──────────┘
│ │
┌──────┴──────┐ ┌───────┴────────┐
│ 🍃 MongoDB │ │ ⚡ Redis │
│ Data Store │ │ Cache + Rate │
│ + Vectors │ │ Limiting │
└─────────────┘ └────────────────┘
┌─────────────────────────────────────────────────┐
│ 🌐 CHROME EXTENSION │
│ TypeScript · Chrome API · Popup + Content │
└─────────────────────────────────────────────────┘
```
**Performance metrics:**
| Metric | Giá trị |
|--------|---------|
| ⚡ Semantic Search | **< 0.1 giây** |
| 🚀 App load time | **< 400ms** |
| 🔄 Scalability | **Docker auto-scaling** |
| 📊 Uptime target | **99.9%** |
| 🛡️ Security | **Production-ready** |
> 🎤 *"Cho các bạn tech-savvy: stack hiện đại, scalable. FastAPI cho speed, MongoDB cho flexibility, Redis cho performance, LangGraph cho AI intelligence."*
---
## Slide 16 — 🏟️ Tỉ Thí Đài: Live Demo
### 👀 *"Lời nói suông vô nghĩa — hãy để thần khí tự chứng minh trên đài tỉ thí"*
**Demo flow (5 bước, ~2 phút):**
| # | Hành động | Thời gian |
|---|----------|----------|
| 01 | ✍️ Tạo memo mới → thêm `#tags` → format Markdown | 30s |
| 02 | 🔍 Dùng Semantic Search tìm memo vừa tạo bằng mô tả | 15s |
| 03 | 🤖 Hỏi AI chatbot: *"Tóm tắt notes hôm nay cho tôi"* | 30s |
| 04 | 🔗 Xem Memo Relations trên Force Graph tương tác | 20s |
| 05 | 🌐 Mở Chrome Extension, capture 1 trang web → sync | 25s |
**Backup:** Chuẩn bị video pre-recorded phòng trường hợp mạng lag.
---
## Slide 17 — 🗺️ Thiên Hạ Đại Kế: Lộ Trình Tương Lai
### 🚀 *"Con đường phía trước — từ giang hồ nhỏ đến bá chủ thiên hạ"*
| Timeline | Milestone | Chi tiết |
|----------|-----------|---------|
| ✅ **Hiện tại** | Core Platform | Memo, AI Chat, Semantic Search, Extension, Security, 30+ languages |
| 🔜 **Q2 2026** | AI Auto-Organize | Tự phân loại notes, gợi ý tags, auto-summarize |
| 🔜 **Q3 2026** | Collaboration | Shared workspaces, real-time co-editing, comments |
| 🔜 **Q4 2026** | Mobile App | iOS & Android native — offline-first |
| 🔮 **2027** | AI Research Agent | Autonomous research, tự tổng hợp tài liệu từ nhiều nguồn |
| 🔮 **2027** | Marketplace | Community templates, plugins, themes |
**Vision:**
> *"Biến CuCu Note thành **Second Brain** toàn diện nhất — nơi mọi kiến thức được lưu trữ, kết nối, và PHÁT TRIỂN cùng bạn."*
**Market size (TAM):**
- 🌏 Note-taking market: **$1.35 tỷ USD** (2025) → dự kiến **$2.1 tỷ USD** (2030)
- 📈 CAGR: **9.2%/năm**
- 👥 Target: Knowledge workers, học sinh/sinh viên, freelancers, startups
> 🎤 *"CuCu Note không chỉ dừng lại ở ghi chú. Mục tiêu là trở thành bộ não thứ hai — nơi kiến thức lưu trữ, kết nối, và PHÁT TRIỂN. Thị trường $2 tỷ đô, và chúng tôi mới chỉ bắt đầu."*
---
## Slide 18 — 🏮 Hạ Màn: Lời Di Huấn
### 💡 *"Đừng để kỳ mưu tuyệt thế chết trong lặng lẽ vô thanh"*
**CTA — Hành động ngay:**
| | Hành động | Link |
|---|----------|------|
| 🎯 | **Dùng thử miễn phí** | Không cần credit card |
| ⭐ | **Star trên GitHub** | [github.com/Hoanganhvu123/cuccu_note](https://github.com/Hoanganhvu123/cuccu_note) |
| 🌐 | **Cài Chrome Extension** | Chrome Web Store |
| 💬 | **Liên hệ đầu tư** | [Email / LinkedIn] |
---
**Closing quote:**
> ## *"Bạn không cần bộ nhớ siêu phàm.*
> ## *Bạn chỉ cần **CuCu Note**."* 🐦
---
**🎤 Q & A**
*Cảm ơn mọi người đã lắng nghe! Nếu các bạn muốn thử, link đây. Và nếu thích dự án, cho mình 1 ⭐ trên GitHub nhé!*
---
## 📋 Speaker Notes Tổng Hợp
**Tone:** Vui nhộn, gần gũi, storytelling — KHÔNG khô khan công nghệ.
**Thời lượng:** ~14 phút (không tính Q&A)
**Tips:**
1. Mở bằng câu hỏi — tương tác ngay với khán giả
2. Dùng meme + số liệu — vui nhưng credible
3. Demo live > Screenshot — show sản phẩm chạy thực
4. Kể chuyện cá nhân — *"Tôi đã mất bao nhiêu ý tưởng trước khi build CuCu Note"*
5. Kết bằng CTA rõ ràng — cho khán giả biết ngay bước tiếp theo
6. Với nhà đầu tư: nhấn mạnh **market size, growth rate, và competitive advantage**
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