(.*?)<\/blockquote>/g, '> $1\n')
+ .replace(/(.*?)<\/pre>/gs, '```\n$1\n```\n')
+ .replace(/(.*?)<\/code>/g, '`$1`')
+ .replace(/(.*?)<\/li>/g, '- $1\n')
+ .replace(/|<\/ul>||<\/ol>/g, '')
+ .replace(/(.*?)<\/p>/g, '$1\n\n')
+ .replace(/ /g, '\n')
+ .replace(/<[^>]+>/g, '');
+ return md.trim();
+ };
+
+ const handleSave = async () => {
+ if (!editorRef.current || !notebook?.id) return;
+ setSaving(true);
+ try {
+ const content = htmlToMarkdown(editorRef.current.innerHTML);
+ const mdContent = coverImage ? `\n\n# ${title}\n\n${content}` : `# ${title}\n\n${content}`;
+ const blob = new Blob([mdContent], { type: 'text/markdown' });
+ const file = new File([blob], `${title}.md`, { type: 'text/markdown' });
+
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('email', user?.email || user?.id || 'default');
+ formData.append('user_id', user?.id || 'default');
+ formData.append('notebook_id', notebook.id);
+ formData.append('notebook_title', notebook?.title || notebook?.name || '');
+
+ const res = await apiFetch('/api/v1/kb/upload', { method: 'POST', body: formData });
+ if (!res.ok) throw new Error('Save failed');
+
+ alert('Note saved to knowledge base!');
+ onSaved?.();
+ onClose();
+ } catch (err) {
+ alert('Failed to save note');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleExport = () => {
+ if (!editorRef.current) return;
+ const content = htmlToMarkdown(editorRef.current.innerHTML);
+ const mdContent = coverImage ? `\n\n# ${title}\n\n${content}` : `# ${title}\n\n${content}`;
+ const blob = new Blob([mdContent], { type: 'text/markdown' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${title}.md`;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ return (
+
+
+
setTitle(e.target.value)}
+ className="text-2xl font-bold outline-none flex-1"
+ placeholder="Untitled"
+ />
+
+
+ Export
+
+
+ {saving ? 'Saving...' : 'Save'}
+
+
+ Done
+
+
+
+
+
+
coverInputRef.current?.click()} className="p-2 hover:bg-gray-200 rounded" title="Add Cover">
+
+
+
mdInputRef.current?.click()} className="p-2 hover:bg-gray-200 rounded" title="Embed Markdown">
+
+
+
+
execCommand('bold')} className="p-2 hover:bg-gray-200 rounded" title="Bold">
+
+
+
execCommand('italic')} className="p-2 hover:bg-gray-200 rounded" title="Italic">
+
+
+
execCommand('formatBlock', 'h1')} className="p-2 hover:bg-gray-200 rounded" title="Heading 1">
+
+
+
execCommand('formatBlock', 'h2')} className="p-2 hover:bg-gray-200 rounded" title="Heading 2">
+
+
+
execCommand('insertUnorderedList')} className="p-2 hover:bg-gray-200 rounded" title="Bullet List">
+
+
+
execCommand('insertOrderedList')} className="p-2 hover:bg-gray-200 rounded" title="Numbered List">
+
+
+
execCommand('formatBlock', 'blockquote')} className="p-2 hover:bg-gray-200 rounded" title="Quote">
+
+
+
execCommand('formatBlock', 'pre')} className="p-2 hover:bg-gray-200 rounded" title="Code Block">
+
+
+
+
+
+
+
+ {coverImage && (
+
+
+
setCoverImage(null)} className="absolute top-2 right-2 bg-red-500 text-white px-2 py-1 rounded text-xs">
+ Remove
+
+
+ )}
+
+
+
+ );
+};
diff --git a/frontend_en/src/components/notes/NotionEditor.tsx b/frontend_en/src/components/notes/NotionEditor.tsx
new file mode 100644
index 0000000..a25024e
--- /dev/null
+++ b/frontend_en/src/components/notes/NotionEditor.tsx
@@ -0,0 +1,586 @@
+import React, { useState, useCallback } from 'react';
+import { Block, BlockType } from './types';
+import { BlockEditor } from './BlockEditor';
+import { SlashMenu } from './SlashMenu';
+import { AIPanel } from './AIPanel';
+import { TextSelectionToolbar } from './TextSelectionToolbar';
+import { DiffPreviewPanel } from './DiffPreviewPanel';
+import { Image, Download, Save, X, Maximize2, Minimize2 } from 'lucide-react';
+import { apiFetch } from '../../config/api';
+import type { KnowledgeFile } from '../../types';
+
+interface NotionEditorProps {
+ onClose: () => void;
+ notebook: any;
+ user: any;
+ files?: KnowledgeFile[];
+ onSaved?: () => void;
+}
+
+export const NotionEditor: React.FC = ({
+ onClose,
+ notebook,
+ user,
+ files = [],
+ onSaved,
+}) => {
+ const [title, setTitle] = useState('Untitled');
+ const [coverImage, setCoverImage] = useState(null);
+ const [blocks, setBlocks] = useState([{ id: '1', type: 'text', content: '' }]);
+ const [slashMenuBlock, setSlashMenuBlock] = useState(null);
+ const [aiPanelBlock, setAiPanelBlock] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [isFullScreen, setIsFullScreen] = useState(false);
+ const [draggedBlock, setDraggedBlock] = useState(null);
+
+ // Memory for AI context
+ const [noteMemory, setNoteMemory] = useState('');
+
+ // Text selection toolbar
+ const [textSelection, setTextSelection] = useState<{
+ blockId: string;
+ text: string;
+ position: { x: number; y: number };
+ } | null>(null);
+
+ // Diff preview panel (right side)
+ const [diffPreview, setDiffPreview] = useState<{
+ blockId: string;
+ originalText: string;
+ revisedText: string;
+ } | null>(null);
+
+ const coverInputRef = React.useRef(null);
+
+ const generateId = () => Math.random().toString(36).slice(2, 11);
+
+ const updateBlock = (id: string, content: string, url?: string, scale?: number) => {
+ setBlocks(prev =>
+ prev.map(b => (b.id === id ? { ...b, content, url: url ?? b.url, scale: scale ?? b.scale } : b))
+ );
+ if (content !== '/' && content !== '/') {
+ setSlashMenuBlock(null);
+ }
+ };
+
+ const toggleTodo = (id: string) => {
+ setBlocks(prev => prev.map(b => (b.id === id ? { ...b, checked: !b.checked } : b)));
+ };
+
+ const changeBlockType = (id: string, type: BlockType, newContent?: string) => {
+ setBlocks(prev =>
+ prev.map(b =>
+ b.id === id
+ ? {
+ ...b,
+ type,
+ content: newContent !== undefined
+ ? newContent
+ : b.content.endsWith('/') ? b.content.slice(0, -1) : b.content,
+ }
+ : b
+ )
+ );
+ setSlashMenuBlock(null);
+ };
+
+ const addBlock = (afterId: string, remainingContent?: string) => {
+ const index = blocks.findIndex(b => b.id === afterId);
+ const currentType = blocks[index]?.type;
+ const newType: BlockType = (currentType === 'bulletList' || currentType === 'numberedList' || currentType === 'todo')
+ ? currentType
+ : 'text';
+ const newBlock: Block = { id: generateId(), type: newType, content: remainingContent || '' };
+ setBlocks(prev => [...prev.slice(0, index + 1), newBlock, ...prev.slice(index + 1)]);
+ };
+
+ const deleteBlock = (id: string) => {
+ if (blocks.length === 1) {
+ setBlocks([{ id: blocks[0].id, type: 'text', content: '' }]);
+ return;
+ }
+ setBlocks(prev => prev.filter(b => b.id !== id));
+ };
+
+ const handleDragStart = (id: string) => setDraggedBlock(id);
+ const handleDragOver = (_id: string) => {};
+ const handleDrop = (targetId: string) => {
+ if (!draggedBlock || draggedBlock === targetId) return;
+ const dragIdx = blocks.findIndex(b => b.id === draggedBlock);
+ const targetIdx = blocks.findIndex(b => b.id === targetId);
+ const newBlocks = [...blocks];
+ const [removed] = newBlocks.splice(dragIdx, 1);
+ newBlocks.splice(targetIdx, 0, removed);
+ setBlocks(newBlocks);
+ setDraggedBlock(null);
+ };
+
+ const handleCoverUpload = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = () => setCoverImage(reader.result as string);
+ reader.readAsDataURL(file);
+ }
+ };
+
+ // Handle space key in empty block → show AI panel
+ const handleSpaceKeyEmpty = (blockId: string) => {
+ if (aiPanelBlock === blockId) {
+ // AI panel already open, close it and insert space
+ setAiPanelBlock(null);
+ updateBlock(blockId, ' ');
+ } else {
+ // Open AI panel
+ setSlashMenuBlock(null);
+ setAiPanelBlock(blockId);
+ }
+ };
+
+ // Container-level mouseup: catches selections released anywhere (inside or outside a textarea)
+ const handleEditorMouseUp = useCallback((e: React.MouseEvent) => {
+ // First try to get window selection (supports cross-block selection)
+ const selection = window.getSelection();
+ const selectedText = selection?.toString().trim();
+
+ if (selectedText && selection && selection.rangeCount > 0) {
+ const range = selection.getRangeAt(0);
+ const rect = range.getBoundingClientRect();
+
+ // Check if we have a valid position
+ if (rect.width > 0 && rect.height > 0) {
+ let blockEl = range.startContainer.parentElement?.closest('[data-block-id]');
+ const blockId = blockEl?.getAttribute('data-block-id') ?? '';
+
+ setTextSelection({
+ blockId,
+ text: selectedText,
+ position: {
+ x: Math.max(rect.left, 10),
+ y: Math.max(rect.top - 10, 10)
+ },
+ });
+ return;
+ }
+ }
+
+ // Fallback: check textarea/input selection
+ const active = document.activeElement;
+ if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
+ const start = active.selectionStart ?? 0;
+ const end = active.selectionEnd ?? 0;
+ if (end > start) {
+ const text = active.value.substring(start, end).trim();
+ if (text) {
+ const blockEl = active.closest('[data-block-id]');
+ const blockId = blockEl?.getAttribute('data-block-id') ?? '';
+ if (blockId) {
+ setTextSelection({
+ blockId,
+ text,
+ position: {
+ x: Math.max(e.clientX, 10),
+ y: Math.max(e.clientY - 60, 10)
+ },
+ });
+ }
+ }
+ }
+ }
+ }, []);
+
+ // Handle text selection in any block
+ const handleTextSelection = useCallback(
+ (blockId: string, text: string, rect: DOMRect) => {
+ setTextSelection({
+ blockId,
+ text,
+ position: { x: rect.left, y: rect.top },
+ });
+ },
+ []
+ );
+
+ // Close text selection toolbar
+ const handleCloseTextSelection = useCallback(() => {
+ setTextSelection(null);
+ }, []);
+
+ // Show diff preview on the right
+ const handleDiffResult = (originalText: string, revisedText: string) => {
+ if (!textSelection) return;
+ setDiffPreview({ blockId: textSelection.blockId, originalText, revisedText });
+ setTextSelection(null);
+ };
+
+ // Accept the revised text: replace the matching content in the block
+ const handleAcceptDiff = (blockId: string, revisedText: string) => {
+ if (!diffPreview) return;
+ setBlocks(prev =>
+ prev.map(b => {
+ if (b.id !== blockId) return b;
+ // Replace original text with revised text
+ const replaced = b.content.replace(diffPreview.originalText, revisedText);
+ return { ...b, content: replaced !== b.content ? replaced : revisedText };
+ })
+ );
+ setDiffPreview(null);
+ };
+
+ const handleRejectDiff = () => {
+ setDiffPreview(null);
+ };
+
+ // Insert AI text below target block, parsing markdown
+ const handleInsertText = (text: string, blockId: string) => {
+ const newBlocks = parseMarkdownToBlocks(text);
+ const index = blocks.findIndex(b => b.id === blockId);
+ // If the target block is empty, replace it; otherwise insert after it
+ const targetBlock = blocks[index];
+ if (targetBlock && targetBlock.content === '') {
+ setBlocks(prev => [
+ ...prev.slice(0, index),
+ ...newBlocks,
+ ...prev.slice(index + 1),
+ ]);
+ } else {
+ setBlocks(prev => [
+ ...prev.slice(0, index + 1),
+ ...newBlocks,
+ ...prev.slice(index + 1),
+ ]);
+ }
+ setSlashMenuBlock(null);
+ setAiPanelBlock(null);
+ };
+
+ // Insert text below the block containing selected text
+ const handleInsertBelow = (text: string) => {
+ if (!textSelection) {
+ // fallback: append at end
+ const newBlocks = parseMarkdownToBlocks(text);
+ setBlocks(prev => [...prev, ...newBlocks]);
+ return;
+ }
+ handleInsertText(text, textSelection.blockId);
+ setTextSelection(null);
+ };
+
+ /**
+ * Parse markdown text into Block[] — handles:
+ * - headings (# ## ###)
+ * - bullet lists (- * + )
+ * - numbered lists (1. 2. etc.) ← preserves correct ordering
+ * - code blocks (``` ```)
+ * - quotes (>)
+ * - plain text (wraps long lines correctly since we use textarea)
+ */
+ const parseMarkdownToBlocks = (text: string): Block[] => {
+ const lines = text.split('\n');
+ const newBlocks: Block[] = [];
+ let inCodeBlock = false;
+ let codeContent = '';
+ let tableLines: string[] = [];
+
+ const removeBold = (s: string) => s.replace(/\*\*(.*?)\*\*/g, '$1');
+
+ const flushTable = () => {
+ if (tableLines.length > 0) {
+ newBlocks.push({ id: generateId(), type: 'table', content: tableLines.join('\n') });
+ tableLines = [];
+ }
+ };
+
+ lines.forEach(line => {
+ if (line.startsWith('```')) {
+ flushTable();
+ if (inCodeBlock) {
+ newBlocks.push({ id: generateId(), type: 'code', content: codeContent.trim() });
+ codeContent = '';
+ inCodeBlock = false;
+ } else {
+ inCodeBlock = true;
+ }
+ } else if (inCodeBlock) {
+ codeContent += line + '\n';
+ } else if (line.trim().startsWith('|') && line.trim().endsWith('|')) {
+ tableLines.push(line);
+ } else {
+ flushTable();
+ if (line.startsWith('# ')) {
+ newBlocks.push({ id: generateId(), type: 'heading1', content: removeBold(line.slice(2)) });
+ } else if (line.startsWith('## ')) {
+ newBlocks.push({ id: generateId(), type: 'heading2', content: removeBold(line.slice(3)) });
+ } else if (line.startsWith('### ')) {
+ newBlocks.push({ id: generateId(), type: 'heading3', content: removeBold(line.slice(4)) });
+ } else if (line.match(/^[-*+]\s/)) {
+ newBlocks.push({ id: generateId(), type: 'bulletList', content: removeBold(line.slice(2)) });
+ } else if (line.match(/^\d+\.\s/)) {
+ newBlocks.push({ id: generateId(), type: 'numberedList', content: removeBold(line.replace(/^\d+\.\s+/, '')) });
+ } else if (line.startsWith('> ')) {
+ newBlocks.push({ id: generateId(), type: 'quote', content: removeBold(line.slice(2)) });
+ } else if (line.trim() === '---' || line.trim() === '***' || line.trim() === '___') {
+ newBlocks.push({ id: generateId(), type: 'divider', content: '' });
+ } else if (line.trim() === '') {
+ // skip empty lines
+ } else {
+ newBlocks.push({ id: generateId(), type: 'text', content: removeBold(line) });
+ }
+ }
+ });
+
+ flushTable();
+ if (inCodeBlock && codeContent.trim()) {
+ newBlocks.push({ id: generateId(), type: 'code', content: codeContent.trim() });
+ }
+
+ return newBlocks.length > 0 ? newBlocks : [{ id: generateId(), type: 'text', content: text }];
+ };
+
+ const getNoteContext = () => `Title: ${title}\n\n${blocksToMarkdown()}`;
+
+ const blocksToMarkdown = (): string => {
+ // Calculate numbered list indices
+ let numIdx = 0;
+ return blocks
+ .map(block => {
+ if (block.type !== 'numberedList') numIdx = 0;
+ switch (block.type) {
+ case 'heading1': return `# ${block.content}\n`;
+ case 'heading2': return `## ${block.content}\n`;
+ case 'heading3': return `### ${block.content}\n`;
+ case 'bulletList': return `- ${block.content}\n`;
+ case 'numberedList': {
+ numIdx++;
+ return `${numIdx}. ${block.content}\n`;
+ }
+ case 'todo': return `- [${block.checked ? 'x' : ' '}] ${block.content}\n`;
+ case 'quote': return `> ${block.content}\n`;
+ case 'code': return `\`\`\`\n${block.content}\n\`\`\`\n`;
+ case 'divider': return `---\n`;
+ default: return `${block.content}\n\n`;
+ }
+ })
+ .join('');
+ };
+
+ // Calculate numbered list index, only counting consecutive numberedList blocks
+ const getNumberedIndex = (blockIndex: number): number => {
+ let count = 0;
+ for (let i = blockIndex; i >= 0; i--) {
+ const t = blocks[i].type;
+ if (t === 'numberedList') {
+ count++;
+ } else if (t === 'text' || t.startsWith('heading')) {
+ // Stop at text or heading blocks
+ break;
+ }
+ // bulletList, todo, quote etc. don't break numbering
+ }
+ return count;
+ };
+
+ const handleSave = async () => {
+ if (!notebook?.id) return;
+ setSaving(true);
+ try {
+ const content = blocksToMarkdown();
+ const mdContent = coverImage
+ ? `\n\n# ${title}\n\n${content}`
+ : `# ${title}\n\n${content}`;
+ const blob = new Blob([mdContent], { type: 'text/markdown' });
+ const file = new File([blob], `${title}.md`, { type: 'text/markdown' });
+
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('email', user?.email || user?.id || 'default');
+ formData.append('user_id', user?.id || 'default');
+ formData.append('notebook_id', notebook.id);
+ formData.append('notebook_title', notebook?.title || notebook?.name || '');
+
+ const res = await apiFetch('/api/v1/kb/upload', { method: 'POST', body: formData });
+ if (!res.ok) throw new Error('Save failed');
+
+ alert('Note saved to knowledge base!');
+ onSaved?.();
+ onClose();
+ } catch {
+ alert('Failed to save note');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleExport = () => {
+ const content = blocksToMarkdown();
+ const mdContent = coverImage
+ ? `\n\n# ${title}\n\n${content}`
+ : `# ${title}\n\n${content}`;
+ const blob = new Blob([mdContent], { type: 'text/markdown' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${title}.md`;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ return (
+
+ {/* Toolbar */}
+
+
+ coverInputRef.current?.click()}
+ className="p-2 hover:bg-gray-100 rounded group relative"
+ title="Add Cover"
+ >
+
+
+ Click to add cover image
+
+
+
+
+ setIsFullScreen(!isFullScreen)}
+ className="p-2 hover:bg-gray-100 rounded"
+ title={isFullScreen ? 'Exit Full Screen' : 'Full Screen'}
+ >
+ {isFullScreen ? : }
+
+
+ Export
+
+
+ {saving ? 'Saving...' : 'Save'}
+
+
+
+
+
+
+
+
+
+ {/* Cover Image */}
+ {coverImage && (
+
+
+
setCoverImage(null)}
+ className="absolute top-2 right-2 bg-red-500 text-white px-2 py-1 rounded text-xs"
+ >
+ Remove
+
+
+ )}
+
+ {/* Main body: editing area + optional diff panel */}
+
+ {/* Left: editing area */}
+
+
setTitle(e.target.value)}
+ className="text-4xl font-bold outline-none w-full mb-4 bg-transparent"
+ placeholder="Untitled"
+ />
+
+ {blocks.map((block, blockIndex) => (
+
+
{
+ setAiPanelBlock(null);
+ setSlashMenuBlock(id);
+ }}
+ onSpaceKeyEmpty={handleSpaceKeyEmpty}
+ onTextSelection={handleTextSelection}
+ showSlashMenu={slashMenuBlock === block.id}
+ onDragStart={handleDragStart}
+ onDragOver={handleDragOver}
+ onDrop={handleDrop}
+ onToggleTodo={toggleTodo}
+ numberedIndex={
+ block.type === 'numberedList' ? getNumberedIndex(blockIndex) : undefined
+ }
+ />
+
+ {/* Slash menu */}
+ {slashMenuBlock === block.id && (
+ changeBlockType(block.id, type)}
+ onClose={() => setSlashMenuBlock(null)}
+ onInsertText={text => handleInsertText(text, block.id)}
+ noteContext={getNoteContext()}
+ user={user}
+ />
+ )}
+
+ {/* AI panel (space triggered) */}
+ {aiPanelBlock === block.id && (
+ setAiPanelBlock(null)}
+ onUpdateMemory={setNoteMemory}
+ />
+ )}
+
+ ))}
+
+
+ {/* Right: diff preview panel */}
+ {diffPreview && (
+
+ )}
+
+
+ {/* Floating text selection toolbar (fixed positioning) */}
+ {textSelection && (
+
+ )}
+
+ );
+};
diff --git a/frontend_en/src/components/notes/SlashMenu.tsx b/frontend_en/src/components/notes/SlashMenu.tsx
new file mode 100644
index 0000000..3c1feac
--- /dev/null
+++ b/frontend_en/src/components/notes/SlashMenu.tsx
@@ -0,0 +1,78 @@
+import React, { useState, useEffect } from 'react';
+import { BlockType } from './types';
+import {
+ Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare,
+ Quote, Code, Minus, Image, FileSpreadsheet, Video, Type
+} from 'lucide-react';
+
+interface SlashMenuProps {
+ onSelect: (type: BlockType) => void;
+ onClose: () => void;
+ // kept for API compatibility but unused here
+ onInsertText?: (text: string) => void;
+ noteContext?: string;
+ user?: any;
+}
+
+const commands = [
+ { type: 'text' as BlockType, label: 'Text', icon: Type, desc: 'Plain text' },
+ { type: 'heading1' as BlockType, label: 'Heading 1', icon: Heading1, desc: 'Big section heading' },
+ { type: 'heading2' as BlockType, label: 'Heading 2', icon: Heading2, desc: 'Medium section heading' },
+ { type: 'heading3' as BlockType, label: 'Heading 3', icon: Heading3, desc: 'Small section heading' },
+ { type: 'bulletList' as BlockType, label: 'Bullet List', icon: List, desc: 'Create a simple list' },
+ { type: 'numberedList' as BlockType, label: 'Numbered List', icon: ListOrdered, desc: 'Create a numbered list' },
+ { type: 'todo' as BlockType, label: 'To-do', icon: CheckSquare, desc: 'Track tasks with a checkbox' },
+ { type: 'quote' as BlockType, label: 'Quote', icon: Quote, desc: 'Capture a quote' },
+ { type: 'code' as BlockType, label: 'Code', icon: Code, desc: 'Capture a code snippet' },
+ { type: 'divider' as BlockType, label: 'Divider', icon: Minus, desc: 'Visually divide blocks' },
+ { type: 'image' as BlockType, label: 'Image', icon: Image, desc: 'Embed an image' },
+ { type: 'excel' as BlockType, label: 'Excel', icon: FileSpreadsheet, desc: 'Embed an Excel file' },
+ { type: 'video' as BlockType, label: 'Video', icon: Video, desc: 'Embed a video' },
+];
+
+export const SlashMenu: React.FC = ({ onSelect, onClose }) => {
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setSelectedIndex(prev => (prev + 1) % commands.length);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setSelectedIndex(prev => (prev - 1 + commands.length) % commands.length);
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ onSelect(commands[selectedIndex].type);
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ onClose();
+ }
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [selectedIndex, onSelect, onClose]);
+
+ return (
+
+
+ Block types
+
+ {commands.map((cmd, idx) => (
+
onSelect(cmd.type)}
+ className={`w-full px-3 py-2 text-left flex items-start gap-3 transition-colors ${
+ idx === selectedIndex ? 'bg-blue-50' : 'hover:bg-gray-50'
+ }`}
+ >
+
+
+
{cmd.label}
+
{cmd.desc}
+
+
+ ))}
+
+ );
+};
diff --git a/frontend_en/src/components/notes/TextSelectionToolbar.tsx b/frontend_en/src/components/notes/TextSelectionToolbar.tsx
new file mode 100644
index 0000000..921f387
--- /dev/null
+++ b/frontend_en/src/components/notes/TextSelectionToolbar.tsx
@@ -0,0 +1,350 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { Sparkles, BookOpen, AlignLeft, ChevronDown, Send, Copy, X } from 'lucide-react';
+import { apiFetch } from '../../config/api';
+import { getApiSettings } from '../../services/apiSettingsService';
+import type { KnowledgeFile } from '../../types';
+
+interface TextSelectionToolbarProps {
+ selectedText: string;
+ position: { x: number; y: number };
+ files: KnowledgeFile[];
+ user: any;
+ noteMemory: string;
+ noteContext: string;
+ onDiffResult: (originalText: string, revisedText: string) => void;
+ onInsertBelow: (text: string) => void;
+ onUpdateMemory: (memory: string) => void;
+ onClose: () => void;
+}
+
+const POLISH_STYLES = [
+ { label: 'Formal', prompt: 'Rewrite the following text in a formal, professional tone. Return only the rewritten text:' },
+ { label: 'Casual', prompt: 'Rewrite the following text in a friendly, casual conversational tone. Return only the rewritten text:' },
+ { label: 'Concise', prompt: 'Rewrite the following text more concisely, keeping only the essential information. Return only the rewritten text:' },
+ { label: 'Academic', prompt: 'Rewrite the following text in a scholarly, academic writing style. Return only the rewritten text:' },
+ { label: 'Creative', prompt: 'Rewrite the following text in a more engaging, vivid, and creative style. Return only the rewritten text:' },
+];
+
+const ORGANIZE_PRESETS = [
+ { label: 'Summarize key points', prompt: 'Summarize the key points of the following text into a structured list:' },
+ { label: 'Organize by theme', prompt: 'Organize the following content by main themes with clear section headings:' },
+ { label: 'Create bullet points', prompt: 'Convert the following text into a clear, structured bullet-point list:' },
+ { label: 'Create an outline', prompt: 'Create a structured hierarchical outline from the following content:' },
+ { label: 'Expand and elaborate', prompt: 'Expand on the following text with more detail, examples, and depth:' },
+];
+
+type ToolbarMode = 'toolbar' | 'polish' | 'understand' | 'organize' | 'result-understand' | 'result-organize';
+
+export const TextSelectionToolbar: React.FC = ({
+ selectedText,
+ position,
+ files,
+ user,
+ noteMemory,
+ noteContext,
+ onDiffResult,
+ onInsertBelow,
+ onUpdateMemory,
+ onClose,
+}) => {
+ const [mode, setMode] = useState('toolbar');
+ const [selectedFileIds, setSelectedFileIds] = useState>(new Set());
+ const [customPrompt, setCustomPrompt] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [resultText, setResultText] = useState('');
+ const toolbarRef = useRef(null);
+
+ // Close on outside click
+ useEffect(() => {
+ const handleMouseDown = (e: MouseEvent) => {
+ if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) {
+ onClose();
+ }
+ };
+ setTimeout(() => document.addEventListener('mousedown', handleMouseDown), 100);
+ return () => document.removeEventListener('mousedown', handleMouseDown);
+ }, [onClose]);
+
+ const callAI = async (instruction: string): Promise => {
+ const apiSettings = getApiSettings(user?.id || null);
+ const selectedFilePaths = files
+ .filter(f => selectedFileIds.has(f.id))
+ .map(f => f.url)
+ .filter((url): url is string => Boolean(url));
+
+ const systemContext = noteMemory
+ ? `You are a helpful writing assistant.\n\nContext:\n${noteMemory}`
+ : `You are a helpful writing assistant.`;
+
+ const prompt = `${systemContext}\n\n${instruction}\n\n"${selectedText}"\n\nReturn only the processed result without any explanation or preamble.`;
+
+ const res = await apiFetch('/api/v1/kb/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ files: selectedFilePaths,
+ query: prompt,
+ history: [],
+ api_key: apiSettings?.apiKey?.trim() || undefined,
+ api_url: apiSettings?.apiUrl?.trim() || undefined,
+ }),
+ });
+ const data = await res.json();
+ return data.answer || data.response || 'No response';
+ };
+
+ const handlePolish = async (stylePrompt: string, styleName: string) => {
+ setLoading(true);
+ try {
+ const result = await callAI(stylePrompt);
+ onDiffResult(selectedText, result);
+ // Update memory
+ const entry = `- Polish (${styleName}): "${selectedText.slice(0, 60)}..." → "${result.slice(0, 60)}..."`;
+ onUpdateMemory(noteMemory ? `${noteMemory}\n${entry}`.slice(-2000) : `## Operations\n${entry}`);
+ onClose();
+ } catch {
+ setLoading(false);
+ }
+ };
+
+ const handleUnderstand = async () => {
+ setMode('understand');
+ setLoading(true);
+ try {
+ const result = await callAI(
+ 'Please explain and analyze the following text clearly and insightfully. What does it mean? What are the key ideas and implications?'
+ );
+ setResultText(result);
+ setMode('result-understand');
+ const entry = `- Understand: "${selectedText.slice(0, 60)}..."`;
+ onUpdateMemory(noteMemory ? `${noteMemory}\n${entry}`.slice(-2000) : `## Operations\n${entry}`);
+ } catch {
+ setResultText('Failed to get AI response.');
+ setMode('result-understand');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleOrganize = async (prompt: string) => {
+ setLoading(true);
+ try {
+ const result = await callAI(prompt);
+ setResultText(result);
+ setMode('result-organize');
+ const entry = `- Organize: "${selectedText.slice(0, 60)}..."`;
+ onUpdateMemory(noteMemory ? `${noteMemory}\n${entry}`.slice(-2000) : `## Operations\n${entry}`);
+ } catch {
+ setResultText('Failed to get AI response.');
+ setMode('result-organize');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const toggleFile = (fileId: string) => {
+ setSelectedFileIds(prev => {
+ const next = new Set(prev);
+ if (next.has(fileId)) next.delete(fileId);
+ else next.add(fileId);
+ return next;
+ });
+ };
+
+ // Clamp position so toolbar stays on screen
+ const left = Math.min(Math.max(position.x, 8), window.innerWidth - 320);
+ const top = Math.max(position.y - 48, 8);
+
+ const SourceSelector = () =>
+ files.length > 0 ? (
+
+ Sources (optional):
+ {files.map(f => (
+ toggleFile(f.id)}
+ className={`px-2 py-0.5 text-xs rounded-full border transition-all ${
+ selectedFileIds.has(f.id)
+ ? 'bg-blue-500 text-white border-blue-500'
+ : 'bg-white text-gray-500 border-gray-200 hover:border-blue-300'
+ }`}
+ >
+ {f.name || f.id}
+
+ ))}
+
+ ) : null;
+
+ return (
+
+ {/* Header */}
+ {mode !== 'toolbar' && (
+
+ setMode('toolbar')}
+ className="text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1"
+ >
+ ← Back
+
+
+
+
+
+ )}
+
+ {/* Main toolbar */}
+ {mode === 'toolbar' && (
+
+
setMode('polish')}
+ className="flex items-center gap-1.5 px-3 py-2 text-sm hover:bg-purple-50 text-purple-600 rounded-lg font-medium transition-colors"
+ >
+ Polish
+
+
+
+ Understand
+
+
+
setMode('organize')}
+ className="flex items-center gap-1.5 px-3 py-2 text-sm hover:bg-green-50 text-green-600 rounded-lg font-medium transition-colors"
+ >
+ Organize
+
+
+
+
+
+
+ )}
+
+ {/* Polish style selection */}
+ {mode === 'polish' && (
+
+
+
+
+ Choose polish style
+
+ {POLISH_STYLES.map(s => (
+
handlePolish(s.prompt, s.label)}
+ disabled={loading}
+ className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-purple-50 text-purple-700 rounded-lg disabled:opacity-50 transition-colors"
+ >
+ {loading ? (
+
+ ) : (
+
+ )}
+ {s.label}
+
+ ))}
+
+
+ )}
+
+ {/* Organize options */}
+ {mode === 'organize' && (
+
+
+
+
+ Organize as
+
+ {ORGANIZE_PRESETS.map(p => (
+
handleOrganize(p.prompt)}
+ disabled={loading}
+ className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-green-50 text-green-700 rounded-lg disabled:opacity-50 transition-colors"
+ >
+ {loading ? (
+
+ ) : (
+
+ )}
+ {p.label}
+
+ ))}
+
+ setCustomPrompt(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && customPrompt.trim() && handleOrganize(customPrompt)}
+ placeholder="Custom instruction..."
+ className="flex-1 px-2.5 py-1.5 text-xs border border-gray-200 rounded-lg outline-none focus:border-green-400"
+ />
+ customPrompt.trim() && handleOrganize(customPrompt)}
+ disabled={loading || !customPrompt.trim()}
+ className="px-2.5 py-1.5 bg-green-500 text-white rounded-lg text-xs disabled:opacity-40 hover:bg-green-600 transition-colors"
+ >
+
+
+
+
+
+ )}
+
+ {/* Loading state for understand */}
+ {mode === 'understand' && loading && (
+
+ )}
+
+ {/* Result: Understand */}
+ {mode === 'result-understand' && (
+
+
+ AI Understanding
+
+
+ {resultText}
+
+
{
+ onInsertBelow(resultText);
+ onClose();
+ }}
+ className="mt-2.5 px-3 py-1.5 bg-green-500 text-white rounded-lg text-xs hover:bg-green-600 flex items-center gap-1 transition-colors"
+ >
+ Paste to note
+
+
+ )}
+
+ {/* Result: Organize */}
+ {mode === 'result-organize' && (
+
+
+
+ {resultText}
+
+
{
+ onInsertBelow(resultText);
+ onClose();
+ }}
+ className="mt-2.5 px-3 py-1.5 bg-green-500 text-white rounded-lg text-xs hover:bg-green-600 flex items-center gap-1 transition-colors"
+ >
+ Paste to note
+
+
+ )}
+
+ );
+};
diff --git a/frontend_en/src/components/notes/types.ts b/frontend_en/src/components/notes/types.ts
new file mode 100644
index 0000000..3481bed
--- /dev/null
+++ b/frontend_en/src/components/notes/types.ts
@@ -0,0 +1,33 @@
+export type BlockType =
+ | 'text'
+ | 'heading1'
+ | 'heading2'
+ | 'heading3'
+ | 'bulletList'
+ | 'numberedList'
+ | 'todo'
+ | 'quote'
+ | 'code'
+ | 'divider'
+ | 'table'
+ | 'image'
+ | 'excel'
+ | 'video';
+
+export interface Block {
+ id: string;
+ type: BlockType;
+ content: string;
+ checked?: boolean;
+ url?: string;
+ scale?: number;
+}
+
+export interface NoteDocument {
+ id: string;
+ title: string;
+ coverImage?: string;
+ blocks: Block[];
+ createdAt: string;
+ updatedAt: string;
+}
diff --git a/frontend_en/src/components/quiz/QuizContainer.tsx b/frontend_en/src/components/quiz/QuizContainer.tsx
index cd1d5f2..92ee5b6 100644
--- a/frontend_en/src/components/quiz/QuizContainer.tsx
+++ b/frontend_en/src/components/quiz/QuizContainer.tsx
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
import { QuizQuestion } from './QuizQuestion';
import { QuizResults } from './QuizResults';
import { QuizReview } from './QuizReview';
@@ -25,6 +26,8 @@ interface QuizContainerProps {
type QuizState = 'taking' | 'results' | 'review';
+const springTransition = { type: 'spring', stiffness: 300, damping: 30 };
+
export const QuizContainer: React.FC = ({
questions,
onClose,
@@ -32,9 +35,11 @@ export const QuizContainer: React.FC = ({
const [currentIndex, setCurrentIndex] = useState(0);
const [userAnswers, setUserAnswers] = useState>({});
const [quizState, setQuizState] = useState('taking');
+ const [direction, setDirection] = useState(1);
const currentQuestion = questions[currentIndex];
const currentAnswer = userAnswers[currentQuestion?.id] || null;
+ const progress = ((currentIndex + 1) / questions.length) * 100;
const handleSelectAnswer = (answer: string) => {
setUserAnswers({
@@ -53,15 +58,16 @@ export const QuizContainer: React.FC = ({
const handleNext = () => {
if (currentIndex < questions.length - 1) {
+ setDirection(1);
setCurrentIndex(currentIndex + 1);
} else {
- // 最后一题,显示结果
setQuizState('results');
}
};
const handlePrevious = () => {
if (currentIndex > 0) {
+ setDirection(-1);
setCurrentIndex(currentIndex - 1);
}
};
@@ -76,7 +82,6 @@ export const QuizContainer: React.FC = ({
setQuizState('review');
};
- // 计算统计数据
const calculateStats = () => {
let correct = 0;
let wrong = 0;
@@ -96,7 +101,6 @@ export const QuizContainer: React.FC = ({
return { correct, wrong, skipped };
};
- // 结果页面
if (quizState === 'results') {
const stats = calculateStats();
return (
@@ -111,7 +115,6 @@ export const QuizContainer: React.FC = ({
);
}
- // 复习页面
if (quizState === 'review') {
return (
= ({
);
}
- // 答题页面
return (
- {/* Progress */}
+ {/* iOS Progress Bar */}
-
+
Question {currentIndex + 1} of {questions.length}
-
- {/* Question */}
-
-
-
+ {/* Question with spring transition */}
+
+ 0 ? 30 : -30, opacity: 0 }}
+ animate={{ x: 0, opacity: 1 }}
+ exit={{ x: direction > 0 ? -30 : 30, opacity: 0 }}
+ transition={springTransition}
+ className="bg-white border border-ios-gray-100 rounded-ios-lg p-6 mb-6 shadow-ios-sm"
+ >
+
+
+
{/* Navigation */}
-
Previous
-
+
-
Skip
-
+
-
{currentIndex === questions.length - 1 ? 'Finish' : 'Next'}
-
+
);
diff --git a/frontend_en/src/components/quiz/QuizQuestion.tsx b/frontend_en/src/components/quiz/QuizQuestion.tsx
index ad3ec2f..8bb1d11 100644
--- a/frontend_en/src/components/quiz/QuizQuestion.tsx
+++ b/frontend_en/src/components/quiz/QuizQuestion.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { CheckCircle, XCircle, Circle } from 'lucide-react';
+import { motion } from 'framer-motion';
interface QuizOption {
label: string;
@@ -27,61 +27,88 @@ export const QuizQuestion: React.FC
= ({
}) => {
const getOptionStyle = (optionLabel: string) => {
if (!showResult) {
- // 答题模式
if (selectedAnswer === optionLabel) {
- return 'border-blue-500 bg-blue-50';
+ return 'border-primary bg-primary/5 shadow-ios-sm';
}
- return 'border-gray-300 hover:border-blue-400 hover:bg-gray-50';
+ return 'border-ios-gray-200 hover:border-primary/40 hover:bg-ios-gray-50';
} else {
- // 结果展示模式
if (optionLabel === correctAnswer) {
- return 'border-green-500 bg-green-50';
+ return 'border-green-500 bg-green-50 shadow-ios-sm';
}
if (selectedAnswer === optionLabel && !isCorrect) {
return 'border-red-500 bg-red-50';
}
- return 'border-gray-300 bg-gray-50';
+ return 'border-ios-gray-200 bg-ios-gray-50';
}
};
- const getOptionIcon = (optionLabel: string) => {
+ const getRadioStyle = (optionLabel: string) => {
if (!showResult) {
- return selectedAnswer === optionLabel ? (
-
- ) : (
-
- );
+ if (selectedAnswer === optionLabel) {
+ return (
+
+
+
+
+
+ );
+ }
+ return
;
} else {
if (optionLabel === correctAnswer) {
- return ;
+ return (
+
+
+
+
+
+ );
}
if (selectedAnswer === optionLabel && !isCorrect) {
- return ;
+ return (
+
+ );
}
- return ;
+ return
;
}
};
return (
-
{question}
+
{question}
{options.map((option) => (
-
!showResult && onSelectAnswer(option.label)}
disabled={showResult}
- className={`w-full p-4 rounded-lg border-2 transition-all text-left flex items-start gap-3 ${getOptionStyle(option.label)} ${
+ className={`w-full p-4 rounded-ios-lg border-2 transition-all text-left flex items-start gap-3 ${getOptionStyle(option.label)} ${
showResult ? 'cursor-default' : 'cursor-pointer'
}`}
>
- {getOptionIcon(option.label)}
+ {getRadioStyle(option.label)}
- {option.label}. {' '}
- {option.text}
+ {option.label}. {' '}
+ {option.text}
-
+
))}
diff --git a/frontend_en/src/components/quiz/QuizResults.tsx b/frontend_en/src/components/quiz/QuizResults.tsx
index 3c8ce08..c4e6ec1 100644
--- a/frontend_en/src/components/quiz/QuizResults.tsx
+++ b/frontend_en/src/components/quiz/QuizResults.tsx
@@ -1,5 +1,6 @@
import React from 'react';
-import { CheckCircle, XCircle, SkipForward, RotateCcw, Eye } from 'lucide-react';
+import { motion } from 'framer-motion';
+import { RotateCcw, Eye } from 'lucide-react';
interface QuizResultsProps {
totalQuestions: number;
@@ -21,101 +22,133 @@ export const QuizResults: React.FC = ({
const percentage = Math.round((correctCount / totalQuestions) * 100);
const getScoreColor = () => {
+ if (percentage >= 80) return '#34C759';
+ if (percentage >= 60) return '#FF9500';
+ return '#FF3B30';
+ };
+
+ const getScoreTextColor = () => {
if (percentage >= 80) return 'text-green-600';
- if (percentage >= 60) return 'text-yellow-600';
- return 'text-red-600';
+ if (percentage >= 60) return 'text-orange-500';
+ return 'text-red-500';
};
const getScoreMessage = () => {
- if (percentage >= 80) return 'Excellent! 🎉';
- if (percentage >= 60) return 'Good job! 👍';
- return 'Keep practicing! 💪';
+ if (percentage >= 80) return 'Excellent!';
+ if (percentage >= 60) return 'Good job!';
+ return 'Keep practicing!';
};
+ const circumference = 2 * Math.PI * 88;
+
return (
{/* Score Display */}
-
Quiz Complete!
-
{getScoreMessage()}
+
+ Quiz Complete!
+
+
+ {getScoreMessage()}
+
{/* Score Circle */}
-
+
- = 80 ? '#10b981' : percentage >= 60 ? '#f59e0b' : '#ef4444'}
- strokeWidth="12"
+ stroke={getScoreColor()}
+ strokeWidth="10"
fill="none"
- strokeDasharray={`${2 * Math.PI * 88}`}
- strokeDashoffset={`${2 * Math.PI * 88 * (1 - percentage / 100)}`}
strokeLinecap="round"
- className="transition-all duration-1000"
+ initial={{ strokeDasharray: circumference, strokeDashoffset: circumference }}
+ animate={{ strokeDashoffset: circumference * (1 - percentage / 100) }}
+ transition={{ type: 'spring', stiffness: 60, damping: 15, delay: 0.3 }}
/>
-
+
{percentage}%
-
-
+
+
{correctCount}/{totalQuestions}
-
+
{/* Statistics */}
-
-
-
-
{correctCount}
-
Correct
-
-
-
-
-
{wrongCount}
-
Wrong
-
-
-
-
-
{skippedCount}
-
Skipped
-
+
+ {[
+ { label: 'Correct', count: correctCount, color: 'text-green-600', bg: 'bg-green-50', border: 'border-green-100' },
+ { label: 'Wrong', count: wrongCount, color: 'text-red-500', bg: 'bg-red-50', border: 'border-red-100' },
+ { label: 'Skipped', count: skippedCount, color: 'text-ios-gray-500', bg: 'bg-ios-gray-50', border: 'border-ios-gray-100' },
+ ].map((stat, idx) => (
+
+ {stat.count}
+ {stat.label}
+
+ ))}
{/* Action Buttons */}
-
-
+
Review Quiz
-
+
-
Retake Quiz
-
+
);
diff --git a/frontend_en/src/components/ui/Badge.tsx b/frontend_en/src/components/ui/Badge.tsx
new file mode 100644
index 0000000..fecccd7
--- /dev/null
+++ b/frontend_en/src/components/ui/Badge.tsx
@@ -0,0 +1,131 @@
+import React from 'react';
+
+type BadgeVariant = 'default' | 'accent' | 'success' | 'warning' | 'error' | 'neutral';
+type BadgeSize = 'sm' | 'md' | 'lg';
+
+interface BadgeProps {
+ children: React.ReactNode;
+ variant?: BadgeVariant;
+ size?: BadgeSize;
+ className?: string;
+ icon?: React.ReactNode;
+ removable?: boolean;
+ onRemove?: () => void;
+}
+
+/**
+ * Editorial Workspace Badge Component
+ *
+ * Features:
+ * - Multiple variants for different semantic meanings
+ * - Clean, minimal pill-shaped design
+ * - Optional icon support
+ * - Optional removable (close button)
+ * - Warm editorial color palette
+ * - Proper accessibility (aria-label for remove button)
+ */
+export const Badge: React.FC
= ({
+ children,
+ variant = 'default',
+ size = 'md',
+ className = '',
+ icon,
+ removable = false,
+ onRemove,
+}) => {
+ // Base styles
+ const baseStyles =
+ 'inline-flex items-center font-sans font-medium rounded-full transition-colors';
+
+ // Variant styles
+ const variantStyles: Record = {
+ default: 'bg-neutral-100 text-neutral-700 border border-neutral-200',
+ accent: 'bg-accent-100 text-accent-700 border border-accent-200',
+ success: 'bg-success-50 text-success-600 border border-success-600/20',
+ warning: 'bg-warning-50 text-warning-600 border border-warning-600/20',
+ error: 'bg-error-50 text-error-600 border border-error-600/20',
+ neutral: 'bg-neutral-800 text-white',
+ };
+
+ // Size styles
+ const sizeStyles: Record = {
+ sm: 'px-2 py-0.5 text-xs gap-1',
+ md: 'px-2.5 py-1 text-sm gap-1.5',
+ lg: 'px-3 py-1.5 text-base gap-2',
+ };
+
+ // Icon size based on badge size
+ const iconSizeMap = {
+ sm: 12,
+ md: 14,
+ lg: 16,
+ };
+
+ // Combine all styles
+ const combinedStyles = `${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`;
+
+ return (
+
+ {icon && (
+
+ {icon}
+
+ )}
+ {children}
+ {removable && onRemove && (
+ {
+ e.stopPropagation();
+ onRemove();
+ }}
+ className="shrink-0 ml-0.5 rounded-full hover:bg-black/10 transition-colors"
+ aria-label="移除标签"
+ style={{ padding: 2 }}
+ >
+
+
+
+
+ )}
+
+ );
+};
+
+Badge.displayName = 'Badge';
+
+/**
+ * Badge Group Component
+ * For displaying multiple badges with consistent spacing
+ */
+interface BadgeGroupProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const BadgeGroup: React.FC = ({
+ children,
+ className = '',
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+BadgeGroup.displayName = 'BadgeGroup';
diff --git a/frontend_en/src/components/ui/Button.tsx b/frontend_en/src/components/ui/Button.tsx
new file mode 100644
index 0000000..4006c4e
--- /dev/null
+++ b/frontend_en/src/components/ui/Button.tsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import { motion, HTMLMotionProps } from 'framer-motion';
+
+type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'accent';
+type ButtonSize = 'sm' | 'md' | 'lg';
+
+interface ButtonProps extends Omit, 'size'> {
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ children: React.ReactNode;
+ className?: string;
+}
+
+/**
+ * Editorial Workspace Button Component
+ *
+ * Features:
+ * - Lifted shadow effect (signature editorial style)
+ * - Warm neutral palette with coral accent
+ * - Refined typography and spacing
+ * - Smooth hover/tap animations
+ * - Accessible focus states
+ */
+export const Button = React.forwardRef(
+ ({ variant = 'primary', size = 'md', children, className = '', disabled, ...props }, ref) => {
+ // Base styles - shared across all variants
+ const baseStyles = 'inline-flex items-center justify-center font-sans font-medium tracking-tight transition-all duration-200 focus-editorial disabled:opacity-40 disabled:cursor-not-allowed disabled:shadow-none';
+
+ // Variant styles
+ const variantStyles: Record = {
+ primary: 'bg-neutral-900 text-white hover:bg-neutral-800 lifted-hover',
+ secondary: 'bg-neutral-100 text-neutral-900 hover:bg-neutral-200 border border-neutral-200',
+ ghost: 'bg-transparent text-neutral-700 hover:bg-neutral-50 border border-neutral-300 hover:border-neutral-400',
+ accent: 'bg-accent-500 text-white hover:bg-accent-600 lifted-hover',
+ };
+
+ // Size styles
+ const sizeStyles: Record = {
+ sm: 'px-3 py-1.5 text-sm rounded-md gap-1.5',
+ md: 'px-4 py-2.5 text-base rounded-lg gap-2',
+ lg: 'px-6 py-3 text-lg rounded-lg gap-2.5',
+ };
+
+ // Combine all styles
+ const combinedStyles = `${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`;
+
+ return (
+
+ {children}
+
+ );
+ }
+);
+
+Button.displayName = 'Button';
diff --git a/frontend_en/src/components/ui/Card.tsx b/frontend_en/src/components/ui/Card.tsx
new file mode 100644
index 0000000..f23226c
--- /dev/null
+++ b/frontend_en/src/components/ui/Card.tsx
@@ -0,0 +1,159 @@
+import React from 'react';
+import { motion, HTMLMotionProps } from 'framer-motion';
+
+interface CardProps extends HTMLMotionProps<"div"> {
+ children: React.ReactNode;
+ variant?: 'default' | 'elevated' | 'outlined';
+ padding?: 'none' | 'sm' | 'md' | 'lg';
+ className?: string;
+ interactive?: boolean;
+}
+
+/**
+ * Editorial Workspace Card Component
+ *
+ * Features:
+ * - Clean, minimal container styling
+ * - Lifted shadow effect for editorial feel
+ * - Multiple variants (default, elevated, outlined)
+ * - Flexible padding options
+ * - Optional interactive state (hover/tap animations)
+ * - Warm neutral backgrounds
+ */
+export const Card = React.forwardRef(
+ (
+ {
+ children,
+ variant = 'default',
+ padding = 'md',
+ className = '',
+ interactive = false,
+ ...props
+ },
+ ref
+ ) => {
+ // Base styles
+ const baseStyles = 'bg-white rounded-xl transition-all duration-200';
+
+ // Variant styles
+ const variantStyles = {
+ default: 'border border-neutral-200',
+ elevated: 'lifted',
+ outlined: 'border-2 border-neutral-300',
+ };
+
+ // Padding styles
+ const paddingStyles = {
+ none: '',
+ sm: 'p-3',
+ md: 'p-4',
+ lg: 'p-6',
+ };
+
+ // Interactive styles
+ const interactiveStyles = interactive
+ ? 'cursor-pointer hover:border-neutral-300 hover:shadow-md'
+ : '';
+
+ // Combine all styles
+ const combinedStyles = `${baseStyles} ${variantStyles[variant]} ${paddingStyles[padding]} ${interactiveStyles} ${className}`;
+
+ if (interactive) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+ }
+);
+
+Card.displayName = 'Card';
+
+/**
+ * Card Header Component
+ * For consistent card header styling
+ */
+interface CardHeaderProps {
+ title: string;
+ subtitle?: string;
+ action?: React.ReactNode;
+ className?: string;
+}
+
+export const CardHeader: React.FC = ({
+ title,
+ subtitle,
+ action,
+ className = '',
+}) => {
+ return (
+
+
+
+ {title}
+
+ {subtitle && (
+
{subtitle}
+ )}
+
+ {action &&
{action}
}
+
+ );
+};
+
+CardHeader.displayName = 'CardHeader';
+
+/**
+ * Card Content Component
+ * For consistent card content spacing
+ */
+interface CardContentProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const CardContent: React.FC = ({
+ children,
+ className = '',
+}) => {
+ return {children}
;
+};
+
+CardContent.displayName = 'CardContent';
+
+/**
+ * Card Footer Component
+ * For consistent card footer actions
+ */
+interface CardFooterProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const CardFooter: React.FC = ({
+ children,
+ className = '',
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+CardFooter.displayName = 'CardFooter';
diff --git a/frontend_en/src/components/ui/Input.tsx b/frontend_en/src/components/ui/Input.tsx
new file mode 100644
index 0000000..0979db5
--- /dev/null
+++ b/frontend_en/src/components/ui/Input.tsx
@@ -0,0 +1,101 @@
+import React from 'react';
+
+interface InputProps extends React.InputHTMLAttributes {
+ label?: string;
+ error?: string;
+ helperText?: string;
+ leftIcon?: React.ReactNode;
+ rightIcon?: React.ReactNode;
+}
+
+/**
+ * Editorial Workspace Input Component
+ *
+ * Features:
+ * - Clean, minimal styling with editorial color palette
+ * - Optional label, error, and helper text
+ * - Icon support (left/right)
+ * - Accessible focus states with accent ring
+ * - Proper touch target sizing (44px height)
+ */
+export const Input = React.forwardRef(
+ ({ label, error, helperText, leftIcon, rightIcon, className = '', ...props }, ref) => {
+ const inputId = props.id || `input-${Math.random().toString(36).substr(2, 9)}`;
+
+ return (
+
+ {/* Label */}
+ {label && (
+
+ {label}
+
+ )}
+
+ {/* Input container */}
+
+ {/* Left icon */}
+ {leftIcon && (
+
+ {leftIcon}
+
+ )}
+
+ {/* Input field */}
+
+
+ {/* Right icon */}
+ {rightIcon && (
+
+ {rightIcon}
+
+ )}
+
+
+ {/* Error message */}
+ {error && (
+
+
+
+
+ {error}
+
+ )}
+
+ {/* Helper text */}
+ {!error && helperText && (
+
{helperText}
+ )}
+
+ );
+ }
+);
+
+Input.displayName = 'Input';
diff --git a/frontend_en/src/components/ui/Modal.tsx b/frontend_en/src/components/ui/Modal.tsx
new file mode 100644
index 0000000..fbf1c95
--- /dev/null
+++ b/frontend_en/src/components/ui/Modal.tsx
@@ -0,0 +1,167 @@
+import React, { useEffect } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { X } from 'lucide-react';
+
+interface ModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ title?: string;
+ subtitle?: string;
+ children: React.ReactNode;
+ size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
+ showCloseButton?: boolean;
+ closeOnOverlayClick?: boolean;
+ closeOnEscape?: boolean;
+}
+
+/**
+ * Editorial Workspace Modal Component
+ *
+ * Features:
+ * - Smooth fade-in/scale-in animations
+ * - Multiple size options
+ * - Warm neutral backdrop with proper contrast
+ * - Editorial typography for title/subtitle
+ * - Keyboard accessibility (ESC to close, focus trap)
+ * - Close on overlay click (optional)
+ * - Lifted shadow effect
+ */
+export const Modal: React.FC = ({
+ isOpen,
+ onClose,
+ title,
+ subtitle,
+ children,
+ size = 'md',
+ showCloseButton = true,
+ closeOnOverlayClick = true,
+ closeOnEscape = true,
+}) => {
+ // Size mappings
+ const sizeClasses = {
+ sm: 'max-w-md',
+ md: 'max-w-lg',
+ lg: 'max-w-2xl',
+ xl: 'max-w-4xl',
+ full: 'max-w-[90vw]',
+ };
+
+ // Handle ESC key
+ useEffect(() => {
+ if (!closeOnEscape || !isOpen) return;
+
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onClose();
+ }
+ };
+
+ document.addEventListener('keydown', handleEscape);
+ return () => document.removeEventListener('keydown', handleEscape);
+ }, [isOpen, onClose, closeOnEscape]);
+
+ // Prevent body scroll when modal is open
+ useEffect(() => {
+ if (isOpen) {
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.body.style.overflow = 'unset';
+ }
+
+ return () => {
+ document.body.style.overflow = 'unset';
+ };
+ }, [isOpen]);
+
+ return (
+
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Modal Container */}
+
+
+ {/* Header */}
+ {(title || showCloseButton) && (
+
+
+ {title && (
+
+ {title}
+
+ )}
+ {subtitle && (
+
{subtitle}
+ )}
+
+ {showCloseButton && (
+
+
+
+ )}
+
+ )}
+
+ {/* Content */}
+
+ {children}
+
+
+
+ >
+ )}
+
+ );
+};
+
+Modal.displayName = 'Modal';
+
+/**
+ * Modal Footer Component
+ * For consistent modal footer actions
+ */
+interface ModalFooterProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const ModalFooter: React.FC = ({
+ children,
+ className = '',
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+ModalFooter.displayName = 'ModalFooter';
diff --git a/frontend_en/src/components/ui/index.ts b/frontend_en/src/components/ui/index.ts
new file mode 100644
index 0000000..08eed62
--- /dev/null
+++ b/frontend_en/src/components/ui/index.ts
@@ -0,0 +1,19 @@
+/**
+ * Editorial Workspace UI Component Library
+ *
+ * A cohesive set of components implementing the Editorial Workspace design system:
+ * - Warm neutral palette (#FAFAF9 to #1C1917)
+ * - Electric coral accent (#F43F5E)
+ * - Distinctive typography (Newsreader display, Inter body)
+ * - Lifted shadow effects
+ * - Refined interactions
+ *
+ * Usage:
+ * import { Button, Input, Card, Modal, Badge } from '@/components/ui';
+ */
+
+export { Button } from './Button';
+export { Input } from './Input';
+export { Card, CardHeader, CardContent, CardFooter } from './Card';
+export { Modal, ModalFooter } from './Modal';
+export { Badge, BadgeGroup } from './Badge';
diff --git a/frontend_en/src/config/api.ts b/frontend_en/src/config/api.ts
index 669126a..e789831 100644
--- a/frontend_en/src/config/api.ts
+++ b/frontend_en/src/config/api.ts
@@ -2,6 +2,8 @@
* API configuration for backend calls.
*/
+import { getAccessToken } from '../stores/authStore';
+
// API key for backend authentication
export const API_KEY = import.meta.env.VITE_API_KEY || 'df-internal-2024-workflow-key';
@@ -15,9 +17,14 @@ export const API_URL_OPTIONS = (import.meta.env.VITE_LLM_API_URLS || 'https://ap
* Get headers for API calls including the API key.
*/
export function getApiHeaders(): HeadersInit {
- return {
+ const token = getAccessToken();
+ const headers: HeadersInit = {
'X-API-Key': API_KEY,
};
+ if (token) {
+ (headers as Record).Authorization = `Bearer ${token}`;
+ }
+ return headers;
}
/**
@@ -29,6 +36,10 @@ export async function apiFetch(
): Promise {
const headers = new Headers(options.headers);
headers.set('X-API-Key', API_KEY);
+ const token = getAccessToken();
+ if (token) {
+ headers.set('Authorization', `Bearer ${token}`);
+ }
return fetch(url, {
...options,
diff --git a/frontend_en/src/index.css b/frontend_en/src/index.css
index 1712500..e406ceb 100644
--- a/frontend_en/src/index.css
+++ b/frontend_en/src/index.css
@@ -1,13 +1,289 @@
+/* Import Editorial Fonts - Must be first! */
+@import url('https://fonts.googleapis.com/css2?family=Newsreader:wght@300;400;500;600;700;800&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
+
@tailwind base;
@tailwind components;
@tailwind utilities;
+/**
+ * Editorial Workspace Global Styles
+ * Magazine-inspired design with warm neutrals and vibrant accents
+ */
+
:root {
- background-color: #f8f9fa;
+ /* Base background - warm off-white */
+ background-color: #FAFAF9;
+
+ /* Set default font smoothing for crisp text */
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+}
+
+@layer base {
+ body {
+ @apply bg-neutral-50 text-neutral-900 font-sans;
+ margin: 0;
+ }
+
+ /* Typography defaults */
+ h1, h2, h3, h4, h5, h6 {
+ @apply font-display font-semibold text-neutral-800 tracking-tight;
+ }
+
+ h1 {
+ @apply text-5xl md:text-6xl;
+ }
+
+ h2 {
+ @apply text-3xl md:text-4xl;
+ }
+
+ h3 {
+ @apply text-2xl md:text-3xl;
+ }
+
+ h4 {
+ @apply text-xl md:text-2xl;
+ }
+
+ /* Links - editorial style */
+ a {
+ @apply text-accent-600 hover:text-accent-700 transition-colors underline-offset-2;
+ }
+
+ /* Strong emphasis */
+ strong, b {
+ @apply font-semibold text-neutral-900;
+ }
+
+ /* Code elements */
+ code {
+ @apply font-mono text-sm bg-neutral-100 text-neutral-800 px-1.5 py-0.5 rounded;
+ }
+
+ pre code {
+ @apply bg-transparent p-0;
+ }
+
+ /* Selection color - accent coral */
+ ::selection {
+ @apply bg-accent-200 text-accent-900;
+ }
}
-body {
- margin: 0;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+@layer components {
+ /**
+ * Custom scrollbar - subtle and editorial
+ */
+ .scrollbar-thin::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-track {
+ @apply bg-transparent;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-thumb {
+ @apply bg-neutral-300 rounded-full hover:bg-neutral-400 transition-colors;
+ }
+
+ /**
+ * Glass effect (use sparingly - editorial is more matte)
+ */
+ .glass-subtle {
+ background: rgba(255, 255, 255, 0.8);
+ backdrop-filter: blur(12px) saturate(150%);
+ -webkit-backdrop-filter: blur(12px) saturate(150%);
+ }
+
+ /**
+ * Lifted effect - editorial signature
+ * Creates shadow underneath like printed material
+ */
+ .lifted {
+ @apply shadow-lifted hover:shadow-lifted-hover transition-shadow;
+ }
+
+ .lifted-hover {
+ @apply hover:shadow-lifted-hover hover:-translate-y-0.5 transition-all;
+ }
+
+ /**
+ * Text gradient - use for special headings
+ */
+ .text-gradient-accent {
+ @apply bg-gradient-to-r from-accent-600 to-accent-500 bg-clip-text text-transparent;
+ }
+
+ /**
+ * Focus styles - editorial appropriate
+ */
+ .focus-editorial {
+ @apply focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2 focus:ring-offset-neutral-50;
+ }
+
+ /**
+ * Asymmetric grid helper
+ * Creates magazine-style layouts
+ */
+ .grid-editorial {
+ @apply grid gap-8;
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+ }
+
+ /**
+ * Loading shimmer - editorial style
+ */
+ .shimmer-editorial {
+ background: linear-gradient(90deg,
+ #F5F5F4 0%,
+ #E7E5E4 50%,
+ #F5F5F4 100%
+ );
+ background-size: 200% 100%;
+ animation: shimmer 1.5s ease-in-out infinite;
+ }
+
+ /**
+ * Divider - editorial style
+ */
+ .divider-editorial {
+ @apply border-t-2 border-neutral-200 my-8;
+ }
+
+ .divider-accent {
+ @apply border-t-4 border-accent-500 my-8 w-16;
+ }
}
+@layer utilities {
+ /**
+ * Container utilities for editorial layouts
+ */
+ .container-editorial {
+ @apply mx-auto px-6 md:px-8 lg:px-12;
+ max-width: 1400px;
+ }
+
+ .container-narrow {
+ @apply mx-auto px-6;
+ max-width: 720px;
+ }
+
+ .container-wide {
+ @apply mx-auto px-6;
+ max-width: 1600px;
+ }
+
+ /**
+ * Aspect ratios for media
+ */
+ .aspect-editorial {
+ aspect-ratio: 16 / 10;
+ }
+
+ .aspect-card {
+ aspect-ratio: 4 / 3;
+ }
+
+ /**
+ * Text balance - for better headline wrapping
+ */
+ .text-balance {
+ text-wrap: balance;
+ }
+
+ /**
+ * Editorial spacing rhythm
+ */
+ .space-editorial > * + * {
+ @apply mt-6;
+ }
+
+ .space-editorial-lg > * + * {
+ @apply mt-12;
+ }
+
+ /**
+ * Prose styling for markdown content
+ */
+ .prose-editorial {
+ @apply text-base leading-relaxed text-neutral-700;
+ }
+
+ .prose-editorial h1,
+ .prose-editorial h2,
+ .prose-editorial h3 {
+ @apply font-display font-semibold text-neutral-900 mt-8 mb-4;
+ }
+
+ .prose-editorial p {
+ @apply mb-4;
+ }
+
+ .prose-editorial a {
+ @apply text-accent-600 hover:text-accent-700 underline underline-offset-2;
+ }
+
+ .prose-editorial ul,
+ .prose-editorial ol {
+ @apply ml-6 mb-4;
+ }
+
+ .prose-editorial li {
+ @apply mb-2;
+ }
+
+ .prose-editorial code {
+ @apply font-mono text-sm bg-neutral-100 text-neutral-800 px-1.5 py-0.5 rounded;
+ }
+
+ .prose-editorial pre {
+ @apply bg-neutral-900 text-neutral-50 p-4 rounded-lg overflow-x-auto mb-4;
+ }
+
+ .prose-editorial blockquote {
+ @apply border-l-4 border-accent-500 pl-4 italic text-neutral-600 my-4;
+ }
+}
+
+/**
+ * Animation utilities
+ */
+@keyframes shimmer {
+ 0% {
+ background-position: -200% 0;
+ }
+ 100% {
+ background-position: 200% 0;
+ }
+}
+
+/**
+ * Print styles - for generating reports/exports
+ */
+@media print {
+ @page {
+ margin: 2cm;
+ }
+
+ body {
+ @apply bg-white text-black;
+ }
+
+ .no-print {
+ display: none !important;
+ }
+
+ h1, h2, h3 {
+ page-break-after: avoid;
+ }
+
+ img {
+ page-break-inside: avoid;
+ }
+}
diff --git a/frontend_en/src/lib/supabase.ts b/frontend_en/src/lib/supabase.ts
index c2dd277..9fe6d64 100644
--- a/frontend_en/src/lib/supabase.ts
+++ b/frontend_en/src/lib/supabase.ts
@@ -1,37 +1,65 @@
/**
* Supabase client singleton for frontend.
+ * Configuration is fetched from backend API.
*/
import { createClient, SupabaseClient } from "@supabase/supabase-js";
-const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
-const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
+let supabaseClient: SupabaseClient | null = null;
+let initPromise: Promise | null = null;
/**
- * Check if Supabase is properly configured.
+ * Initialize Supabase client from backend config.
+ * Returns true if configured, false otherwise.
*/
-export function isSupabaseConfigured(): boolean {
- return Boolean(supabaseUrl && supabaseAnonKey);
-}
+export async function initSupabase(): Promise {
+ if (initPromise) {
+ return initPromise;
+ }
+
+ initPromise = (async () => {
+ try {
+ const response = await fetch('/api/v1/auth/config');
+ const data = await response.json();
-// Only create client when configured
-const supabaseClient: SupabaseClient | null = isSupabaseConfigured()
- ? createClient(supabaseUrl!, supabaseAnonKey!, {
- auth: {
- autoRefreshToken: true,
- persistSession: true,
- detectSessionInUrl: true,
- },
- })
- : null;
+ if (data.supabaseConfigured && data.supabaseUrl && data.supabaseAnonKey) {
+ supabaseClient = createClient(data.supabaseUrl, data.supabaseAnonKey, {
+ auth: {
+ autoRefreshToken: true,
+ persistSession: true,
+ detectSessionInUrl: true,
+ },
+ });
+ console.info('[Supabase] Configured and initialized');
+ return true;
+ } else {
+ console.info('[Supabase] Not configured, using trial mode');
+ return false;
+ }
+ } catch (error) {
+ console.error('[Supabase] Initialization failed:', error);
+ return false;
+ }
+ })();
+
+ return initPromise;
+}
/**
- * Get Supabase client.
+ * Get Supabase client (must call initSupabase first).
*/
-export const supabase = supabaseClient as SupabaseClient;
-
-if (!isSupabaseConfigured()) {
- console.info(
- "[Supabase] Not configured. Auth, quotas, and cloud storage disabled."
- );
+export function getSupabaseClient(): SupabaseClient | null {
+ return supabaseClient;
}
+
+/**
+ * Legacy export for compatibility.
+ */
+export const supabase = new Proxy({} as SupabaseClient, {
+ get(target, prop) {
+ if (!supabaseClient) {
+ throw new Error('[Supabase] Client not initialized. Call initSupabase() first.');
+ }
+ return (supabaseClient as any)[prop];
+ }
+});
diff --git a/frontend_en/src/pages/AuthPage.tsx b/frontend_en/src/pages/AuthPage.tsx
new file mode 100644
index 0000000..1ca6726
--- /dev/null
+++ b/frontend_en/src/pages/AuthPage.tsx
@@ -0,0 +1,342 @@
+import { FormEvent, useEffect, useMemo, useState } from 'react';
+import { motion } from 'framer-motion';
+import { ArrowRight, Loader2, Lock, Mail } from 'lucide-react';
+import { useAuthStore } from '../stores/authStore';
+
+type AuthMode = 'login' | 'register' | 'verify';
+const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+export default function AuthPage() {
+ const {
+ loading,
+ error,
+ pendingEmail,
+ needsOtpVerification,
+ signInWithEmail,
+ signUpWithEmail,
+ verifyOtp,
+ resendOtp,
+ clearError,
+ clearPendingVerification,
+ } = useAuthStore();
+
+ const [mode, setMode] = useState('login');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [otpCode, setOtpCode] = useState('');
+ const [emailTouched, setEmailTouched] = useState(false);
+ const [localMessage, setLocalMessage] = useState('');
+ const [localError, setLocalError] = useState('');
+
+ useEffect(() => {
+ if (needsOtpVerification && pendingEmail) {
+ setMode('verify');
+ setEmail(pendingEmail);
+ setLocalMessage(`We sent a verification email or code to ${pendingEmail}.`);
+ }
+ }, [needsOtpVerification, pendingEmail]);
+
+ const normalizedEmail = useMemo(() => email.trim(), [email]);
+ const isEmailValid = normalizedEmail.length > 0 && EMAIL_REGEX.test(normalizedEmail);
+ const showEmailError = emailTouched && normalizedEmail.length > 0 && !isEmailValid;
+ const displayError = localError || error || '';
+
+ const resetMessages = () => {
+ clearError();
+ setLocalError('');
+ setLocalMessage('');
+ };
+
+ const handleLogin = async (event: FormEvent) => {
+ event.preventDefault();
+ resetMessages();
+ setEmailTouched(true);
+ if (!normalizedEmail || !password) {
+ setLocalError('Enter your email and password.');
+ return;
+ }
+ if (!isEmailValid) {
+ setLocalError('Enter a valid email address.');
+ return;
+ }
+ await signInWithEmail(normalizedEmail, password);
+ };
+
+ const handleRegister = async (event: FormEvent) => {
+ event.preventDefault();
+ resetMessages();
+ setEmailTouched(true);
+ if (!normalizedEmail || !password || !confirmPassword) {
+ setLocalError('Complete all registration fields.');
+ return;
+ }
+ if (!isEmailValid) {
+ setLocalError('Enter a valid email address.');
+ return;
+ }
+ if (password !== confirmPassword) {
+ setLocalError('Passwords do not match.');
+ return;
+ }
+ if (password.length < 6) {
+ setLocalError('Password must be at least 6 characters.');
+ return;
+ }
+
+ const result = await signUpWithEmail(normalizedEmail, password);
+ if (!result.needsVerification) {
+ setLocalMessage('Account created and signed in.');
+ }
+ };
+
+ const handleVerify = async (event: FormEvent) => {
+ event.preventDefault();
+ resetMessages();
+ const emailToVerify = pendingEmail || normalizedEmail;
+ if (!emailToVerify || !otpCode.trim()) {
+ setLocalError('Enter the verification code.');
+ return;
+ }
+ await verifyOtp(emailToVerify, otpCode);
+ };
+
+ const handleResend = async () => {
+ resetMessages();
+ const emailToVerify = pendingEmail || normalizedEmail;
+ if (!emailToVerify) {
+ setLocalError('Missing pending email.');
+ return;
+ }
+ await resendOtp(emailToVerify);
+ setLocalMessage(`Resent to ${emailToVerify}.`);
+ };
+
+ const switchMode = (nextMode: Exclude) => {
+ resetMessages();
+ if (mode === 'verify') {
+ clearPendingVerification();
+ setOtpCode('');
+ }
+ setMode(nextMode);
+ };
+
+ return (
+
+
+
+
+
+
OpenNotebookLM
+
+ {mode === 'register' ? 'Create account' : mode === 'verify' ? 'Verify email' : 'Sign in'}
+
+
+
+ {mode !== 'verify' && (
+
+ switchMode('login')}
+ className={`flex-1 rounded-[14px] px-4 py-2.5 text-sm font-medium transition ${
+ mode === 'login' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'
+ }`}
+ >
+ Sign in
+
+ switchMode('register')}
+ className={`flex-1 rounded-[14px] px-4 py-2.5 text-sm font-medium transition ${
+ mode === 'register' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'
+ }`}
+ >
+ Register
+
+
+ )}
+
+ {mode === 'login' && (
+
+ )}
+
+ {mode === 'register' && (
+
+ )}
+
+ {mode === 'verify' && (
+
+ )}
+
+ {localMessage && (
+
+ {localMessage}
+
+ )}
+
+ {displayError && (
+
+ {displayError}
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend_en/src/pages/Dashboard.tsx b/frontend_en/src/pages/Dashboard.tsx
index 7139332..56c8341 100644
--- a/frontend_en/src/pages/Dashboard.tsx
+++ b/frontend_en/src/pages/Dashboard.tsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
-import { Settings, Plus, User, Loader2, BookOpen, Key, CheckCircle2 } from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Settings, Plus, User, Loader2, BookOpen, Key, CheckCircle2, LogOut, Info } from 'lucide-react';
import { useAuthStore } from '../stores/authStore';
import { apiFetch } from '../config/api';
import { API_URL_OPTIONS, DEFAULT_LLM_API_URL } from '../config/api';
@@ -19,8 +20,8 @@ export interface Notebook {
updated_at?: string;
}
-const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n: Notebook) => void; refreshTrigger?: number }) => {
- const { user } = useAuthStore();
+const Dashboard = ({ onOpenNotebook, refreshTrigger = 0, supabaseConfigured }: { onOpenNotebook: (n: Notebook) => void; refreshTrigger?: number; supabaseConfigured: boolean | null }) => {
+ const { user, signOut } = useAuthStore();
const [notebooks, setNotebooks] = useState([]);
const [loading, setLoading] = useState(true);
const [createModalOpen, setCreateModalOpen] = useState(false);
@@ -36,8 +37,8 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n:
const [configSaving, setConfigSaving] = useState(false);
const [configSaved, setConfigSaved] = useState(false);
- // 不做用户管理时用默认用户,数据从 outputs 取
- const effectiveUserId = user?.id || 'default';
+ const effectiveUserId = user?.id || 'local';
+ const effectiveEmail = user?.email || '';
useEffect(() => {
const s = getApiSettings(effectiveUserId);
@@ -71,7 +72,7 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n:
const fetchNotebooks = async () => {
setLoading(true);
try {
- const res = await apiFetch(`/api/v1/kb/notebooks?user_id=${encodeURIComponent(effectiveUserId)}`);
+ const res = await apiFetch(`/api/v1/kb/notebooks?user_id=${encodeURIComponent(effectiveUserId)}&email=${encodeURIComponent(effectiveEmail)}`);
const data = await res.json();
if (data?.success && Array.isArray(data.notebooks)) {
const list: Notebook[] = data.notebooks.map((row: any) => ({
@@ -84,7 +85,6 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n:
date: row.updated_at ? new Date(row.updated_at).toLocaleDateString('zh-CN') : '',
sources: typeof row.sources === 'number' ? row.sources : 0,
}));
- // 本地笔记本的 sources 已由后端从 outputs 扫描返回,无需再读 localStorage
setNotebooks(list);
} else {
setNotebooks([]);
@@ -110,7 +110,7 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n:
const res = await apiFetch('/api/v1/kb/notebooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name, description: '', user_id: effectiveUserId }),
+ body: JSON.stringify({ name, description: '', user_id: effectiveUserId, email: effectiveEmail }),
});
const data = await res.json();
if (data?.success && data?.notebook) {
@@ -141,41 +141,84 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n:
return (
-
-
-
-
open NoteBookLM
-
-
-
setConfigOpen((o) => !o)}
- className="text-gray-600 hover:text-gray-900 flex items-center gap-2"
- >
-
- API Settings
-
-
-
+ {/* Glass Header */}
+
+
+
+
+
OpenNotebookLM
+
+
+
+ {user?.email || user?.id}
+
+
setConfigOpen((o) => !o)}
+ className="text-ios-gray-600 hover:text-ios-gray-900 flex items-center gap-2 px-3 py-2 rounded-ios hover:bg-white/50 transition-colors"
+ >
+
+ API Settings
+
+
void signOut()}
+ className="text-ios-gray-600 hover:text-ios-gray-900 flex items-center gap-2 px-3 py-2 rounded-ios hover:bg-white/50 transition-colors"
+ >
+
+ Sign out
+
+
+
+
+ {/* Supabase Configuration Notice - Only shown when not configured */}
+ {!supabaseConfigured && (
+
+
+
+ )}
+
{configOpen && (
-
-
+
+
Home config (used when you open a notebook)
-
LLM API
+
LLM API
- API URL
+ API URL
setApiUrl(e.target.value)}
- className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ className="w-full px-3 py-2.5 border border-ios-gray-200 rounded-ios text-sm focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
>
{[apiUrl, ...API_URL_OPTIONS].filter((v, i, a) => a.indexOf(v) === i).map((url: string) => (
{url}
@@ -183,24 +226,24 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n:
- API Key
+ API Key
setApiKey(e.target.value)}
placeholder="sk-..."
- className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ className="w-full px-3 py-2.5 border border-ios-gray-200 rounded-ios text-sm focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
/>
-
Search API
+
Search API
- Search provider
+ Search provider
setSearchProvider(e.target.value as SearchProvider)}
- className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ className="w-full px-3 py-2.5 border border-ios-gray-200 rounded-ios text-sm focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
>
Serper (Google, env)
SerpAPI (Google/Baidu)
@@ -209,23 +252,23 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n:
{(searchProvider === 'serpapi' || searchProvider === 'bocha') && (
- Search API Key
+ Search API Key
setSearchApiKey(e.target.value)}
placeholder={searchProvider === 'bocha' ? 'Bocha API Key' : 'SerpAPI Key'}
- className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ className="w-full px-3 py-2.5 border border-ios-gray-200 rounded-ios text-sm focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
/>
)}
{searchProvider === 'serpapi' && (
- Search engine
+ Search engine
setSearchEngine(e.target.value as SearchEngine)}
- className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ className="w-full px-3 py-2.5 border border-ios-gray-200 rounded-ios text-sm focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
>
Google
Baidu
@@ -235,98 +278,127 @@ const Dashboard = ({ onOpenNotebook, refreshTrigger = 0 }: { onOpenNotebook: (n:
-
{configSaving ? : configSaved ? : }
{configSaving ? 'Saving...' : configSaved ? 'Saved' : 'Save config'}
-
+
)}
-
Notebooks
+ Notebooks
{loading ? (
-
+
Loading...
) : (
-
setCreateModalOpen(true)}
>
-
+
New notebook
+
+ {/* Notebook Cards */}
{notebooks.map((nb) => (
-
onOpenNotebook(nb)}
>
-
-
+
{nb.title || nb.name || 'Untitled'}
-
+
{nb.date || (nb.updated_at ? new Date(nb.updated_at).toLocaleDateString('zh-CN') : '')}
{typeof nb.sources === 'number' ? ` · ${nb.sources} sources` : ''}
-
+
))}
)}
- {createModalOpen && (
-
!creating && setCreateModalOpen(false)}>
-
e.stopPropagation()}>
-
New notebook
-
setNewNotebookName(e.target.value)}
+ {/* Create Modal — iOS Sheet */}
+
+ {createModalOpen && (
+ !creating && setCreateModalOpen(false)}>
+
- {createError &&
{createError}
}
-
- !creating && setCreateModalOpen(false)}
- disabled={creating}
- >
- Cancel
-
-
- {creating && }
- Create
-
-
+
e.stopPropagation()}
+ >
+ {/* iOS Drag Indicator */}
+
+ New notebook
+ setNewNotebookName(e.target.value)}
+ />
+ {createError && {createError}
}
+
+ !creating && setCreateModalOpen(false)}
+ disabled={creating}
+ >
+ Cancel
+
+
+ {creating && }
+ Create
+
+
+
-
- )}
+ )}
+
);
};
diff --git a/frontend_en/src/pages/NotebookView.tsx b/frontend_en/src/pages/NotebookView.tsx
index fa3acf6..3f965cb 100644
--- a/frontend_en/src/pages/NotebookView.tsx
+++ b/frontend_en/src/pages/NotebookView.tsx
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
import {
ChevronLeft, Plus, Share2, Settings, MessageSquare,
BarChart2, Zap, AudioLines, Video, FileText,
@@ -7,7 +8,7 @@ import {
Globe, Link2, Cloud, ChevronRight, LayoutGrid, Download, BookOpen, Brain
} from 'lucide-react';
import { useAuthStore } from '../stores/authStore';
-import { supabase, isSupabaseConfigured } from '../lib/supabase';
+import { getSupabaseClient } from '../lib/supabase';
import { apiFetch } from '../config/api';
import { getApiSettings } from '../services/apiSettingsService';
import type { KnowledgeFile, ChatMessage, ToolType } from '../types';
@@ -15,10 +16,9 @@ import ReactMarkdown from 'react-markdown';
import { MermaidPreview } from '../components/knowledge-base/tools/MermaidPreview';
import { SettingsModal } from '../components/SettingsModal';
import DrawioInlineEditor from '../components/DrawioInlineEditor';
-import { FlashcardGenerator } from '../components/flashcards/FlashcardGenerator';
import { FlashcardViewer } from '../components/flashcards/FlashcardViewer';
-import { QuizGenerator } from '../components/quiz/QuizGenerator';
import { QuizContainer } from '../components/quiz/QuizContainer';
+import { NotionEditor } from '../components/notes/NotionEditor';
import katex from 'katex';
import 'katex/dist/katex.min.css';
@@ -28,7 +28,6 @@ const DEFAULT_USER = { id: 'default', email: 'default' };
const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void }) => {
const { user } = useAuthStore();
const effectiveUser = user || DEFAULT_USER;
- const [activeTab, setActiveTab] = useState<'chat' | 'retrieval' | 'sources'>('chat');
const [activeTool, setActiveTool] = useState
('chat');
// Files management
@@ -39,7 +38,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const WELCOME_MSG: ChatMessage = {
id: 'welcome',
role: 'assistant',
- content: 'Hello! I\'m your knowledge base assistant. Upload files or select sources on the left, then ask your questions here.',
+ content: 'Welcome to OpenNotebookLM! I\'m your intelligent knowledge base assistant.\n\nUpload documents on the left, then chat with me to explore, summarize, and generate insights from your sources — including podcasts, mind maps, presentations, flashcards, and quizzes.',
time: new Date().toLocaleTimeString()
};
const [chatMessages, setChatMessages] = useState([WELCOME_MSG]);
@@ -86,7 +85,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const [toolLoading, setToolLoading] = useState(false);
const [outputFeed, setOutputFeed] = useState void
previewUrl?: string;
createdAt: string;
mermaidCode?: string;
+ setId?: string;
}>>([]);
// Settings modal
const [showSettingsModal, setShowSettingsModal] = useState(false);
+ // Flashcard state
+ const [flashcards, setFlashcards] = useState([]);
+ const [showFlashcardViewer, setShowFlashcardViewer] = useState(false);
+ const [flashcardSetId, setFlashcardSetId] = useState('');
+
+ // Quiz state
+ const [quizQuestions, setQuizQuestions] = useState([]);
+ const [showQuizContainer, setShowQuizContainer] = useState(false);
+ const [quizId, setQuizId] = useState('');
+
// Output preview
const [previewOutput, setPreviewOutput] = useState<{
id: string;
- type: 'ppt' | 'mindmap' | 'podcast' | 'drawio';
+ type: 'ppt' | 'mindmap' | 'podcast' | 'drawio' | 'flashcard' | 'quiz';
title: string;
sources: string;
url?: string;
previewUrl?: string;
createdAt: string;
mermaidCode?: string;
+ setId?: string;
} | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
/** DrawIO 预览:从 url 拉取后的 xml,用于在弹窗内嵌编辑 */
const [previewDrawioXml, setPreviewDrawioXml] = useState(null);
- const [retrievalQuery, setRetrievalQuery] = useState('');
- const [retrievalResults, setRetrievalResults] = useState([]);
- const [retrievalLoading, setRetrievalLoading] = useState(false);
const [retrievalError, setRetrievalError] = useState('');
- const [retrievalTopK, setRetrievalTopK] = useState(5);
const [retrievalModel, setRetrievalModel] = useState('text-embedding-3-large');
const [vectorFiles, setVectorFiles] = useState([]);
const [vectorLoading, setVectorLoading] = useState(false);
@@ -158,15 +165,8 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const [sourceDetailFormat, setSourceDetailFormat] = useState<'text' | 'markdown'>('text');
const [sourceDetailLoading, setSourceDetailLoading] = useState(false);
- // Flashcard state
- const [flashcards, setFlashcards] = useState([]);
- const [showFlashcardViewer, setShowFlashcardViewer] = useState(false);
- const [flashcardSetId, setFlashcardSetId] = useState('');
-
- // Quiz state
- const [quizQuestions, setQuizQuestions] = useState([]);
- const [showQuizContainer, setShowQuizContainer] = useState(false);
- const [quizId, setQuizId] = useState('');
+ // Loading state for saved flashcard/quiz sets
+ const [loadingSetId, setLoadingSetId] = useState(null);
// 三栏可拖拽宽度(左 / 右,中间 flex 自适应)
const [leftPanelWidth, setLeftPanelWidth] = useState(256);
@@ -181,11 +181,12 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
document.body.style.userSelect = 'none';
const onMove = (e: MouseEvent) => {
if (!resizeRef.current) return;
- const delta = e.clientX - resizeRef.current.startX;
+ const { startX, startLeft, startRight } = resizeRef.current;
+ const delta = e.clientX - startX;
if (resizing === 'left') {
- setLeftPanelWidth(() => Math.min(480, Math.max(160, resizeRef.current!.startLeft + delta)));
+ setLeftPanelWidth(Math.min(480, Math.max(160, startLeft + delta)));
} else {
- setRightPanelWidth(() => Math.min(600, Math.max(200, resizeRef.current!.startRight - delta)));
+ setRightPanelWidth(Math.min(600, Math.max(200, startRight - delta)));
}
};
const onUp = () => {
@@ -208,16 +209,18 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const studioTools: Array<{icon: React.ReactNode, label: string, id: ToolType}> = [
{ icon: , label: 'PPT', id: 'ppt' },
{ icon: , label: 'Mind Map', id: 'mindmap' },
- { icon: , label: 'DrawIO', id: 'drawio' },
+ // DrawIO temporarily disabled - will be fixed later
+ // { icon: , label: 'DrawIO', id: 'drawio' },
{ icon: , label: 'Flashcards', id: 'flashcard' },
{ icon: , label: 'Quiz', id: 'quiz' },
{ icon: , label: 'Knowledge Podcast', id: 'podcast' },
+ { icon: , label: 'Note', id: 'note' },
// Video narration temporarily disabled
// { icon: , label: 'Video narration', id: 'video' },
];
// Studio:每个功能卡片各自配置,点卡片上的「…」翻转进该卡片的设置
- type StudioToolId = 'ppt' | 'mindmap' | 'drawio' | 'podcast' | 'video';
+ type StudioToolId = 'ppt' | 'mindmap' | 'drawio' | 'flashcard' | 'quiz' | 'podcast' | 'video' | 'note';
const [studioPanelView, setStudioPanelView] = useState<'tools' | 'settings'>('tools');
const [studioSettingsTool, setStudioSettingsTool] = useState(null);
const STORAGE_STUDIO_CONFIG = `kb_studio_config_${effectiveUser?.id || 'default'}`;
@@ -225,8 +228,11 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
ppt: { llmModel: 'deepseek-v3.2', genFigModel: 'gemini-2.5-flash-image', stylePreset: 'modern', stylePrompt: '', language: 'zh', page_count: '10' },
mindmap: { llmModel: 'deepseek-v3.2', mindmapStyle: 'default' },
drawio: { llmModel: 'deepseek-v3.2', diagramType: 'auto', diagramStyle: 'default', language: 'zh' },
- podcast: { llmModel: 'deepseek-v3.2', ttsModel: 'gemini-2.5-pro-preview-tts', voiceName: 'Kore', voiceNameB: 'Puck' },
+ flashcard: { llmModel: 'deepseek-v3.2', language: 'en', cardCount: '20' },
+ quiz: { llmModel: 'deepseek-v3.2', language: 'en', questionCount: '10' },
+ podcast: { llmModel: 'deepseek-v3.2', ttsModel: 'qwen-tts', voiceName: 'vivian' },
video: { llmModel: 'deepseek-v3.2' },
+ note: {},
};
const [studioConfigByTool, setStudioConfigByTool] = useState>>(() => {
try {
@@ -323,7 +329,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
localStorage.setItem(key, JSON.stringify(items));
};
- const inferOutputType = (urlOrName?: string): 'ppt' | 'mindmap' | 'podcast' | 'drawio' => {
+ const inferOutputType = (urlOrName?: string): 'ppt' | 'mindmap' | 'podcast' | 'drawio' | 'flashcard' | 'quiz' => {
const value = (urlOrName || '').toLowerCase();
if (value.endsWith('.wav') || value.endsWith('.mp3') || value.endsWith('.m4a')) return 'podcast';
if (value.endsWith('.mmd') || value.endsWith('.mermaid')) return 'mindmap';
@@ -331,13 +337,45 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
return 'ppt';
};
- const getOutputTitle = (type: 'ppt' | 'mindmap' | 'podcast' | 'drawio') => {
+ const getOutputTitle = (type: 'ppt' | 'mindmap' | 'podcast' | 'drawio' | 'flashcard' | 'quiz') => {
if (type === 'mindmap') return 'Mind Map';
if (type === 'podcast') return 'Podcast';
if (type === 'drawio') return 'DrawIO';
+ if (type === 'flashcard') return 'Flashcards';
+ if (type === 'quiz') return 'Quiz';
return 'PPT';
};
+ const handleLoadSavedSet = async (item: typeof outputFeed[number]) => {
+ if (!item.setId) {
+ alert('Failed to load: this item has no saved set ID. It may have been created before persistence was added.');
+ return;
+ }
+ setLoadingSetId(item.id);
+ try {
+ const endpoint = item.type === 'flashcard'
+ ? `/api/v1/kb/get-flashcard-set?notebook_id=${encodeURIComponent(notebook.id)}&set_id=${encodeURIComponent(item.setId)}`
+ : `/api/v1/kb/get-quiz-set?notebook_id=${encodeURIComponent(notebook.id)}&set_id=${encodeURIComponent(item.setId)}`;
+ const res = await apiFetch(endpoint);
+ const data = await res.json();
+ if (!data.success) throw new Error(data.detail || 'Load failed');
+ if (item.type === 'flashcard') {
+ setFlashcards(data.flashcards || []);
+ setFlashcardSetId(data.id || '');
+ setShowFlashcardViewer(true);
+ } else {
+ setQuizQuestions(data.questions || []);
+ setQuizId(data.id || '');
+ setShowQuizContainer(true);
+ }
+ } catch (err) {
+ console.error('Load saved set error:', err);
+ alert('Failed to load. The data may have been deleted.');
+ } finally {
+ setLoadingSetId(null);
+ }
+ };
+
const mergeOutputFeeds = (remote: typeof outputFeed, local: typeof outputFeed) => {
const map = new Map();
const add = (item: typeof outputFeed[number]) => {
@@ -350,7 +388,8 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
}
map.set(key, {
...item,
- mermaidCode: prev.mermaidCode || item.mermaidCode
+ mermaidCode: prev.mermaidCode || item.mermaidCode,
+ setId: prev.setId || item.setId,
});
};
remote.forEach(add);
@@ -364,30 +403,79 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
const fetchOutputHistory = async () => {
if (!effectiveUser?.email && !effectiveUser?.id) return [];
+ const results: typeof outputFeed = [];
try {
const params = new URLSearchParams({ email: effectiveUser.email || effectiveUser.id });
if (notebook?.id) params.set('notebook_id', notebook.id);
+ const nbTitle = notebook?.title || notebook?.name || '';
+ if (nbTitle) params.set('notebook_title', nbTitle);
const res = await apiFetch(`/api/v1/kb/outputs?${params.toString()}`);
- if (!res.ok) return [];
- const data = await res.json();
- if (!data?.success || !Array.isArray(data.files)) return [];
- return data.files.map((item: any) => {
- const url = item.download_url || item.url || '';
- const type = (item.output_type as 'ppt' | 'mindmap' | 'podcast' | 'drawio') || inferOutputType(item.file_name || url);
- return {
- id: item.id || url || `output_${Date.now()}`,
- type,
- title: getOutputTitle(type),
- sources: 'Past outputs',
- url,
- createdAt: item.created_at ? new Date(item.created_at).toLocaleString() : new Date().toLocaleString(),
- mermaidCode: undefined
- };
- });
+ if (res.ok) {
+ const data = await res.json();
+ if (data?.success && Array.isArray(data.files)) {
+ for (const item of data.files) {
+ const url = item.download_url || item.url || '';
+ const type = (item.output_type as 'ppt' | 'mindmap' | 'podcast' | 'drawio') || inferOutputType(item.file_name || url);
+ results.push({
+ id: item.id || url || `output_${Date.now()}`,
+ type,
+ title: getOutputTitle(type),
+ sources: 'Past outputs',
+ url,
+ createdAt: item.created_at ? new Date(item.created_at).toLocaleString() : new Date().toLocaleString(),
+ mermaidCode: undefined
+ });
+ }
+ }
+ }
} catch (err) {
console.error('Failed to load output history:', err);
- return [];
}
+
+ // Fetch flashcard & quiz history from dedicated endpoints
+ if (notebook?.id) {
+ const nbTitle = notebook?.title || notebook?.name || '';
+ const fcParams = new URLSearchParams({ notebook_id: notebook.id, notebook_title: nbTitle });
+ const qzParams = new URLSearchParams({ notebook_id: notebook.id, notebook_title: nbTitle });
+ const [fcRes, qzRes] = await Promise.all([
+ apiFetch(`/api/v1/kb/list-flashcard-sets?${fcParams.toString()}`).catch(() => null),
+ apiFetch(`/api/v1/kb/list-quiz-sets?${qzParams.toString()}`).catch(() => null),
+ ]);
+ if (fcRes?.ok) {
+ const fcData = await fcRes.json().catch(() => null);
+ if (fcData?.success && Array.isArray(fcData.sets)) {
+ for (const s of fcData.sets) {
+ results.push({
+ id: s.id || `flashcard_${s.set_id}`,
+ type: 'flashcard',
+ title: getOutputTitle('flashcard'),
+ sources: Array.isArray(s.source_files) ? s.source_files.map((f: string) => f.split('/').pop() || f).join(', ') : 'Past outputs',
+ url: '',
+ createdAt: s.created_at ? new Date(s.created_at).toLocaleString() : new Date().toLocaleString(),
+ setId: s.set_id,
+ });
+ }
+ }
+ }
+ if (qzRes?.ok) {
+ const qzData = await qzRes.json().catch(() => null);
+ if (qzData?.success && Array.isArray(qzData.sets)) {
+ for (const s of qzData.sets) {
+ results.push({
+ id: s.id || `quiz_${s.set_id}`,
+ type: 'quiz',
+ title: getOutputTitle('quiz'),
+ sources: Array.isArray(s.source_files) ? s.source_files.map((f: string) => f.split('/').pop() || f).join(', ') : 'Past outputs',
+ url: '',
+ createdAt: s.created_at ? new Date(s.created_at).toLocaleString() : new Date().toLocaleString(),
+ setId: s.set_id,
+ });
+ }
+ }
+ }
+ }
+
+ return results;
};
const getChatStorageKey = () => {
@@ -447,7 +535,8 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
};
const markEmbedded = async (file?: KnowledgeFile, storagePath?: string) => {
- if (isSupabaseConfigured()) {
+ const supabase = getSupabaseClient();
+ if (supabase) {
if (file?.id) {
await supabase.from('knowledge_base_files').update({ is_embedded: true }).eq('id', file.id);
} else if (storagePath) {
@@ -559,56 +648,6 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
}
};
- const handleRunRetrieval = async () => {
- if (!retrievalQuery.trim()) {
- setRetrievalError('Please enter a query');
- return;
- }
- if (!user?.email) {
- setRetrievalError('User info missing');
- return;
- }
-
- const settings = getApiSettings(user?.id || null);
- const apiUrl = settings?.apiUrl?.trim() || '';
- const apiKey = settings?.apiKey?.trim() || '';
- if (!apiUrl || !apiKey) {
- setRetrievalError('Please configure API URL and API Key in Settings first');
- return;
- }
-
- try {
- const selectedFiles = files.filter(f => selectedIds.has(f.id));
- const fileIds = selectedFiles.map(f => f.kbFileId).filter(Boolean) as string[];
-
- setRetrievalLoading(true);
- setRetrievalError('');
- const res = await apiFetch('/api/v1/kb/search', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- query: retrievalQuery.trim(),
- top_k: retrievalTopK,
- email: effectiveUser?.email || effectiveUser?.id,
- api_url: apiUrl,
- api_key: apiKey,
- model_name: retrievalModel,
- file_ids: fileIds.length > 0 ? fileIds : undefined
- })
- });
- if (!res.ok) {
- const msg = await res.text();
- throw new Error(msg || 'Retrieval failed');
- }
- const data = await res.json();
- setRetrievalResults(Array.isArray(data.results) ? data.results : []);
- } catch (err: any) {
- setRetrievalError(err?.message || 'Retrieval failed');
- } finally {
- setRetrievalLoading(false);
- }
- };
-
// Fetch files from outputs when notebook changes(不做用户管理,数据从 outputs 取)
useEffect(() => {
if (notebook?.id) fetchFiles();
@@ -929,7 +968,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
search_provider: searchProvider,
search_engine: searchEngine,
};
- if (searchProvider === 'serpapi' || searchProvider === 'bocha') body.search_api_key = searchApiKey;
+ body.search_api_key = searchApiKey;
const res = await apiFetch('/api/v1/kb/fast-research', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1108,7 +1147,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
setDeepResearchError('Please configure API in Settings first');
return;
}
- if ((searchProvider === 'serpapi' || searchProvider === 'bocha') && !searchApiKey) {
+ if (!searchApiKey) {
setDeepResearchError('Please configure Search API Key in Settings first');
return;
}
@@ -1133,7 +1172,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
language: 'zh',
add_as_source: true,
search_provider: searchProvider,
- search_api_key: searchProvider === 'serpapi' || searchProvider === 'bocha' ? searchApiKey : undefined,
+ search_api_key: searchApiKey,
search_engine: searchEngine,
search_top_k: 10
})
@@ -1377,6 +1416,12 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
case 'drawio':
endpoint = '/api/v1/kb/generate-drawio';
break;
+ case 'flashcard':
+ endpoint = '/api/v1/kb/generate-flashcards';
+ break;
+ case 'quiz':
+ endpoint = '/api/v1/kb/generate-quiz';
+ break;
default:
throw new Error('Unsupported tool');
}
@@ -1434,16 +1479,18 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
};
} else if (tool === 'podcast') {
const cfg = getStudioConfig('podcast');
+ // Qwen TTS only supports monologue mode
+ const ttsModel = (cfg.ttsModel || 'local-fireredtts').toLowerCase();
+ const isQwenTTS = ttsModel.includes('qwen');
bodyData = {
...baseBody,
file_paths: selectedFileUrls,
model: cfg.llmModel || 'deepseek-v3.2',
- tts_model: cfg.ttsModel || 'gemini-2.5-pro-preview-tts',
- voice_name: cfg.voiceName || 'Kore',
- voice_name_b: cfg.voiceNameB || 'Puck',
- podcast_mode: 'monologue',
+ tts_model: cfg.ttsModel || 'local-fireredtts',
+ voice_name: cfg.voiceName || 'vivian',
+ podcast_mode: isQwenTTS ? 'monologue' : 'dialog',
podcast_length: 'standard',
- language: 'zh'
+ language: cfg.podcastLanguage || 'en'
};
} else if (tool === 'mindmap') {
const cfg = getStudioConfig('mindmap');
@@ -1463,6 +1510,24 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
diagram_style: cfg.diagramStyle || 'default',
language: cfg.language || 'zh',
};
+ } else if (tool === 'flashcard') {
+ const cfg = getStudioConfig('flashcard');
+ bodyData = {
+ ...baseBody,
+ file_paths: selectedFileUrls,
+ model: cfg.llmModel || 'deepseek-v3.2',
+ language: cfg.language || 'en',
+ card_count: Math.max(5, Math.min(50, parseInt(String(cfg.cardCount || '20'), 10) || 20)),
+ };
+ } else if (tool === 'quiz') {
+ const cfg = getStudioConfig('quiz');
+ bodyData = {
+ ...baseBody,
+ file_paths: selectedFileUrls,
+ model: cfg.llmModel || 'deepseek-v3.2',
+ language: cfg.language || 'en',
+ question_count: Math.max(5, Math.min(30, parseInt(String(cfg.questionCount || '10'), 10) || 10)),
+ };
} else {
bodyData = {
...baseBody,
@@ -1542,6 +1607,40 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
},
...prev,
]);
+ } else if (tool === 'flashcard') {
+ setFlashcards(data.flashcards || []);
+ setFlashcardSetId(data.flashcard_set_id || '');
+ if (data.flashcards?.length) setShowFlashcardViewer(true);
+ const fcSetId = (data.flashcard_set_id || '').replace('flashcard_', '');
+ setOutputFeed(prev => [
+ {
+ id: data.flashcard_set_id || `flashcard_${Date.now()}`,
+ type: 'flashcard',
+ title: 'Flashcards',
+ sources: selectedNames.length ? selectedNames.join(', ') : `${selectedIds.size} source(s)`,
+ url: '',
+ createdAt: now,
+ setId: fcSetId || String(Date.now()),
+ },
+ ...prev,
+ ]);
+ } else if (tool === 'quiz') {
+ setQuizQuestions(data.questions || []);
+ setQuizId(data.quiz_id || '');
+ if (data.questions?.length) setShowQuizContainer(true);
+ const qzSetId = (data.quiz_id || '').replace('quiz_', '');
+ setOutputFeed(prev => [
+ {
+ id: data.quiz_id || `quiz_${Date.now()}`,
+ type: 'quiz',
+ title: 'Quiz',
+ sources: selectedNames.length ? selectedNames.join(', ') : `${selectedIds.size} source(s)`,
+ url: '',
+ createdAt: now,
+ setId: qzSetId || String(Date.now()),
+ },
+ ...prev,
+ ]);
}
} catch (err) {
@@ -1704,7 +1803,8 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
return (
-
+ <>
+
{/* Citation tooltip styles */}
{/* Header */}
-
+
-
+
-
-
-
+
+
+
{notebook?.title || 'Semantic Rewards for Low-Resource Language Alignment'}
-
+
- {/* 右上方添加笔记 - 暂未使用,先注释
-
-
- New notebook
-
- */}
- {/* 右侧上方分析和分享 - 暂未使用,先注释
-
-
- Analyze
-
-
-
- Share
-
- */}
-
setShowSettingsModal(true)}
- className="p-2 hover:bg-gray-100 rounded-full transition-colors"
+ className="p-2 hover:bg-white/50 rounded-ios transition-colors"
title="API settings"
>
-
-
-
-
PRO
-
+
+
+
+
PRO
+
{(effectiveUser?.email || effectiveUser?.id || 'U').charAt(0).toUpperCase()}
@@ -1798,12 +1883,12 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
{/* Left Sidebar: Sources */}
{retrievalError && (
@@ -1860,12 +1946,12 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
{!sourceDetailView ? (
<>
-
+
{selectedIds.size > 0 ? `${selectedIds.size} selected` : 'All sources'}
0}
onChange={(e) => {
if (e.target.checked) {
@@ -1879,21 +1965,24 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
{files.length === 0 ? (
-
+
No files. Please upload.
) : (
files.map((file, fileIdx) => (
-
openSourceDetail(file)}
>
-
-
{fileIdx + 1}
+
+ {fileIdx + 1}
-
+
{file.name}
@@ -1906,7 +1995,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
{
setSelectedIds(prev => {
@@ -1918,7 +2007,7 @@ const NotebookView = ({ notebook, onBack }: { notebook: any, onBack: () => void
}}
onClick={(e) => e.stopPropagation()}
/>
-
+
))
)}
@@ -1970,61 +2059,55 @@ rel="noopener noreferrer"
{
e.preventDefault();
setResizing('left');
resizeRef.current = { startX: e.clientX, startLeft: leftPanelWidth, startRight: rightPanelWidth };
}}
>
-
+
{/* Center: Chat/Content Area */}
+ {activeTool === 'note' ? (
+
+ setActiveTool('chat')}
+ notebook={notebook}
+ user={effectiveUser}
+ files={files}
+ onSaved={fetchFiles}
+ />
+
+ ) : (
-
-
- setActiveTab('chat')}
- className={`text-sm font-semibold pb-1 transition-all ${activeTab === 'chat' ? 'text-gray-800 border-b-2 border-black' : 'text-gray-400 hover:text-gray-600'}`}
- >
- Chat
-
- setActiveTab('retrieval')}
- className={`text-sm font-semibold pb-1 transition-all ${activeTab === 'retrieval' ? 'text-gray-800 border-b-2 border-black' : 'text-gray-400 hover:text-gray-600'}`}
- >
- Retrieval
-
- setActiveTab('sources')}
- className={`text-sm font-medium pb-1 transition-all ${activeTab === 'sources' ? 'text-gray-800 border-b-2 border-black' : 'text-gray-400 hover:text-gray-600'}`}
- >
- Source management
-
-
+
+
Chat
-
New Chat
-
-
+
Chat history
-
+
- {activeTab === 'chat' && chatSubView === 'history' && (
+ {chatSubView === 'history' && (
Chat history (click to restore)
@@ -2058,17 +2141,22 @@ rel="noopener noreferrer"
)}
- {activeTab === 'chat' && chatSubView === 'current' && (
+ {chatSubView === 'current' && (
+ {chatMessages.length <= 1 && chatMessages[0]?.id === 'welcome' && (
+
+
+
+ )}
{chatMessages.map(msg => (
-
{msg.role === 'assistant' ? : }
-
{msg.role === 'assistant' ? (
@@ -2080,10 +2168,10 @@ rel="noopener noreferrer"
))}
{isChatLoading && (
-
+
-
@@ -2091,275 +2179,65 @@ rel="noopener noreferrer"
)}
- {activeTab === 'retrieval' && (
-
- {!apiConfigured && (
-
-
Configure API URL and API Key in Settings (top right) to use retrieval and embeddings.
-
setShowSettingsModal(true)} className="shrink-0 text-sm font-medium text-amber-700 hover:text-amber-900 underline">Settings
-
- )}
- {retrievalError && (
-
-
{retrievalError}
-
setRetrievalError('')} className="shrink-0 text-sm text-red-500 hover:text-red-700">Close
-
- )}
-
-
-
-
-
-
-
Knowledge base retrieval
-
Enter a question to search over embedded sources.
-
-
-
-
setRetrievalQuery(e.target.value)}
- placeholder="e.g. What is the main contribution of the model?"
- className="w-full bg-white border border-gray-200 rounded-xl px-4 py-3 text-sm text-gray-700 outline-none focus:border-blue-400"
- />
-
-
- TopK
- setRetrievalTopK(Math.max(1, Number(e.target.value || 1)))}
- className="w-16 bg-white border border-gray-200 rounded-lg px-2 py-1 text-xs text-gray-700"
- />
-
-
- Embedding Model
- setRetrievalModel(e.target.value)}
- className="w-56 bg-white border border-gray-200 rounded-lg px-2 py-1 text-xs text-gray-700"
- />
-
-
-
- {retrievalLoading ? 'Searching...' : 'Search'}
-
-
-
- {retrievalError && (
-
{retrievalError}
- )}
-
-
-
-
- {retrievalResults.length === 0 && !retrievalLoading && (
-
- No results
-
- )}
- {retrievalResults.map((item, idx) => (
-
-
-
- Score:{item.score?.toFixed ? item.score.toFixed(3) : item.score}
-
- {item.source_file?.url && (
-
{
- const u = getSameOriginUrl(item.source_file?.url);
- if (u) window.open(u, '_blank', 'noopener,noreferrer');
- }}
- className="text-xs text-blue-600 hover:text-blue-500 underline cursor-pointer bg-transparent border-0 p-0"
- >
- Open source
-
- )}
-
-
- {item.content || '(no content)'}
-
- {item.media?.url && (
-
- {item.type === 'image' ? (
-
- ) : (
-
{
- const u = getSameOriginUrl(item.media?.url);
- if (u) window.open(u, '_blank', 'noopener,noreferrer');
- }}
- className="text-xs text-blue-600 hover:text-blue-500 underline cursor-pointer bg-transparent border-0 p-0"
- >
- View media
-
- )}
-
- )}
-
- ))}
-
-
- )}
-
- {activeTab === 'sources' && (
-
-
-
Vector store files
-
- Sources are managed on the left. This list shows embedded files and status.
-
-
-
- {vectorLoading ? 'Refreshing...' : 'Refresh'}
-
- {vectorError && {vectorError} }
-
-
-
- {vectorLoading && (
-
Loading vector list...
- )}
-
- {!vectorLoading && vectorFiles.length === 0 && (
-
No vector files
- )}
-
-
- {vectorFiles.map((item, idx) => {
- const fileName = getFileNameFromPath(item.original_path);
- const status = item.status || 'unknown';
- const actionKey = item.id || item.original_path;
- const isBusy = actionKey ? vectorActionLoading[actionKey] : false;
- const statusColor =
- status === 'embedded'
- ? 'text-green-600 bg-green-50'
- : status === 'failed'
- ? 'text-red-600 bg-red-50'
- : status === 'skipped'
- ? 'text-gray-600 bg-gray-100'
- : 'text-blue-600 bg-blue-50';
- return (
-
-
-
-
-
-
{fileName || 'Untitled'}
-
- Type: {item.file_type || '-'} | chunks: {item.chunks_count ?? 0} | media: {item.media_desc_count ?? 0}
-
-
-
-
- handleReembedVector(item)}
- disabled={isBusy}
- className="text-xs px-2 py-1 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-100 disabled:opacity-50"
- >
- Re-embed
-
- handleDeleteVector(item)}
- disabled={isBusy}
- className="text-xs px-2 py-1 rounded-lg border border-red-200 text-red-600 hover:bg-red-50 disabled:opacity-50"
- >
- Delete vector
-
-
- {status}
-
-
-
- {item.error && (
-
-
- {/401|Unauthorized/i.test(String(item.error))
- ? 'API auth failed. Check API Key in Settings.'
- : `Error: ${item.error}`}
-
- {(/401|Unauthorized/i.test(String(item.error))) && (
- setShowSettingsModal(true)}
- className="text-blue-600 hover:underline"
- >
- Settings
-
- )}
-
- )}
-
- );
- })}
-
-
- )}
- {activeTab === 'chat' && chatSubView === 'current' && (
+ {chatSubView === 'current' && (
-
setInputMsg(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && handleSendMessage()}
- placeholder={selectedIds.size > 0 ? "Type here..." : "Select files first..."}
- disabled={selectedIds.size === 0}
- className="w-full bg-[#f8f9fa] border border-gray-200 rounded-3xl py-4 pl-6 pr-24 focus:outline-none focus:ring-1 focus:ring-blue-500 text-lg disabled:opacity-50"
- />
-
-
{selectedIds.size} sources
-
-
-
+
+
setInputMsg(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && handleSendMessage()}
+ placeholder={selectedIds.size > 0 ? "Type here..." : "Select files first..."}
+ disabled={selectedIds.size === 0}
+ className="w-full bg-transparent rounded-ios-xl py-4 pl-6 pr-24 focus:outline-none text-lg disabled:opacity-50"
+ />
+
+ {selectedIds.size} sources
+
+
+
+
-
+
Answers may not be fully accurate. Please verify important content.
)}
+ )}
{/* 中-右 拖拽条 */}
+ {activeTool !== 'note' && (
+ <>
{
e.preventDefault();
setResizing('right');
resizeRef.current = { startX: e.clientX, startLeft: leftPanelWidth, startRight: rightPanelWidth };
}}
>
-
+
{/* Right Sidebar: Studio 功能卡片,每卡片「…」翻转进该卡片设置 */}
-
-
Studio
+
+
Studio
{studioPanelView === 'settings' && studioSettingsTool ? (
@@ -2377,6 +2255,8 @@ rel="noopener noreferrer"
{studioSettingsTool === 'mindmap' && 'Mind Map'}
{studioSettingsTool === 'drawio' && 'DrawIO'}
{studioSettingsTool === 'podcast' && 'Knowledge Podcast'}
+ {studioSettingsTool === 'flashcard' && 'Flashcards'}
+ {studioSettingsTool === 'quiz' && 'Quiz'}
{/* {studioSettingsTool === 'video' && 'Video narration'} */}
@@ -2500,6 +2380,17 @@ rel="noopener noreferrer"
})()}
{studioSettingsTool === 'podcast' && (() => {
const c = getStudioConfig('podcast');
+ const speakers = [
+ { value: 'vivian', label: 'Vivian - Bright, slightly edgy young female voice (Chinese)' },
+ { value: 'serena', label: 'Serena - Warm, gentle young female voice (Chinese)' },
+ { value: 'uncle_fu', label: 'Uncle Fu - Seasoned male voice, low and mellow (Chinese)' },
+ { value: 'dylan', label: 'Dylan - Youthful Beijing male voice, clear and natural (Beijing Dialect)' },
+ { value: 'eric', label: 'Eric - Lively Chengdu male voice, slightly husky (Sichuan Dialect)' },
+ { value: 'ryan', label: 'Ryan - Dynamic male voice with strong rhythm (English)' },
+ { value: 'aiden', label: 'Aiden - Sunny American male voice, clear midrange (English)' },
+ { value: 'ono_anna', label: 'Ono Anna - Playful Japanese female voice, light and nimble (Japanese)' },
+ { value: 'sohee', label: 'Sohee - Warm Korean female voice, rich emotion (Korean)' }
+ ];
return (
<>
@@ -2508,15 +2399,101 @@ rel="noopener noreferrer"
- Voice A
- setStudioConfigForTool('podcast', { voiceName: e.target.value })} placeholder="Kore" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500" />
+ Podcast Language
+ setStudioConfigForTool('podcast', { podcastLanguage: e.target.value })} className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
+ 中文
+ English
+
- Voice B
- setStudioConfigForTool('podcast', { voiceNameB: e.target.value })} placeholder="Puck" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500" />
+ Voice Speaker
+ setStudioConfigForTool('podcast', { voiceName: e.target.value })} className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
+ {speakers.map(s => {s.label} )}
+
+
+>
+ );
+ })()}
+ {studioSettingsTool === 'flashcard' && (() => {
+ const c = getStudioConfig('flashcard');
+ return (
+ <>
+
+ Language
+ setStudioConfigForTool('flashcard', { language: e.target.value })} className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
+ English
+ Chinese
+
+
+
+
Number of cards
+
{
+ const v = e.target.value.replace(/\D/g, '');
+ if (v === '') { setStudioConfigForTool('flashcard', { cardCount: '' }); return; }
+ const n = parseInt(v, 10);
+ if (!Number.isNaN(n)) setStudioConfigForTool('flashcard', { cardCount: String(Math.max(5, Math.min(50, n))) });
+ }}
+ onBlur={(e) => {
+ const n = parseInt(e.target.value || '20', 10);
+ if (Number.isNaN(n) || n < 5 || n > 50) setStudioConfigForTool('flashcard', { cardCount: '20' });
+ }}
+ placeholder="5–50"
+ className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
+ />
+
5–50 cards
+
+
+ LLM model
+ setStudioConfigForTool('flashcard', { llmModel: e.target.value })} placeholder="deepseek-v3.2" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500" />
+
+ >
+ );
+ })()}
+ {studioSettingsTool === 'quiz' && (() => {
+ const c = getStudioConfig('quiz');
+ return (
+ <>
+
+ Language
+ setStudioConfigForTool('quiz', { language: e.target.value })} className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500">
+ English
+ Chinese
+
+
+
+
Number of questions
+
{
+ const v = e.target.value.replace(/\D/g, '');
+ if (v === '') { setStudioConfigForTool('quiz', { questionCount: '' }); return; }
+ const n = parseInt(v, 10);
+ if (!Number.isNaN(n)) setStudioConfigForTool('quiz', { questionCount: String(Math.max(5, Math.min(30, n))) });
+ }}
+ onBlur={(e) => {
+ const n = parseInt(e.target.value || '10', 10);
+ if (Number.isNaN(n) || n < 5 || n > 30) setStudioConfigForTool('quiz', { questionCount: '10' });
+ }}
+ placeholder="5–30"
+ className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
+ />
+
5–30 questions
+
+
+ LLM model
+ setStudioConfigForTool('quiz', { llmModel: e.target.value })} placeholder="deepseek-v3.2" className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500" />
>
);
@@ -2541,37 +2518,53 @@ rel="noopener noreferrer"
{studioTools.map(tool => (
-
setActiveTool(tool.id)}
- className={`relative p-4 rounded-xl border transition-all cursor-pointer ${
- activeTool === tool.id ? 'bg-blue-50 border-blue-200' : 'bg-gray-50 border-gray-100 hover:border-blue-200 hover:bg-white'
+ className={`relative p-4 rounded-ios-xl border transition-all cursor-pointer ${
+ activeTool === tool.id ? 'bg-gradient-to-br from-primary/10 to-primary/5 border-primary/30 shadow-ios-sm' : 'bg-ios-gray-50 border-ios-gray-100 hover:border-primary/20 hover:bg-white'
}`}
>
-
+
{tool.icon}
-
{tool.label}
-
{tool.label}
+ { e.stopPropagation(); setStudioSettingsTool(tool.id as StudioToolId); setStudioPanelView('settings'); }}
- className="absolute top-2 right-2 min-w-[36px] min-h-[36px] flex items-center justify-center hover:bg-gray-200 rounded-lg transition-colors"
+ className="absolute top-2 right-2 min-w-[36px] min-h-[36px] flex items-center justify-center hover:bg-ios-gray-200 rounded-ios transition-colors"
title="Tool settings"
>
-
-
-
+
+
+
))}
{activeTool !== 'chat' && activeTool !== 'search' && (
-
handleToolGenerate(activeTool)}
disabled={selectedIds.size === 0 || toolLoading}
- className="w-full py-2.5 mb-4 bg-black text-white text-sm font-medium rounded-xl hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"
+ className="w-full py-2.5 mb-4 bg-gradient-to-r from-gray-900 to-gray-800 text-white text-sm font-medium rounded-ios-xl hover:from-gray-800 hover:to-gray-700 disabled:opacity-50 disabled:cursor-not-allowed shadow-ios-sm flex items-center justify-center gap-2 transition-all"
>
- {toolLoading ? 'Generating…' : 'Generate'}
-
+ {toolLoading ? (
+ <>
+
+ Generating…
+ >
+ ) : (
+ <>
+
+ Generate
+ >
+ )}
+
)}
{/* Tool Output Display */}
@@ -2641,59 +2634,55 @@ rel="noopener noreferrer"
)}
- {/* Flashcard Generator */}
- {activeTool === 'flashcard' && !showFlashcardViewer && (
-
selectedIds.has(f.id)).map(f => f.url || f.name)}
- notebookId={notebook?.id || ''}
- email={effectiveUser.email || ''}
- userId={effectiveUser.id || ''}
- onGenerated={(id: string, cards: any[]) => {
- setFlashcardSetId(id);
- setFlashcards(cards);
- setShowFlashcardViewer(true);
- }}
- />
- )}
-
- {/* Quiz Generator */}
- {activeTool === 'quiz' && !showQuizContainer && (
- selectedIds.has(f.id)).map(f => f.url || f.name)}
- notebookId={notebook?.id || ''}
- email={effectiveUser.email || ''}
- userId={effectiveUser.id || ''}
- onGenerated={(id: string, questions: any[]) => {
- setQuizId(id);
- setQuizQuestions(questions);
- setShowQuizContainer(true);
- }}
- />
- )}
{/* Output Feed */}
{outputFeed.length > 0 && (
-
Outputs
- Latest {outputFeed.length} items
+ Outputs
+ Latest {outputFeed.length} items
- {outputFeed.map(item => (
-
setPreviewOutput(item)}
+ {outputFeed.map((item, feedIdx) => (
+
{
+ if (item.type === 'flashcard' || item.type === 'quiz') {
+ handleLoadSavedSet(item);
+ } else {
+ setPreviewOutput(item);
+ }
+ }}
>
-
{item.title}
-
{item.createdAt}
+
+ {item.type === 'flashcard' && }
+ {item.type === 'quiz' && }
+ {item.title}
+
+
{item.createdAt}
-
+
Sources: {item.sources}
- {item.url ? (
+ {(item.type === 'flashcard' || item.type === 'quiz') ? (
+ {
+ e.stopPropagation();
+ handleLoadSavedSet(item);
+ }}
+ disabled={loadingSetId === item.id}
+ className="text-xs px-2.5 py-1 rounded-full bg-purple-50 text-purple-600 hover:bg-purple-100 transition-colors disabled:opacity-50"
+ >
+ {loadingSetId === item.id ? 'Loading...' : item.type === 'flashcard' ? 'Study' : 'Take Quiz'}
+
+ ) : item.url ? (
<>
{
@@ -2715,10 +2704,10 @@ rel="noopener noreferrer"
>
) : (
- No download link
+ No download link
)}
-
+
))}
@@ -2735,6 +2724,8 @@ rel="noopener noreferrer"
*/}
+ >
+ )}
{/* API 设置弹窗 */}
@@ -2746,7 +2737,7 @@ rel="noopener noreferrer"
{/* 引入弹框:根据以下内容生成音频概览和视频概览 */}
{showIntroduceModal && (
{
setShowIntroduceModal(false);
setDeepResearchSuccess(null);
@@ -2755,12 +2746,25 @@ rel="noopener noreferrer"
setIntroduceUploadSuccess('');
}}
>
-
+
e.stopPropagation()}
>
-
-
+
+
+
Add sources: upload, URL, or paste text
@@ -3045,18 +3049,28 @@ rel="noopener noreferrer"
-
+
)}
{/* 产出预览抽屉 */}
{previewOutput && (
setPreviewOutput(null)}
>
-
+
e.stopPropagation()}
>
{/* Header */}
-
+
-
{previewOutput.title}
-
Sources: {previewOutput.sources}
+
{previewOutput.title}
+
Sources: {previewOutput.sources}
{previewOutput.url && (
@@ -3075,17 +3089,18 @@ rel="noopener noreferrer"
href={previewOutput.url}
target="_blank"
rel="noreferrer"
- className="px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
+ className="px-4 py-2 text-sm font-medium text-primary bg-primary/10 hover:bg-primary/20 rounded-ios transition-colors"
>
Download
)}
- setPreviewOutput(null)}
- className="p-2 hover:bg-gray-200 rounded-lg text-gray-500 hover:text-gray-700 transition-colors"
+ className="p-2 hover:bg-ios-gray-100 rounded-ios text-ios-gray-500 hover:text-ios-gray-700 transition-colors"
>
-
+
@@ -3215,34 +3230,61 @@ rel="noopener noreferrer"
)}
-
+
)}
{/* Flashcard Viewer Modal */}
{showFlashcardViewer && flashcards.length > 0 && (
-
setShowFlashcardViewer(false)}>
-
e.stopPropagation()}>
+
setShowFlashcardViewer(false)}>
+
+ e.stopPropagation()}
+ >
setShowFlashcardViewer(false)}
/>
-
+
)}
{/* Quiz Container Modal */}
{showQuizContainer && quizQuestions.length > 0 && (
-
setShowQuizContainer(false)}>
-
e.stopPropagation()}>
+
setShowQuizContainer(false)}>
+
+ e.stopPropagation()}
+ >
setShowQuizContainer(false)}
/>
-
+
)}
+ >
);
};
diff --git a/frontend_en/src/stores/authStore.ts b/frontend_en/src/stores/authStore.ts
index 0d32f34..24f7a5c 100644
--- a/frontend_en/src/stores/authStore.ts
+++ b/frontend_en/src/stores/authStore.ts
@@ -1,36 +1,175 @@
/**
- * Zustand store for authentication state (Simplified version for notebook).
+ * Zustand store for authentication state.
*/
import { create } from "zustand";
import { User, Session } from "@supabase/supabase-js";
-import { supabase, isSupabaseConfigured } from "../lib/supabase";
+import { getSupabaseClient } from "../lib/supabase";
interface AuthState {
user: User | null;
session: Session | null;
loading: boolean;
-
+ error: string | null;
+ pendingEmail: string | null;
+ needsOtpVerification: boolean;
+
setSession: (session: Session | null) => void;
+ signInWithEmail: (email: string, password: string) => Promise
;
+ signUpWithEmail: (email: string, password: string) => Promise<{ needsVerification: boolean }>;
+ verifyOtp: (email: string, token: string) => Promise;
+ resendOtp: (email: string) => Promise;
signOut: () => Promise;
+ clearError: () => void;
+ clearPendingVerification: () => void;
}
export const useAuthStore = create((set) => ({
user: null,
session: null,
loading: true,
+ error: null,
+ pendingEmail: null,
+ needsOtpVerification: false,
setSession: (session) => {
set({
session,
user: session?.user ?? null,
loading: false,
+ error: null,
+ });
+ },
+
+ signInWithEmail: async (email, password) => {
+ const supabase = getSupabaseClient();
+ if (!supabase) {
+ set({ error: "Supabase is not configured", loading: false });
+ return;
+ }
+
+ set({ loading: true, error: null });
+ const { data, error } = await supabase.auth.signInWithPassword({
+ email: email.trim(),
+ password,
+ });
+
+ if (error) {
+ let friendlyError = error.message;
+ const normalized = error.message.toLowerCase();
+ if (normalized.includes("invalid login credentials")) {
+ friendlyError = "Invalid email or password.";
+ } else if (normalized.includes("email not confirmed")) {
+ friendlyError = "Email is not confirmed yet.";
+ }
+ set({ error: friendlyError, loading: false });
+ return;
+ }
+
+ set({
+ session: data.session,
+ user: data.user,
+ loading: false,
+ error: null,
+ pendingEmail: null,
+ needsOtpVerification: false,
});
},
+ signUpWithEmail: async (email, password) => {
+ const supabase = getSupabaseClient();
+ if (!supabase) {
+ set({ error: "Supabase is not configured", loading: false });
+ return { needsVerification: false };
+ }
+
+ set({ loading: true, error: null });
+ const normalizedEmail = email.trim();
+ const { data, error } = await supabase.auth.signUp({
+ email: normalizedEmail,
+ password,
+ });
+
+ if (error) {
+ set({ error: error.message, loading: false });
+ return { needsVerification: false };
+ }
+
+ if (data.user && !data.session) {
+ set({
+ loading: false,
+ pendingEmail: normalizedEmail,
+ needsOtpVerification: true,
+ error: null,
+ });
+ return { needsVerification: true };
+ }
+
+ set({
+ session: data.session,
+ user: data.user,
+ loading: false,
+ error: null,
+ pendingEmail: null,
+ needsOtpVerification: false,
+ });
+ return { needsVerification: false };
+ },
+
+ verifyOtp: async (email, token) => {
+ const supabase = getSupabaseClient();
+ if (!supabase) {
+ set({ error: "Supabase is not configured", loading: false });
+ return;
+ }
+
+ set({ loading: true, error: null });
+ const { data, error } = await supabase.auth.verifyOtp({
+ email: email.trim(),
+ token: token.trim(),
+ type: "signup",
+ });
+
+ if (error) {
+ set({ error: error.message, loading: false });
+ return;
+ }
+
+ set({
+ session: data.session,
+ user: data.user,
+ loading: false,
+ error: null,
+ pendingEmail: null,
+ needsOtpVerification: false,
+ });
+ },
+
+ resendOtp: async (email) => {
+ const supabase = getSupabaseClient();
+ if (!supabase) {
+ set({ error: "Supabase is not configured", loading: false });
+ return;
+ }
+
+ set({ loading: true, error: null });
+ const { error } = await supabase.auth.resend({
+ type: "signup",
+ email: email.trim(),
+ });
+
+ if (error) {
+ set({ error: error.message, loading: false });
+ return;
+ }
+
+ set({ loading: false, error: null });
+ },
+
signOut: async () => {
- if (!isSupabaseConfigured()) {
- set({ user: null, session: null, loading: false });
+ const supabase = getSupabaseClient();
+ if (!supabase) {
+ set({ user: null, session: null, loading: false, error: null, pendingEmail: null, needsOtpVerification: false });
return;
}
@@ -45,8 +184,14 @@ export const useAuthStore = create((set) => ({
user: null,
session: null,
loading: false,
+ error: null,
+ pendingEmail: null,
+ needsOtpVerification: false,
});
},
+
+ clearError: () => set({ error: null }),
+ clearPendingVerification: () => set({ pendingEmail: null, needsOtpVerification: false, error: null }),
}));
/**
diff --git a/frontend_en/src/styles/design-tokens.ts b/frontend_en/src/styles/design-tokens.ts
new file mode 100644
index 0000000..6158f1c
--- /dev/null
+++ b/frontend_en/src/styles/design-tokens.ts
@@ -0,0 +1,201 @@
+/**
+ * Editorial Workspace Design Tokens
+ *
+ * A bold, magazine-inspired design system that breaks from generic SaaS patterns.
+ * Strong typography, asymmetric layouts, warm neutrals with vibrant accents.
+ */
+
+export const designTokens = {
+ /**
+ * Color System
+ * Base: Warm blacks and off-whites (not pure white/black)
+ * Accent: Electric coral (vibrant, memorable, NOT SaaS blue)
+ */
+ colors: {
+ // Base Neutrals - Warm, editorial feel
+ neutral: {
+ 50: '#FAFAF9', // Soft white (backgrounds)
+ 100: '#F5F5F4', // Lightest gray (cards)
+ 200: '#E7E5E4', // Light gray (borders)
+ 300: '#D6D3D1', // Muted gray
+ 400: '#A8A29E', // Mid gray (secondary text)
+ 500: '#78716C', // Dark gray (tertiary text)
+ 600: '#57534E', // Darker gray
+ 700: '#44403C', // Almost black (secondary headings)
+ 800: '#292524', // Rich black (primary headings)
+ 900: '#1C1917', // True black (body text)
+ },
+
+ // Accent - Electric Coral (distinctive, energetic)
+ accent: {
+ 50: '#FFF1F2',
+ 100: '#FFE4E6',
+ 200: '#FECDD3',
+ 300: '#FDA4AF',
+ 400: '#FB7185',
+ 500: '#F43F5E', // PRIMARY ACCENT
+ 600: '#E11D48', // Hover state
+ 700: '#BE123C',
+ 800: '#9F1239',
+ 900: '#881337',
+ },
+
+ // Success, Warning, Error (editorial-appropriate tones)
+ success: {
+ 50: '#F0FDF4',
+ 500: '#10B981', // Emerald green
+ 600: '#059669',
+ },
+ warning: {
+ 50: '#FFFBEB',
+ 500: '#F59E0B', // Amber
+ 600: '#D97706',
+ },
+ error: {
+ 50: '#FEF2F2',
+ 500: '#EF4444', // Red
+ 600: '#DC2626',
+ },
+
+ // Special: For code/technical elements
+ code: {
+ bg: '#1C1917',
+ text: '#FAFAF9',
+ accent: '#FB7185',
+ }
+ },
+
+ /**
+ * Typography System
+ * Display: Distinctive serif for headlines (editorial feel)
+ * Body: Refined sans-serif for readability
+ * Mono: Technical content
+ */
+ typography: {
+ // Font families
+ fonts: {
+ display: "'Newsreader', 'Georgia', serif", // Editorial serif
+ body: "'Inter', 'Helvetica Neue', sans-serif", // Inter OK for body text
+ mono: "'JetBrains Mono', 'Courier New', monospace",
+ },
+
+ // Type scale (Major Third - 1.25x ratio)
+ scale: {
+ xs: '0.64rem', // 10px
+ sm: '0.8rem', // 13px
+ base: '1rem', // 16px
+ lg: '1.125rem', // 18px
+ xl: '1.25rem', // 20px
+ '2xl': '1.563rem', // 25px
+ '3xl': '1.953rem', // 31px
+ '4xl': '2.441rem', // 39px
+ '5xl': '3.052rem', // 49px
+ '6xl': '3.815rem', // 61px
+ '7xl': '4.768rem', // 76px
+ },
+
+ // Font weights
+ weights: {
+ light: 300,
+ normal: 400,
+ medium: 500,
+ semibold: 600,
+ bold: 700,
+ extrabold: 800,
+ },
+
+ // Line heights
+ leading: {
+ tight: 1.2,
+ snug: 1.375,
+ normal: 1.5,
+ relaxed: 1.625,
+ loose: 1.75,
+ },
+
+ // Letter spacing
+ tracking: {
+ tighter: '-0.05em',
+ tight: '-0.025em',
+ normal: '0',
+ wide: '0.025em',
+ wider: '0.05em',
+ widest: '0.1em',
+ }
+ },
+
+ /**
+ * Spacing System
+ * Base: 4px increments for precision
+ */
+ spacing: {
+ 0: '0',
+ 1: '0.25rem', // 4px
+ 2: '0.5rem', // 8px
+ 3: '0.75rem', // 12px
+ 4: '1rem', // 16px
+ 5: '1.25rem', // 20px
+ 6: '1.5rem', // 24px
+ 8: '2rem', // 32px
+ 10: '2.5rem', // 40px
+ 12: '3rem', // 48px
+ 16: '4rem', // 64px
+ 20: '5rem', // 80px
+ 24: '6rem', // 96px
+ 32: '8rem', // 128px
+ },
+
+ /**
+ * Border Radius
+ * Subtle, not overly rounded (editorial feel)
+ */
+ radius: {
+ none: '0',
+ sm: '2px', // Barely rounded
+ base: '4px', // Default
+ md: '6px',
+ lg: '8px',
+ xl: '12px',
+ full: '9999px',
+ },
+
+ /**
+ * Shadows
+ * Subtle elevation for editorial feel
+ */
+ shadows: {
+ sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
+ base: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
+ md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
+ lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
+ xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',
+ // Editorial special: lifted effect
+ lifted: '0 2px 0 0 rgba(0, 0, 0, 0.1)',
+ liftedHover: '0 4px 0 0 rgba(0, 0, 0, 0.15)',
+ },
+
+ /**
+ * Animation Timing
+ * Quick, responsive transitions
+ */
+ transitions: {
+ fast: '100ms',
+ base: '150ms',
+ slow: '300ms',
+ slower: '500ms',
+ },
+
+ /**
+ * Breakpoints
+ * Mobile-first responsive design
+ */
+ breakpoints: {
+ sm: '640px',
+ md: '768px',
+ lg: '1024px',
+ xl: '1280px',
+ '2xl': '1536px',
+ }
+} as const;
+
+export type DesignTokens = typeof designTokens;
diff --git a/frontend_en/src/types/index.ts b/frontend_en/src/types/index.ts
index 92712cd..46c0717 100644
--- a/frontend_en/src/types/index.ts
+++ b/frontend_en/src/types/index.ts
@@ -27,4 +27,4 @@ export interface ChatMessage {
}
export type SectionType = 'library' | 'upload' | 'output' | 'settings';
-export type ToolType = 'chat' | 'ppt' | 'mindmap' | 'podcast' | 'video' | 'search' | 'drawio' | 'flashcard' | 'quiz';
+export type ToolType = 'chat' | 'ppt' | 'mindmap' | 'podcast' | 'video' | 'search' | 'drawio' | 'flashcard' | 'quiz' | 'note';
diff --git a/frontend_en/tailwind.config.js b/frontend_en/tailwind.config.js
index b6616b9..52621fc 100644
--- a/frontend_en/tailwind.config.js
+++ b/frontend_en/tailwind.config.js
@@ -6,12 +6,171 @@ export default {
],
theme: {
extend: {
+ /**
+ * Editorial Workspace Color System
+ * Warm neutrals + vibrant coral accent
+ */
colors: {
- background: '#f8f9fa',
- primary: '#1a73e8',
- }
+ // Neutral palette (warm blacks and off-whites)
+ neutral: {
+ 50: '#FAFAF9',
+ 100: '#F5F5F4',
+ 200: '#E7E5E4',
+ 300: '#D6D3D1',
+ 400: '#A8A29E',
+ 500: '#78716C',
+ 600: '#57534E',
+ 700: '#44403C',
+ 800: '#292524',
+ 900: '#1C1917',
+ },
+ // Accent - Electric Coral (distinctive, NOT SaaS blue)
+ accent: {
+ 50: '#FFF1F2',
+ 100: '#FFE4E6',
+ 200: '#FECDD3',
+ 300: '#FDA4AF',
+ 400: '#FB7185',
+ 500: '#F43F5E',
+ 600: '#E11D48',
+ 700: '#BE123C',
+ 800: '#9F1239',
+ 900: '#881337',
+ },
+ // Success, Warning, Error
+ success: {
+ 50: '#F0FDF4',
+ 500: '#10B981',
+ 600: '#059669',
+ },
+ warning: {
+ 50: '#FFFBEB',
+ 500: '#F59E0B',
+ 600: '#D97706',
+ },
+ error: {
+ 50: '#FEF2F2',
+ 500: '#EF4444',
+ 600: '#DC2626',
+ },
+ // Legacy support (will gradually remove)
+ primary: '#F43F5E',
+ background: '#FAFAF9',
+ },
+
+ /**
+ * Typography
+ * Editorial: Distinctive serif for headlines
+ * Body: Refined Inter for readability
+ */
+ fontFamily: {
+ display: ['"Newsreader"', 'Georgia', 'serif'],
+ sans: ['Inter', 'Helvetica Neue', 'Arial', 'sans-serif'],
+ mono: ['"JetBrains Mono"', '"Courier New"', 'monospace'],
+ },
+
+ /**
+ * Font sizes with proper line heights
+ * Major Third scale (1.25x ratio)
+ */
+ fontSize: {
+ xs: ['0.64rem', { lineHeight: '1rem' }],
+ sm: ['0.8rem', { lineHeight: '1.25rem' }],
+ base: ['1rem', { lineHeight: '1.5rem' }],
+ lg: ['1.125rem', { lineHeight: '1.75rem' }],
+ xl: ['1.25rem', { lineHeight: '1.875rem' }],
+ '2xl': ['1.563rem', { lineHeight: '2rem' }],
+ '3xl': ['1.953rem', { lineHeight: '2.375rem' }],
+ '4xl': ['2.441rem', { lineHeight: '2.875rem' }],
+ '5xl': ['3.052rem', { lineHeight: '3.5rem' }],
+ '6xl': ['3.815rem', { lineHeight: '4rem' }],
+ '7xl': ['4.768rem', { lineHeight: '1' }],
+ },
+
+ /**
+ * Letter spacing (tracking)
+ * Tighter for large text, looser for small
+ */
+ letterSpacing: {
+ tighter: '-0.05em',
+ tight: '-0.025em',
+ normal: '0',
+ wide: '0.025em',
+ wider: '0.05em',
+ widest: '0.1em',
+ },
+
+ /**
+ * Border radius
+ * Subtle, not overly rounded
+ */
+ borderRadius: {
+ sm: '2px',
+ DEFAULT: '4px',
+ md: '6px',
+ lg: '8px',
+ xl: '12px',
+ full: '9999px',
+ },
+
+ /**
+ * Shadows
+ * Editorial style: lifted effect
+ */
+ boxShadow: {
+ sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
+ DEFAULT: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
+ md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
+ lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
+ xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',
+ // Editorial special: lifted effect for buttons/cards
+ lifted: '0 2px 0 0 rgba(0, 0, 0, 0.1)',
+ 'lifted-hover': '0 4px 0 0 rgba(0, 0, 0, 0.15)',
+ },
+
+ /**
+ * Transitions
+ */
+ transitionDuration: {
+ fast: '100ms',
+ DEFAULT: '150ms',
+ slow: '300ms',
+ slower: '500ms',
+ },
+
+ /**
+ * Animation keyframes
+ */
+ keyframes: {
+ 'fade-in': {
+ '0%': { opacity: '0' },
+ '100%': { opacity: '1' },
+ },
+ 'slide-up': {
+ '0%': { transform: 'translateY(10px)', opacity: '0' },
+ '100%': { transform: 'translateY(0)', opacity: '1' },
+ },
+ 'slide-down': {
+ '0%': { transform: 'translateY(-10px)', opacity: '0' },
+ '100%': { transform: 'translateY(0)', opacity: '1' },
+ },
+ 'scale-in': {
+ '0%': { transform: 'scale(0.95)', opacity: '0' },
+ '100%': { transform: 'scale(1)', opacity: '1' },
+ },
+ shimmer: {
+ '0%': { backgroundPosition: '-200% 0' },
+ '100%': { backgroundPosition: '200% 0' },
+ },
+ },
+ animation: {
+ 'fade-in': 'fade-in 150ms ease-out',
+ 'slide-up': 'slide-up 200ms ease-out',
+ 'slide-down': 'slide-down 200ms ease-out',
+ 'scale-in': 'scale-in 150ms ease-out',
+ shimmer: 'shimmer 1.5s ease-in-out infinite',
+ },
},
},
plugins: [],
}
-
diff --git a/frontend_en/vite.config.ts b/frontend_en/vite.config.ts
index 0a58087..f7027e1 100644
--- a/frontend_en/vite.config.ts
+++ b/frontend_en/vite.config.ts
@@ -4,16 +4,17 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
- port: 3001,
- open: true,
+ host: '0.0.0.0', // 允许外部访问
+ port: 3000,
+ open: false, // 服务器环境不自动打开浏览器
allowedHosts: true,
proxy: {
'/api': {
- target: 'http://localhost:8211',
+ target: 'http://localhost:8213',
changeOrigin: true,
},
'/outputs': {
- target: 'http://localhost:8211',
+ target: 'http://localhost:8213',
changeOrigin: true,
},
},
diff --git a/frontend_zh/INTEGRATION_GUIDE.md b/frontend_zh/INTEGRATION_GUIDE.md
deleted file mode 100644
index 1ea2f33..0000000
--- a/frontend_zh/INTEGRATION_GUIDE.md
+++ /dev/null
@@ -1,340 +0,0 @@
-# NotebookLM v2 知识库集成指南
-
-## 📋 概述
-
-本项目成功将 `frontend-workflow` 知识库的后端功能集成到 `frontend-v2` (NotebookLM) 项目中。
-
-**核心目标**:
-- ✅ 保持 Notebook 的前端界面风格
-- ✅ 接入知识库的后端 API
-- ✅ 支持文件上传、智能问答、PPT生成、思维导图等功能
-- ✅ 不改动后端逻辑,前端适配后端
-
----
-
-## 🔧 完成的工作
-
-### 1. 项目配置
-
-#### 1.1 创建的配置文件
-
-- **`vite.config.ts`** - Vite 配置,包含后端代理
- - 端口: 3001
- - 后端代理: `http://localhost:8210`
-
-- **`src/config/api.ts`** - API 配置文件
- - API Key 管理
- - apiFetch 封装函数
-
-- **`src/lib/supabase.ts`** - Supabase 客户端
- - 支持认证和数据库操作
- - 未配置时自动降级
-
-- **`src/stores/authStore.ts`** - 认证状态管理
- - 使用 Zustand 管理用户状态
- - 支持 session 管理
-
-- **`src/services/apiSettingsService.ts`** - API 设置服务
- - 管理 LLM API 配置
-
-#### 1.2 依赖更新
-
-在 `package.json` 中添加了:
-```json
-{
- "@supabase/supabase-js": "^2.89.0",
- "mermaid": "^10.6.1",
- "zustand": "^4.4.7"
-}
-```
-
-### 2. 核心组件重构
-
-#### 2.1 NotebookView.tsx (二级界面)
-
-**原有功能**:静态展示的笔记本界面
-
-**新增功能**:
-1. **文件管理**
- - 从 Supabase 获取用户上传的文件
- - 支持文件选择(多选)
- - 支持文件上传
-
-2. **智能问答**
- - 基于选中文件的 RAG 问答
- - 显示历史对话
- - 实时流式响应
-
-3. **Studio 工具**
- - PPT 生成
- - 思维导图生成(支持 Mermaid 渲染)
- - 知识播客生成
- - 视频讲解生成
- - 语义检索
-
-4. **UI 保持**
- - 保留了原有的 NotebookLM 风格
- - 三栏布局(来源、对话、工具)
- - Tab 切换(对话、检索、来源管理)
-
-#### 2.2 App.tsx
-
-添加了认证初始化逻辑:
-- 如果 Supabase 已配置,使用真实认证
-- 如果未配置,创建模拟用户(方便开发)
-
-#### 2.3 Dashboard.tsx (一级界面)
-
-保持原有设计,展示:
-- 精选笔记本(写死的数据)
-- 最近打开的笔记本(写死的数据)
-- 新建笔记本入口
-
-### 3. 工具组件
-
-#### 3.1 MermaidPreview.tsx
-
-从 `frontend-workflow` 复制的思维导图预览组件:
-- 支持 Mermaid 代码渲染
-- 支持放大预览
-- 支持下载 SVG 和源代码
-- 支持编辑和实时预览
-
-### 4. 类型定义
-
-更新 `src/types/index.ts`:
-```typescript
-export type MaterialType = 'image' | 'doc' | 'video' | 'link' | 'audio';
-export interface KnowledgeFile { ... }
-export interface ChatMessage { ... }
-export type SectionType = 'library' | 'upload' | 'output' | 'settings';
-export type ToolType = 'chat' | 'ppt' | 'mindmap' | 'podcast' | 'video' | 'search';
-```
-
----
-
-## 🚀 使用指南
-
-### 启动项目
-
-```bash
-cd /data/users/szl/opennotebook/Paper2Any/frontend-v2
-
-# 安装依赖(已完成)
-npm install
-
-# 启动开发服务器
-npm run dev
-```
-
-访问: `http://localhost:3001`
-
-### 环境变量配置
-
-如果需要使用 Supabase,创建 `.env` 文件:
-
-```env
-VITE_SUPABASE_URL=your_supabase_url
-VITE_SUPABASE_ANON_KEY=your_anon_key
-VITE_API_KEY=df-internal-2024-workflow-key
-```
-
-如果不配置,将使用模拟用户。
-
-### 后端服务
-
-确保后端服务运行在 `http://localhost:8210`
-
-主要 API 端点:
-- `POST /api/v1/kb/upload` - 文件上传
-- `POST /api/v1/kb/chat` - 智能问答
-- `POST /api/v1/kb/mindmap` - 思维导图生成
-- `POST /api/v1/kb/ppt` - PPT 生成
-- `POST /api/v1/kb/podcast` - 播客生成
-
----
-
-## 📁 项目结构
-
-```
-frontend-v2/
-├── src/
-│ ├── components/
-│ │ └── knowledge-base/
-│ │ └── tools/
-│ │ └── MermaidPreview.tsx # 思维导图组件
-│ ├── config/
-│ │ └── api.ts # API 配置
-│ ├── lib/
-│ │ └── supabase.ts # Supabase 客户端
-│ ├── pages/
-│ │ ├── Dashboard.tsx # 一级界面
-│ │ └── NotebookView.tsx # 二级界面(核心)
-│ ├── services/
-│ │ └── apiSettingsService.ts # API 设置
-│ ├── stores/
-│ │ └── authStore.ts # 认证状态
-│ ├── types/
-│ │ └── index.ts # 类型定义
-│ ├── App.tsx # 主应用
-│ └── main.tsx # 入口
-├── vite.config.ts # Vite 配置
-├── package.json # 依赖配置
-└── README.md # 项目说明
-```
-
----
-
-## 🔄 适配逻辑
-
-### 前端适配后端
-
-1. **文件上传**
- - 前端: 通过 ` ` 上传
- - 后端: `POST /api/v1/kb/upload`
- - 数据库: 保存到 Supabase `knowledge_base_files` 表
-
-2. **文件管理**
- - 前端: 从 Supabase 读取用户的文件列表
- - 显示: 左侧来源面板
- - 选择: 支持多选,自动选中所有文件
-
-3. **智能问答**
- - 输入: 用户问题 + 选中的文件
- - 后端: `POST /api/v1/kb/chat`
- - 参数: `{ files, query, history, api_url, api_key }`
- - 响应: `{ answer, file_analyses }`
-
-4. **工具生成**
- - 思维导图: `POST /api/v1/kb/mindmap` -> 返回 `mindmap_code`
- - PPT: `POST /api/v1/kb/ppt` -> 返回 `ppt_url`
- - 播客: `POST /api/v1/kb/podcast` -> 返回 `audio_url`
-
-### 数据流
-
-```
-用户上传文件
- ↓
-前端调用 /api/v1/kb/upload
- ↓
-后端保存文件,返回 URL
- ↓
-前端保存到 Supabase
- ↓
-用户选择文件,发起问答/生成
- ↓
-前端调用相应 API,传入选中文件的 URL
- ↓
-后端处理,返回结果
- ↓
-前端展示(对话框/工具面板)
-```
-
----
-
-## ✅ 功能清单
-
-### 已实现功能
-
-- [x] 文件上传(支持 PDF, DOCX, PPTX, 图片等)
-- [x] 文件列表展示
-- [x] 文件多选
-- [x] 智能问答(RAG)
-- [x] 思维导图生成(Mermaid 渲染)
-- [x] PPT 生成
-- [x] 播客生成
-- [x] 前端样式保持 NotebookLM 风格
-- [x] 认证状态管理(支持 Supabase 或模拟用户)
-- [x] 后端 API 集成
-
-### 待完善功能
-
-- [ ] 来源管理(删除、重新索引等)
-- [ ] 多模态检索功能实现
-- [ ] 视频讲解生成完整实现
-- [ ] 一级界面(Dashboard)接入真实数据
-- [ ] API 设置界面
-- [ ] 错误处理优化
-- [ ] 加载状态优化
-
----
-
-## 🎯 核心原则
-
-1. **前端适配后端**
- - 不修改后端 API
- - 前端调用现有接口
- - 数据格式遵循后端规范
-
-2. **保持 UI 风格**
- - Notebook 的界面设计
- - 三栏布局
- - 原有的交互逻辑
-
-3. **复用知识库功能**
- - 文件管理
- - 智能工具
- - 认证系统
-
----
-
-## 📝 注意事项
-
-1. **后端依赖**
- - 必须运行 Paper2Any 后端服务
- - 默认端口: 8210
- - 确保后端 API 可访问
-
-2. **数据库**
- - 如果使用 Supabase,需要配置环境变量
- - 表结构: `knowledge_base_files`
- - 字段: user_id, file_name, file_type, storage_path 等
-
-3. **开发模式**
- - 未配置 Supabase 时,使用模拟用户
- - 模拟用户 ID: `dev-user-001`
- - 适合纯前端开发
-
-4. **生产环境**
- - 必须配置 Supabase
- - 必须配置 LLM API
- - 需要真实的认证系统
-
----
-
-## 🐛 常见问题
-
-### Q: 上传文件失败?
-A: 检查后端服务是否运行,检查网络代理配置
-
-### Q: 无法登录?
-A: 如果未配置 Supabase,会自动使用模拟用户
-
-### Q: 生成工具无响应?
-A: 检查是否选中了文件,检查后端 API 状态
-
-### Q: 思维导图不显示?
-A: 检查 mermaid 依赖是否安装,检查返回的代码格式
-
----
-
-## 📞 技术支持
-
-如有问题,请检查:
-1. 后端服务日志
-2. 浏览器控制台错误
-3. 网络请求状态
-4. 环境变量配置
-
----
-
-## 🎉 总结
-
-本次集成成功将知识库的强大后端功能与 NotebookLM 优雅的前端界面结合,实现了:
-
-- ✅ **无缝集成** - 前端完全适配后端 API
-- ✅ **功能完整** - 支持所有核心知识库工具
-- ✅ **开发友好** - 支持模拟用户,方便调试
-- ✅ **可扩展** - 易于添加新功能
-
-现在您可以使用 NotebookLM 的界面,享受知识库的全部功能!
diff --git a/frontend_zh/PREVIEW_FEATURE.md b/frontend_zh/PREVIEW_FEATURE.md
deleted file mode 100644
index 30599f3..0000000
--- a/frontend_zh/PREVIEW_FEATURE.md
+++ /dev/null
@@ -1,139 +0,0 @@
-# 产出预览功能说明
-
-## ✅ 已完成的功能
-
-### 1. 产出内容信息流
-
-在右侧 Studio 面板下方显示"产出内容"列表:
-
-- **PPT 生成** - 显示来源文件,提供预览和下载按钮
-- **思维导图** - 显示来源文件,提供预览和下载按钮
-- **播客生成** - 显示来源文件,提供预览和下载按钮
-
-每条产出记录包含:
-- 标题(工具类型)
-- 来源文件列表
-- 生成时间
-- 预览和下载按钮
-
----
-
-### 2. 点击预览功能
-
-点击产出内容卡片后,会打开全屏预览模态框:
-
-#### PPT 预览
-- 使用 iframe 直接嵌入 PDF 预览
-- 支持下载 PDF 文件
-- 大屏展示,便于查看内容
-
-#### 播客预览
-- 精美的音频播放器界面
-- 支持自动播放
-- 显示播客标题和生成时间
-- 支持下载音频文件
-
-#### 思维导图预览
-- 使用 Mermaid 渲染引擎
-- 支持放大缩小
-- 支持查看代码/图形切换
-- 支持下载 SVG 和源代码
-
----
-
-## 🎨 界面特点
-
-### 产出列表卡片
-- 白色背景,圆角设计
-- hover 悬浮效果
-- 点击整个卡片即可预览
-- 独立的下载按钮
-
-### 预览模态框
-- 全屏半透明背景
-- 居中展示,最大化利用空间
-- 支持点击外部关闭
-- 根据不同类型展示不同内容
-
----
-
-## 📋 使用流程
-
-1. **上传文件** → 左侧来源列表
-2. **选择文件** → 勾选需要的文件
-3. **点击工具卡片** → PPT/思维导图/播客
-4. **等待生成** → 右侧显示加载状态
-5. **查看产出** → 产出内容列表自动新增
-6. **点击预览** → 打开预览界面
-7. **下载文件** → 点击下载按钮
-
----
-
-## 🔧 技术实现
-
-### 状态管理
-```typescript
-const [outputFeed, setOutputFeed] = useState>([]);
-
-const [previewOutput, setPreviewOutput] = useState<...>(null);
-```
-
-### 产出记录
-生成成功后自动追加到 `outputFeed`:
-- 从后端返回值提取 URL
-- 保存来源文件信息
-- 思维导图额外保存 mermaid 代码
-
-### 预览渲染
-- **PPT**:``
-- **播客**:` `
-- **思维导图**:` `
-
----
-
-## 🎯 下一步优化建议
-
-### 可选增强功能
-
-1. **产出历史持久化**
- - 保存到 localStorage
- - 刷新页面后仍然可见
-
-2. **产出管理**
- - 删除单条产出
- - 清空全部产出
- - 导出产出列表
-
-3. **更多预览选项**
- - PDF 翻页控制
- - 音频播放速度调节
- - 思维导图编辑功能
-
-4. **产出分享**
- - 生成分享链接
- - 复制产出 URL
- - 导出到其他平台
-
----
-
-## ✨ 当前状态
-
-✅ **PPT 生成** - 完全正常,支持预览和下载
-✅ **思维导图** - 完全正常,支持预览和下载
-✅ **播客生成** - 完全正常,支持预览和下载
-✅ **智能问答** - 完全正常
-✅ **产出预览** - 全部实现
-
----
-
-## 🎉 功能已完成
-
-Notebook 前端现在已经完全集成知识库功能,并增加了优雅的产出预览界面!
diff --git a/frontend_zh/QUICK_FIX.md b/frontend_zh/QUICK_FIX.md
deleted file mode 100644
index a4435ff..0000000
--- a/frontend_zh/QUICK_FIX.md
+++ /dev/null
@@ -1,203 +0,0 @@
-# 🔧 快速修复说明
-
-## 问题描述
-
-```
-TypeError: Cannot read properties of null (reading 'from')
-```
-
-**原因**:Supabase 未配置,`supabase` 客户端为 `null`
-
----
-
-## ✅ 已修复
-
-修改了 `NotebookView.tsx`,添加了降级处理:
-
-### 修复内容
-
-1. **导入检查函数**
- ```typescript
- import { supabase, isSupabaseConfigured } from '../lib/supabase';
- ```
-
-2. **文件获取逻辑**
- - **有 Supabase**:从数据库读取
- - **无 Supabase**:从 localStorage 读取
-
-3. **文件上传逻辑**
- - **有 Supabase**:保存到数据库
- - **无 Supabase**:保存到 localStorage
-
-### 工作模式
-
-#### 模式 1:完整模式(推荐生产环境)
-```env
-# .env 文件
-VITE_SUPABASE_URL=your_supabase_url
-VITE_SUPABASE_ANON_KEY=your_anon_key
-```
-- ✅ 数据持久化到云端
-- ✅ 多设备同步
-- ✅ 完整的用户管理
-
-#### 模式 2:开发模式(当前使用)
-```env
-# 不配置 Supabase
-```
-- ✅ 使用 localStorage 存储
-- ✅ 无需数据库
-- ✅ 快速开发测试
-- ⚠️ 数据仅在当前浏览器保存
-
----
-
-## 🚀 现在可以使用了
-
-### 测试步骤
-
-1. **刷新页面**
- ```bash
- # 浏览器中按 F5 刷新
- ```
-
-2. **上传文件**
- - 点击"上传文件"按钮
- - 选择一个文件(PDF、图片等)
- - 等待上传完成
-
-3. **查看文件**
- - 上传成功后,文件会显示在左侧来源列表
- - 文件信息保存在 localStorage 中
-
-4. **使用功能**
- - 选择文件(自动全选)
- - 在对话框中提问
- - 使用右侧工具(思维导图、PPT等)
-
----
-
-## 📊 数据存储
-
-### localStorage 结构
-
-**键名**:`kb_files_${user_id}`
-
-**值示例**:
-```json
-[
- {
- "id": "file-1234567890",
- "name": "example.pdf",
- "type": "doc",
- "size": "2.5 MB",
- "uploadTime": "2024/1/31 下午3:45:30",
- "isEmbedded": false,
- "desc": "",
- "url": "/outputs/kb_data/dev@notebook.local/example.pdf"
- }
-]
-```
-
-### 查看存储的数据
-
-打开浏览器控制台:
-```javascript
-// 查看存储的文件
-localStorage.getItem('kb_files_dev-user-001')
-
-// 清空数据(重新开始)
-localStorage.removeItem('kb_files_dev-user-001')
-```
-
----
-
-## 🔄 后续升级
-
-如果以后需要使用 Supabase:
-
-1. **注册 Supabase 账号**
- - 访问 https://supabase.com
- - 创建项目
-
-2. **获取配置**
- - 复制 Project URL
- - 复制 Anon Key
-
-3. **配置环境变量**
- ```env
- VITE_SUPABASE_URL=https://xxx.supabase.co
- VITE_SUPABASE_ANON_KEY=eyJxxx...
- ```
-
-4. **重启开发服务器**
- ```bash
- npm run dev
- ```
-
-5. **数据会自动切换到云端**
-
----
-
-## ⚙️ 当前配置状态
-
-查看当前是否配置了 Supabase:
-
-**方法 1**:浏览器控制台
-```javascript
-// 检查配置
-console.log(import.meta.env.VITE_SUPABASE_URL)
-// 如果显示 undefined,说明未配置
-```
-
-**方法 2**:查看提示
-- 打开浏览器控制台
-- 如果看到 `[Supabase] Not configured. Auth, quotas, and cloud storage disabled.`
-- 说明正在使用开发模式
-
----
-
-## 🎯 总结
-
-现在应用可以在两种模式下工作:
-
-| 功能 | 有 Supabase | 无 Supabase |
-|------|------------|-------------|
-| 文件上传 | ✅ 云端存储 | ✅ 本地存储 |
-| 文件列表 | ✅ 数据库 | ✅ localStorage |
-| 智能问答 | ✅ | ✅ |
-| 工具生成 | ✅ | ✅ |
-| 多设备同步 | ✅ | ❌ |
-| 数据持久性 | ✅ 永久 | ⚠️ 清除浏览器数据会丢失 |
-
-**推荐**:开发测试使用当前模式,生产环境配置 Supabase。
-
----
-
-## 🐛 问题排查
-
-### 如果还有问题
-
-1. **清除浏览器缓存**
- - 按 Ctrl+Shift+Delete
- - 清除缓存和 Cookie
-
-2. **重启开发服务器**
- ```bash
- # Ctrl+C 停止
- npm run dev
- ```
-
-3. **检查后端**
- - 确保后端运行在 http://localhost:8210
- - 查看后端日志
-
-4. **查看控制台**
- - 打开浏览器开发者工具 (F12)
- - 查看 Console 和 Network 标签
-
----
-
-## ✨ 现在试试吧!
-
-刷新页面,上传一个文件,开始使用!🚀
diff --git a/frontend_zh/README.md b/frontend_zh/README.md
deleted file mode 100644
index cd4ddee..0000000
--- a/frontend_zh/README.md
+++ /dev/null
@@ -1,122 +0,0 @@
-# NotebookLM v2 - 知识库集成版
-
-这是 NotebookLM 的前端界面,已集成知识库的后端功能。
-
-## 功能特性
-
-- 📚 知识库文件管理(上传、查看、选择)
-- 💬 智能问答(基于选中的文件)
-- 🎨 PPT 生成
-- 🧠 思维导图生成
-- 🎙️ 知识播客生成
-- 🎬 视频讲解生成
-- 🔍 语义检索
-
-## 技术栈
-
-- React 18
-- TypeScript
-- Vite
-- Tailwind CSS
-- Supabase (用户认证和数据存储)
-- Zustand (状态管理)
-- Mermaid (思维导图渲染)
-
-## 安装
-
-```bash
-# 安装依赖
-npm install
-
-# 复制环境变量文件
-cp .env.example .env
-
-# 编辑 .env 文件,填入你的配置
-```
-
-## 环境变量配置
-
-创建 `.env` 文件,内容如下:
-
-```env
-# Supabase 配置 (可选 - 如果不设置,将使用模拟用户)
-VITE_SUPABASE_URL=your_supabase_url_here
-VITE_SUPABASE_ANON_KEY=your_supabase_anon_key_here
-
-# 后端 API 配置
-VITE_API_KEY=df-internal-2024-workflow-key
-
-# LLM 提供商配置
-VITE_DEFAULT_LLM_API_URL=https://api.apiyi.com/v1
-```
-
-## 开发
-
-```bash
-# 启动开发服务器 (默认端口 3001)
-npm run dev
-```
-
-## 构建
-
-```bash
-# 构建生产版本
-npm run build
-
-# 预览生产版本
-npm run preview
-```
-
-## 项目结构
-
-```
-src/
-├── components/
-│ └── knowledge-base/
-│ └── tools/ # 知识库工具组件
-├── config/
-│ └── api.ts # API 配置
-├── lib/
-│ └── supabase.ts # Supabase 客户端
-├── pages/
-│ ├── Dashboard.tsx # 一级界面(笔记本列表)
-│ └── NotebookView.tsx # 二级界面(知识库交互)
-├── services/
-│ └── apiSettingsService.ts # API 设置服务
-├── stores/
-│ └── authStore.ts # 认证状态管理
-├── types/
-│ └── index.ts # TypeScript 类型定义
-├── App.tsx # 主应用组件
-└── main.tsx # 应用入口
-```
-
-## 后端 API
-
-本项目依赖后端 API 服务(默认运行在 `http://localhost:8210`)。
-
-主要 API 端点:
-- `POST /api/v1/kb/upload` - 文件上传
-- `POST /api/v1/kb/chat` - 智能问答
-- `POST /api/v1/kb/mindmap` - 思维导图生成
-- `POST /api/v1/kb/ppt` - PPT 生成
-- `POST /api/v1/kb/podcast` - 播客生成
-
-## 注意事项
-
-1. 确保后端服务正在运行
-2. 如果不配置 Supabase,将使用模拟用户进行开发
-3. 文件上传需要后端服务支持
-4. LLM API 密钥需要在设置中配置或使用环境变量
-
-## 开发模式
-
-如果没有配置 Supabase,应用会自动创建一个模拟用户:
-- ID: `dev-user-001`
-- Email: `dev@notebook.local`
-
-这样可以在没有数据库的情况下进行前端开发。
-
-## License
-
-MIT
diff --git a/frontend_zh/index.html b/frontend_zh/index.html
index af81a7f..bb0f21d 100644
--- a/frontend_zh/index.html
+++ b/frontend_zh/index.html
@@ -4,7 +4,7 @@
- open NoteBookLM
+ OpenNotebookLM
diff --git a/frontend_zh/public/logo.jpg b/frontend_zh/public/logo.jpg
new file mode 100644
index 0000000..28d4a8f
Binary files /dev/null and b/frontend_zh/public/logo.jpg differ
diff --git a/frontend_zh/public/logo.png b/frontend_zh/public/logo.png
index 4b1e36f..d4fbf54 100644
Binary files a/frontend_zh/public/logo.png and b/frontend_zh/public/logo.png differ
diff --git a/frontend_zh/public/logo_banner.jpg b/frontend_zh/public/logo_banner.jpg
new file mode 100644
index 0000000..28d4a8f
Binary files /dev/null and b/frontend_zh/public/logo_banner.jpg differ
diff --git a/frontend_zh/public/logo_small.png b/frontend_zh/public/logo_small.png
new file mode 100644
index 0000000..03e8631
Binary files /dev/null and b/frontend_zh/public/logo_small.png differ
diff --git a/frontend_zh/src/App.tsx b/frontend_zh/src/App.tsx
index 8c0b112..420f594 100644
--- a/frontend_zh/src/App.tsx
+++ b/frontend_zh/src/App.tsx
@@ -1,41 +1,53 @@
import React, { useState, useEffect } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
import Dashboard from './pages/Dashboard';
import NotebookView from './pages/NotebookView';
+import AuthPage from './pages/AuthPage';
import { useAuthStore } from './stores/authStore';
-import { supabase, isSupabaseConfigured } from './lib/supabase';
+import { initSupabase, getSupabaseClient } from './lib/supabase';
+import { Loader2 } from 'lucide-react';
+
+const pageVariants = {
+ initial: (direction: number) => ({
+ x: direction > 0 ? 20 : -20,
+ opacity: 0,
+ }),
+ animate: {
+ x: 0,
+ opacity: 1,
+ transition: { type: 'spring', stiffness: 300, damping: 30 },
+ },
+ exit: (direction: number) => ({
+ x: direction > 0 ? -20 : 20,
+ opacity: 0,
+ transition: { duration: 0.2 },
+ }),
+};
function App() {
const [currentView, setCurrentView] = useState<'dashboard' | 'notebook'>('dashboard');
const [selectedNotebook, setSelectedNotebook] = useState(null);
const [dashboardRefresh, setDashboardRefresh] = useState(0);
- const { setSession } = useAuthStore();
+ const [direction, setDirection] = useState(0);
+ const [supabaseConfigured, setSupabaseConfigured] = useState(null);
+ const { user, loading, setSession } = useAuthStore();
+
+ // Initialize Supabase from backend config
+ useEffect(() => {
+ initSupabase().then(setSupabaseConfigured);
+ }, []);
// Initialize auth session
useEffect(() => {
- if (!isSupabaseConfigured()) {
- // 不做用户管理:使用 default,数据从 outputs 取
- const mockUser = {
- id: 'default',
- email: 'default',
- created_at: new Date().toISOString(),
- app_metadata: {},
- user_metadata: {},
- aud: 'authenticated',
- role: 'authenticated'
- };
-
- const mockSession = {
- access_token: 'mock-token',
- refresh_token: 'mock-refresh',
- expires_in: 3600,
- token_type: 'bearer',
- user: mockUser as any
- };
-
- setSession(mockSession as any);
+ if (supabaseConfigured === null) return;
+ if (!supabaseConfigured) {
+ setSession(null);
return;
}
+ const supabase = getSupabaseClient();
+ if (!supabase) return;
+
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
@@ -49,32 +61,75 @@ function App() {
});
return () => subscription.unsubscribe();
- }, [setSession]);
+ }, [setSession, supabaseConfigured]);
+
+ useEffect(() => {
+ if (!user) {
+ setCurrentView('dashboard');
+ setSelectedNotebook(null);
+ }
+ }, [user]);
+
+ if (loading || supabaseConfigured === null) {
+ return (
+
+
+
+ );
+ }
+
+ // If Supabase is configured but user is not logged in, show auth page
+ if (supabaseConfigured && !user) {
+ return ;
+ }
+
+ // If Supabase is not configured, allow trial mode (no auth required)
const handleOpenNotebook = (notebook: any) => {
setSelectedNotebook(notebook);
+ setDirection(1);
setCurrentView('notebook');
};
const handleBackToDashboard = () => {
+ setDirection(-1);
setCurrentView('dashboard');
setSelectedNotebook(null);
setDashboardRefresh((n) => n + 1);
};
return (
-
- {currentView === 'dashboard' ? (
-
- ) : (
-
- )}
+
+
+ {currentView === 'dashboard' ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
);
}
export default App;
-
diff --git a/frontend_zh/src/components/SettingsModal.tsx b/frontend_zh/src/components/SettingsModal.tsx
index 3b13534..adc42cf 100644
--- a/frontend_zh/src/components/SettingsModal.tsx
+++ b/frontend_zh/src/components/SettingsModal.tsx
@@ -125,14 +125,14 @@ export const SettingsModal: React.FC
= ({ open, onClose }) =
博查 Bocha
- {(searchProvider === 'serpapi' || searchProvider === 'bocha') && (
+ {(searchProvider === 'serper' || searchProvider === 'serpapi' || searchProvider === 'bocha') && (
Search API Key
setSearchApiKey(e.target.value)}
- placeholder={searchProvider === 'bocha' ? '博查 API Key' : 'SerpAPI Key'}
+ placeholder={searchProvider === 'bocha' ? '博查 API Key' : searchProvider === 'serper' ? 'Serper API Key' : 'SerpAPI Key'}
className="w-full px-4 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
diff --git a/frontend_zh/src/components/flashcards/FlashcardViewer.tsx b/frontend_zh/src/components/flashcards/FlashcardViewer.tsx
index 6c0b21f..1640520 100644
--- a/frontend_zh/src/components/flashcards/FlashcardViewer.tsx
+++ b/frontend_zh/src/components/flashcards/FlashcardViewer.tsx
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
import { RotateCw, ChevronLeft, ChevronRight } from 'lucide-react';
interface Flashcard {
@@ -14,6 +15,8 @@ interface FlashcardViewerProps {
onClose: () => void;
}
+const springFlip = { type: 'spring', stiffness: 300, damping: 25 };
+
export const FlashcardViewer: React.FC = ({
flashcards,
onClose,
@@ -22,6 +25,7 @@ export const FlashcardViewer: React.FC = ({
const [isFlipped, setIsFlipped] = useState(false);
const currentCard = flashcards[currentIndex];
+ const progress = ((currentIndex + 1) / flashcards.length) * 100;
const handleNext = () => {
if (currentIndex < flashcards.length - 1) {
@@ -45,49 +49,51 @@ export const FlashcardViewer: React.FC = ({
{/* Header */}
-
Flashcard Study
- Flashcard Study
+
Close
-
+
- {/* Progress Indicator */}
+ {/* iOS Progress Bar */}
-
+
{currentIndex + 1} / {flashcards.length}
-
- {/* 卡片区域 */}
+ {/* Card Area */}
-
{/* Front - Question */}
-
{currentCard.question}
-
+
{currentCard.question}
+
Click to flip and see answer
@@ -95,43 +101,45 @@ export const FlashcardViewer: React.FC
= ({
{/* Back - Answer */}
-
{currentCard.answer}
+
{currentCard.answer}
{currentCard.source_excerpt && (
-
-
Source Excerpt:
-
{currentCard.source_excerpt}
+
+
Source Excerpt:
+
{currentCard.source_excerpt}
)}
-
+
{/* Navigation Buttons */}
-
Previous
-
+
-
Next
-
+
);
diff --git a/frontend_zh/src/components/knowledge-base/index.tsx b/frontend_zh/src/components/knowledge-base/index.tsx
index 65c8173..bbd67e2 100644
--- a/frontend_zh/src/components/knowledge-base/index.tsx
+++ b/frontend_zh/src/components/knowledge-base/index.tsx
@@ -10,7 +10,7 @@ import { MermaidPreview } from './tools/MermaidPreview';
import { supabase } from '../../lib/supabase';
import { useAuthStore } from '../../stores/authStore';
import { X, Eye, Trash2, FileText, Image, Video, Link as LinkIcon, Headphones } from 'lucide-react';
-import { API_KEY } from '../../config/api';
+import { apiFetch } from '../../config/api';
const KnowledgeBase = () => {
const { user } = useAuthStore();
@@ -186,11 +186,10 @@ const KnowledgeBase = () => {
setMindmapStatus(null);
setMindmapError(null);
- const res = await fetch('/api/v1/kb/save-mindmap', {
+ const res = await apiFetch('/api/v1/kb/save-mindmap', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
- 'X-API-Key': API_KEY
},
body: JSON.stringify({
file_url: previewFile.url,
diff --git a/frontend_zh/src/components/notes/AIPanel.tsx b/frontend_zh/src/components/notes/AIPanel.tsx
new file mode 100644
index 0000000..205d48c
--- /dev/null
+++ b/frontend_zh/src/components/notes/AIPanel.tsx
@@ -0,0 +1,242 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { Bot, Send, X, ChevronDown, ChevronUp, Copy } from 'lucide-react';
+import { apiFetch } from '../../config/api';
+import { getApiSettings } from '../../services/apiSettingsService';
+import { Badge, BadgeGroup, Button } from '../ui';
+import type { KnowledgeFile } from '../../types';
+
+interface AIPanelProps {
+ blockId: string;
+ files: KnowledgeFile[];
+ user: any;
+ notebook?: any;
+ noteContext: string;
+ noteMemory: string;
+ onInsertText: (text: string, blockId: string) => void;
+ onClose: () => void;
+ onUpdateMemory: (memory: string) => void;
+}
+
+const ORGANIZE_PRESETS = [
+ { label: '提炼要点', value: '将以下内容的关键要点提炼成简洁清晰的结构化列表。' },
+ { label: '按主题整理', value: '将以下内容按主要主题进行分类整理,形成结构清晰的章节。' },
+ { label: '生成提纲', value: '根据以下内容生成层级清晰的提纲。' },
+ { label: '提取行动项', value: '从以下内容中提取所有行动项、任务和后续步骤。' },
+ { label: '生成FAQ', value: '基于以下内容生成常见问题解答列表,问题和答案清晰明了。' },
+ { label: '展开详述', value: '对以下内容进行展开和深化,增加更多细节和深度。' },
+];
+
+export const AIPanel: React.FC
= ({
+ blockId,
+ files,
+ user,
+ notebook,
+ noteContext,
+ noteMemory,
+ onInsertText,
+ onClose,
+ onUpdateMemory,
+}) => {
+ // 默认选中所有文件
+ const [selectedFileIds, setSelectedFileIds] = useState>(
+ new Set(files.map(f => f.id))
+ );
+ const [inputText, setInputText] = useState('');
+ const [showPresets, setShowPresets] = useState(false);
+ const [aiResponse, setAiResponse] = useState('');
+ const [loading, setLoading] = useState(false);
+ const textareaRef = useRef(null);
+
+ // 当 files 变化时,更新选中状态(保持之前的选择,新增文件自动选中)
+ useEffect(() => {
+ setSelectedFileIds(prev => {
+ const newIds = new Set(prev);
+ files.forEach(f => {
+ if (!prev.has(f.id)) {
+ newIds.add(f.id);
+ }
+ });
+ return newIds;
+ });
+ }, [files]);
+
+ useEffect(() => {
+ textareaRef.current?.focus();
+ }, []);
+
+ const toggleFile = (fileId: string) => {
+ setSelectedFileIds(prev => {
+ const next = new Set(prev);
+ if (next.has(fileId)) next.delete(fileId);
+ else next.add(fileId);
+ return next;
+ });
+ };
+
+ const handleAsk = async () => {
+ if (!inputText.trim()) return;
+ setLoading(true);
+ setAiResponse('');
+ try {
+ const apiSettings = getApiSettings(user?.id || null);
+ const selectedFilePaths = files
+ .filter(f => selectedFileIds.has(f.id))
+ .map(f => f.url)
+ .filter((url): url is string => Boolean(url));
+
+ const systemContext = noteMemory
+ ? `你是一个智能笔记助手。\n\n笔记上下文与历史记录:\n${noteMemory}`
+ : `你是一个智能笔记助手,帮助用户撰写和整理笔记。`;
+
+ const noteContextPart = noteContext
+ ? `\n\n当前笔记内容:\n${noteContext.slice(0, 800)}`
+ : '';
+
+ const fullPrompt = `${systemContext}${noteContextPart}\n\n用户需求:${inputText}\n\n请提供结构清晰、可以直接插入笔记的回复。`;
+
+ const requestBody = {
+ files: selectedFilePaths,
+ query: fullPrompt,
+ history: [],
+ email: user?.email || user?.id || undefined,
+ notebook_id: notebook?.id || undefined,
+ api_key: apiSettings?.apiKey?.trim() || undefined,
+ api_url: apiSettings?.apiUrl?.trim() || undefined,
+ };
+
+ const res = await apiFetch('/api/v1/kb/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(requestBody),
+ });
+ const data = await res.json();
+ const response = data.answer || data.response || '无回复';
+ setAiResponse(response);
+
+ const memoryEntry = `- AI请求:"${inputText.slice(0, 80)}"\n 回复摘要:"${response.slice(0, 150)}..."`;
+ if (!noteMemory) {
+ const baseMemory = `## 笔记上下文\n${noteContext.slice(0, 500)}\n\n## 操作历史\n${memoryEntry}`;
+ onUpdateMemory(baseMemory);
+ } else {
+ onUpdateMemory(`${noteMemory}\n${memoryEntry}`.slice(-2000));
+ }
+ } catch {
+ setAiResponse('获取AI回复失败,请检查API设置。');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ {/* 来源文件选择 - 使用 Badge 组件 */}
+ {files.length > 0 && (
+
+ 来源:
+ {files.map(f => (
+ toggleFile(f.id)}
+ className="focus:outline-none"
+ >
+
+ {f.name || f.id}
+
+
+ ))}
+
+ )}
+
+ {/* 输入区域 */}
+
+
+ {/* 预设指令下拉 */}
+ {showPresets && (
+
+ {ORGANIZE_PRESETS.map(p => (
+ {
+ setInputText(p.value);
+ setShowPresets(false);
+ textareaRef.current?.focus();
+ }}
+ className="w-full px-3 py-2 text-sm text-left hover:bg-accent-50 text-neutral-700 border-b border-neutral-100 last:border-0 transition-colors"
+ >
+ {p.label}
+
+ ))}
+
+ )}
+
+ {/* AI 回复 */}
+ {aiResponse && !loading && (
+
+
+ AI 回复
+
+
{aiResponse}
+
{
+ onInsertText(aiResponse, blockId);
+ onClose();
+ }}
+ className="mt-2 px-3 py-1.5 bg-success-500 text-white rounded-lg text-xs hover:bg-success-600 flex items-center gap-1 transition-colors shadow-sm"
+ >
+ 粘贴到笔记
+
+
+ )}
+
+ );
+};
diff --git a/frontend_zh/src/components/notes/BlockEditor.css b/frontend_zh/src/components/notes/BlockEditor.css
new file mode 100644
index 0000000..444af91
--- /dev/null
+++ b/frontend_zh/src/components/notes/BlockEditor.css
@@ -0,0 +1,14 @@
+[contenteditable][data-placeholder]:empty:before {
+ content: attr(data-placeholder);
+ color: #9ca3af;
+ pointer-events: none;
+ position: absolute;
+}
+
+[contenteditable] {
+ min-height: 1.5em;
+}
+
+[contenteditable]:focus {
+ outline: none;
+}
diff --git a/frontend_zh/src/components/notes/BlockEditor.tsx b/frontend_zh/src/components/notes/BlockEditor.tsx
new file mode 100644
index 0000000..bf15211
--- /dev/null
+++ b/frontend_zh/src/components/notes/BlockEditor.tsx
@@ -0,0 +1,425 @@
+import React, { useRef, useEffect, KeyboardEvent, useState } from 'react';
+import { Block, BlockType } from './types';
+import { Check, Square, GripVertical, ZoomIn, ZoomOut, Plus, Trash2 } from 'lucide-react';
+
+interface BlockEditorProps {
+ block: Block;
+ onChange: (id: string, content: string, url?: string, scale?: number) => void;
+ onTypeChange: (id: string, type: BlockType, newContent?: string) => void;
+ onDelete: (id: string) => void;
+ onEnter: (id: string, remainingContent?: string) => void;
+ onBackspace: (id: string) => void;
+ onSlashCommand: (id: string) => void;
+ onSpaceKeyEmpty?: (id: string) => void;
+ onTextSelection?: (id: string, text: string, rect: DOMRect) => void;
+ showSlashMenu: boolean;
+ onDragStart?: (id: string) => void;
+ onDragOver?: (id: string) => void;
+ onDrop?: (id: string) => void;
+ onToggleTodo?: (id: string) => void;
+ numberedIndex?: number;
+}
+
+export const BlockEditor: React.FC = ({
+ block,
+ onChange,
+ onTypeChange,
+ onDelete,
+ onEnter,
+ onBackspace,
+ onSlashCommand,
+ onSpaceKeyEmpty,
+ onTextSelection: _onTextSelection,
+ showSlashMenu,
+ onDragStart,
+ onDragOver,
+ onDrop,
+ onToggleTodo,
+ numberedIndex,
+}) => {
+ const inputRef = useRef(null);
+ const fileInputRef = useRef(null);
+ const [scale, setScale] = useState(block.scale || 100);
+ const [showMenu, setShowMenu] = useState(false);
+
+ useEffect(() => {
+ if (inputRef.current && 'focus' in inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, []);
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === ' ' && block.content === '' && onSpaceKeyEmpty) {
+ e.preventDefault();
+ onSpaceKeyEmpty(block.id);
+ return;
+ }
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ const target = e.target as HTMLTextAreaElement | HTMLInputElement;
+ const cursorPos = target.selectionStart || 0;
+ const beforeCursor = block.content.substring(0, cursorPos);
+ const afterCursor = block.content.substring(cursorPos);
+ onChange(block.id, beforeCursor);
+ onEnter(block.id, afterCursor);
+ } else if (e.key === 'Backspace') {
+ const target = e.target as HTMLTextAreaElement | HTMLInputElement;
+ const cursorPos = target.selectionStart || 0;
+
+ if (cursorPos === 0) {
+ const actualContent = target.value || '';
+ if (actualContent === '') {
+ e.preventDefault();
+ onBackspace(block.id);
+ } else if (['bulletList', 'numberedList', 'todo', 'quote'].includes(block.type)) {
+ e.preventDefault();
+ onTypeChange(block.id, 'text');
+ }
+ }
+ }
+ };
+
+ const handleInput = (e: any) => {
+ const target = e.target as HTMLTextAreaElement | HTMLInputElement;
+ const content = target.value;
+
+ // Auto resize textarea
+ if (target instanceof HTMLTextAreaElement) {
+ autoResize(target);
+ }
+
+ // Live markdown shortcuts (only convert from text blocks)
+ if (block.type === 'text') {
+ if (content === '- ' || content === '* ' || content === '+ ') {
+ onTypeChange(block.id, 'bulletList', '');
+ return;
+ }
+ if (/^\d+\. $/.test(content)) {
+ onTypeChange(block.id, 'numberedList', '');
+ return;
+ }
+ if (content === '> ') {
+ onTypeChange(block.id, 'quote', '');
+ return;
+ }
+ if (content === '[ ] ' || content === '[] ') {
+ onTypeChange(block.id, 'todo', '');
+ return;
+ }
+ if (content.startsWith('### ')) {
+ onTypeChange(block.id, 'heading3', content.slice(4));
+ return;
+ }
+ if (content.startsWith('## ')) {
+ onTypeChange(block.id, 'heading2', content.slice(3));
+ return;
+ }
+ if (content.startsWith('# ')) {
+ onTypeChange(block.id, 'heading1', content.slice(2));
+ return;
+ }
+ }
+
+ onChange(block.id, content);
+ if (content === '/' || content === '/') {
+ onSlashCommand(block.id);
+ }
+ };
+
+ const handleFileUpload = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = () => onChange(block.id, file.name, reader.result as string);
+ reader.readAsDataURL(file);
+ }
+ };
+
+ const handleScaleChange = (delta: number) => {
+ const newScale = Math.max(20, Math.min(200, scale + delta));
+ setScale(newScale);
+ onChange(block.id, block.content, block.url, newScale);
+ };
+
+ const autoResize = (el: HTMLTextAreaElement) => {
+ el.style.height = 'auto';
+ el.style.height = el.scrollHeight + 'px';
+ };
+
+ const placeholder = showSlashMenu
+ ? '输入 / 查看命令'
+ : '按空格启用AI,或输入"/"启用命令';
+
+ const renderBlock = () => {
+ const baseTextareaProps = {
+ ref: inputRef as React.Ref,
+ value: block.content,
+ onChange: handleInput,
+ onKeyDown: handleKeyDown,
+ className: 'w-full outline-none bg-transparent resize-none overflow-hidden',
+ rows: 1,
+ };
+
+ switch (block.type) {
+ case 'heading1':
+ return (
+ }
+ value={block.content}
+ onChange={handleInput}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder}
+ className="w-full outline-none bg-transparent text-3xl font-bold"
+ />
+ );
+ case 'heading2':
+ return (
+ }
+ value={block.content}
+ onChange={handleInput}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder}
+ className="w-full outline-none bg-transparent text-2xl font-bold"
+ />
+ );
+ case 'heading3':
+ return (
+ }
+ value={block.content}
+ onChange={handleInput}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder}
+ className="w-full outline-none bg-transparent text-xl font-semibold"
+ />
+ );
+ case 'code':
+ return (
+
+ );
+ case 'quote':
+ return (
+
+
+
+ );
+ case 'todo':
+ return (
+
+ onToggleTodo?.(block.id)} className="mt-1 shrink-0">
+ {block.checked ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+ case 'bulletList':
+ return (
+
+ •
+
+
+ );
+ case 'numberedList':
+ return (
+
+
+ {numberedIndex !== undefined ? `${numberedIndex}.` : '1.'}
+
+
+
+ );
+ case 'divider':
+ return ;
+ case 'image':
+ return (
+
+ {!block.url ? (
+
fileInputRef.current?.click()}
+ className="px-4 py-2 bg-blue-500 text-white rounded"
+ >
+ 上传图片
+
+ ) : (
+
+
+
+ handleScaleChange(-10)} className="p-1 hover:bg-gray-100">
+
+
+ handleScaleChange(10)} className="p-1 hover:bg-gray-100">
+
+
+
+
+ )}
+
+
+ );
+ case 'excel':
+ return (
+
+ {!block.url ? (
+
fileInputRef.current?.click()}
+ className="px-4 py-2 bg-green-500 text-white rounded"
+ >
+ 上传Excel
+
+ ) : (
+
+
📊 {block.content}
+
+ 下载
+
+
+ )}
+
+
+ );
+ case 'video':
+ return (
+
+ {!block.url ? (
+ fileInputRef.current?.click()}
+ className="px-4 py-2 bg-purple-500 text-white rounded"
+ >
+ 上传视频
+
+ ) : (
+
+ )}
+
+
+ );
+ case 'table': {
+ // Parse markdown table rows into header + body
+ const rows = block.content
+ .split('\n')
+ .map(r => r.trim())
+ .filter(r => r.startsWith('|') && r.endsWith('|'));
+ const parsedRows = rows
+ .filter(r => !r.replace(/\|/g, '').trim().match(/^[-:\s]+$/))
+ .map(r =>
+ r
+ .slice(1, -1)
+ .split('|')
+ .map(cell => cell.trim())
+ );
+ if (parsedRows.length === 0) return 空表格
;
+ const [headerCells, ...bodyCells] = parsedRows;
+ return (
+
+
+
+
+ {headerCells.map((cell, ci) => (
+
+ {cell}
+
+ ))}
+
+
+
+ {bodyCells.map((row, ri) => (
+
+ {row.map((cell, ci) => (
+
+ {cell}
+
+ ))}
+
+ ))}
+
+
+
+ );
+ }
+ default:
+ return (
+
+ );
+ }
+ };
+
+ return (
+ {
+ e.preventDefault();
+ onDragOver?.(block.id);
+ }}
+ onDrop={() => onDrop?.(block.id)}
+ >
+
+
onEnter(block.id)} className="p-0.5 hover:bg-gray-200 rounded">
+
+
+
+
setShowMenu(!showMenu)}
+ draggable
+ onDragStart={() => onDragStart?.(block.id)}
+ className="cursor-grab active:cursor-grabbing p-0.5 hover:bg-gray-200 rounded"
+ >
+
+
+ {showMenu && (
+
+ {
+ onDelete(block.id);
+ setShowMenu(false);
+ }}
+ className="w-full px-3 py-1.5 text-left text-sm hover:bg-gray-100 flex items-center gap-2 text-red-600"
+ >
+ 删除
+
+
+ )}
+
+
+
{renderBlock()}
+
+ );
+};
diff --git a/frontend_zh/src/components/notes/DiffPreviewPanel.tsx b/frontend_zh/src/components/notes/DiffPreviewPanel.tsx
new file mode 100644
index 0000000..3f3f50f
--- /dev/null
+++ b/frontend_zh/src/components/notes/DiffPreviewPanel.tsx
@@ -0,0 +1,98 @@
+import React, { useState } from 'react';
+import { Check, X } from 'lucide-react';
+
+interface DiffPreviewPanelProps {
+ originalText: string;
+ revisedText: string;
+ blockId: string;
+ onAccept: (blockId: string, text: string) => void;
+ onReject: () => void;
+}
+
+export const DiffPreviewPanel: React.FC = ({
+ originalText,
+ revisedText,
+ blockId,
+ onAccept,
+ onReject,
+}) => {
+ const [editedText, setEditedText] = useState(revisedText);
+
+ return (
+
+ {/* 顶部操作栏 */}
+
+
+ AI 润色预览
+ 查看改动
+
+
+ onAccept(blockId, editedText)}
+ className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors font-medium shadow-sm"
+ >
+ 接受
+
+
+ 拒绝
+
+
+
+
+ {/* 差异对比内容 */}
+
+ {/* 改前 */}
+
+
+ {/* 分隔箭头 */}
+
+
+ {/* 改后(可编辑) */}
+
+
+
+ {/* 底部提示 */}
+
+
+ 您可以在接受前直接编辑上方的改写内容。
+
+
+
+ );
+};
diff --git a/frontend_zh/src/components/notes/NoteEditor.tsx b/frontend_zh/src/components/notes/NoteEditor.tsx
new file mode 100644
index 0000000..6bf2c08
--- /dev/null
+++ b/frontend_zh/src/components/notes/NoteEditor.tsx
@@ -0,0 +1,189 @@
+import React, { useState, useRef } from 'react';
+import { Bold, Italic, List, ListOrdered, Heading1, Heading2, Code, Quote, Image, FileText, Download, Save } from 'lucide-react';
+import { apiFetch } from '../../config/api';
+
+interface NoteEditorProps {
+ onClose: () => void;
+ notebook: any;
+ user: any;
+ onSaved?: () => void;
+}
+
+export const NoteEditor: React.FC = ({ onClose, notebook, user, onSaved }) => {
+ const [title, setTitle] = useState('无标题');
+ const [coverImage, setCoverImage] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const editorRef = useRef(null);
+ const coverInputRef = useRef(null);
+ const mdInputRef = useRef(null);
+
+ const execCommand = (command: string, value?: string) => {
+ document.execCommand(command, false, value);
+ editorRef.current?.focus();
+ };
+
+ const handleCoverUpload = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = () => setCoverImage(reader.result as string);
+ reader.readAsDataURL(file);
+ }
+ };
+
+ const handleMdEmbed = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const content = reader.result as string;
+ if (editorRef.current) {
+ editorRef.current.innerHTML += `${content} `;
+ }
+ };
+ reader.readAsText(file);
+ }
+ };
+
+ const htmlToMarkdown = (html: string): string => {
+ let md = html
+ .replace(/(.*?)<\/h1>/g, '# $1\n')
+ .replace(/(.*?)<\/h2>/g, '## $1\n')
+ .replace(/(.*?)<\/strong>/g, '**$1**')
+ .replace(/(.*?)<\/b>/g, '**$1**')
+ .replace(/(.*?)<\/em>/g, '*$1*')
+ .replace(/(.*?)<\/i>/g, '*$1*')
+ .replace(/(.*?)<\/blockquote>/g, '> $1\n')
+ .replace(/(.*?)<\/pre>/gs, '```\n$1\n```\n')
+ .replace(/(.*?)<\/code>/g, '`$1`')
+ .replace(/(.*?)<\/li>/g, '- $1\n')
+ .replace(/|<\/ul>||<\/ol>/g, '')
+ .replace(/(.*?)<\/p>/g, '$1\n\n')
+ .replace(/ /g, '\n')
+ .replace(/<[^>]+>/g, '');
+ return md.trim();
+ };
+
+ const handleSave = async () => {
+ if (!editorRef.current || !notebook?.id) return;
+ setSaving(true);
+ try {
+ const content = htmlToMarkdown(editorRef.current.innerHTML);
+ const mdContent = coverImage ? `\n\n# ${title}\n\n${content}` : `# ${title}\n\n${content}`;
+ const blob = new Blob([mdContent], { type: 'text/markdown' });
+ const file = new File([blob], `${title}.md`, { type: 'text/markdown' });
+
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('email', user?.email || user?.id || 'default');
+ formData.append('user_id', user?.id || 'default');
+ formData.append('notebook_id', notebook.id);
+ formData.append('notebook_title', notebook?.title || notebook?.name || '');
+
+ const res = await apiFetch('/api/v1/kb/upload', { method: 'POST', body: formData });
+ if (!res.ok) throw new Error('保存失败');
+
+ alert('笔记已保存到知识库!');
+ onSaved?.();
+ onClose();
+ } catch (err) {
+ alert('保存笔记失败');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleExport = () => {
+ if (!editorRef.current) return;
+ const content = htmlToMarkdown(editorRef.current.innerHTML);
+ const mdContent = coverImage ? `\n\n# ${title}\n\n${content}` : `# ${title}\n\n${content}`;
+ const blob = new Blob([mdContent], { type: 'text/markdown' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${title}.md`;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ return (
+
+
+
setTitle(e.target.value)}
+ className="text-2xl font-bold outline-none flex-1"
+ placeholder="无标题"
+ />
+
+
+ 导出
+
+
+ {saving ? '保存中...' : '保存'}
+
+
+ 完成
+
+
+
+
+
+
coverInputRef.current?.click()} className="p-2 hover:bg-gray-200 rounded" title="添加封面">
+
+
+
mdInputRef.current?.click()} className="p-2 hover:bg-gray-200 rounded" title="嵌入Markdown">
+
+
+
+
execCommand('bold')} className="p-2 hover:bg-gray-200 rounded" title="粗体">
+
+
+
execCommand('italic')} className="p-2 hover:bg-gray-200 rounded" title="斜体">
+
+
+
execCommand('formatBlock', 'h1')} className="p-2 hover:bg-gray-200 rounded" title="标题1">
+
+
+
execCommand('formatBlock', 'h2')} className="p-2 hover:bg-gray-200 rounded" title="标题2">
+
+
+
execCommand('insertUnorderedList')} className="p-2 hover:bg-gray-200 rounded" title="无序列表">
+
+
+
execCommand('insertOrderedList')} className="p-2 hover:bg-gray-200 rounded" title="有序列表">
+
+
+
execCommand('formatBlock', 'blockquote')} className="p-2 hover:bg-gray-200 rounded" title="引用">
+
+
+
execCommand('formatBlock', 'pre')} className="p-2 hover:bg-gray-200 rounded" title="代码块">
+
+
+
+
+
+
+
+ {coverImage && (
+
+
+
setCoverImage(null)} className="absolute top-2 right-2 bg-red-500 text-white px-2 py-1 rounded text-xs">
+ 移除
+
+
+ )}
+
+
+
+ );
+};
diff --git a/frontend_zh/src/components/notes/NotionEditor.tsx b/frontend_zh/src/components/notes/NotionEditor.tsx
new file mode 100644
index 0000000..0ab8b2c
--- /dev/null
+++ b/frontend_zh/src/components/notes/NotionEditor.tsx
@@ -0,0 +1,565 @@
+import React, { useState, useCallback } from 'react';
+import { Block, BlockType } from './types';
+import { BlockEditor } from './BlockEditor';
+import { SlashMenu } from './SlashMenu';
+import { AIPanel } from './AIPanel';
+import { TextSelectionToolbar } from './TextSelectionToolbar';
+import { DiffPreviewPanel } from './DiffPreviewPanel';
+import { Image, Download, Save, X, Maximize2, Minimize2 } from 'lucide-react';
+import { apiFetch } from '../../config/api';
+import type { KnowledgeFile } from '../../types';
+
+interface NotionEditorProps {
+ onClose: () => void;
+ notebook: any;
+ user: any;
+ files?: KnowledgeFile[];
+ onSaved?: () => void;
+}
+
+export const NotionEditor: React.FC = ({
+ onClose,
+ notebook,
+ user,
+ files = [],
+ onSaved,
+}) => {
+ const [title, setTitle] = useState('无标题');
+ const [coverImage, setCoverImage] = useState(null);
+ const [blocks, setBlocks] = useState([{ id: '1', type: 'text', content: '' }]);
+ const [slashMenuBlock, setSlashMenuBlock] = useState(null);
+ const [aiPanelBlock, setAiPanelBlock] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [isFullScreen, setIsFullScreen] = useState(false);
+ const [draggedBlock, setDraggedBlock] = useState(null);
+
+ // AI 上下文记忆
+ const [noteMemory, setNoteMemory] = useState('');
+
+ // 文本选中工具栏
+ const [textSelection, setTextSelection] = useState<{
+ blockId: string;
+ text: string;
+ position: { x: number; y: number };
+ } | null>(null);
+
+ // 差异对比面板(右侧)
+ const [diffPreview, setDiffPreview] = useState<{
+ blockId: string;
+ originalText: string;
+ revisedText: string;
+ } | null>(null);
+
+ const coverInputRef = React.useRef(null);
+
+ const generateId = () => Math.random().toString(36).slice(2, 11);
+
+ const updateBlock = (id: string, content: string, url?: string, scale?: number) => {
+ setBlocks(prev =>
+ prev.map(b => (b.id === id ? { ...b, content, url: url ?? b.url, scale: scale ?? b.scale } : b))
+ );
+ if (content !== '/' && content !== '/') {
+ setSlashMenuBlock(null);
+ }
+ };
+
+ const toggleTodo = (id: string) => {
+ setBlocks(prev => prev.map(b => (b.id === id ? { ...b, checked: !b.checked } : b)));
+ };
+
+ const changeBlockType = (id: string, type: BlockType, newContent?: string) => {
+ setBlocks(prev =>
+ prev.map(b =>
+ b.id === id
+ ? {
+ ...b,
+ type,
+ content: newContent !== undefined
+ ? newContent
+ : b.content.endsWith('/') ? b.content.slice(0, -1) : b.content,
+ }
+ : b
+ )
+ );
+ setSlashMenuBlock(null);
+ };
+
+ const addBlock = (afterId: string, remainingContent?: string) => {
+ const index = blocks.findIndex(b => b.id === afterId);
+ const currentType = blocks[index]?.type;
+ // Continue list type when pressing Enter in list blocks
+ const newType: BlockType = (currentType === 'bulletList' || currentType === 'numberedList' || currentType === 'todo')
+ ? currentType
+ : 'text';
+ const newBlock: Block = { id: generateId(), type: newType, content: remainingContent || '' };
+ setBlocks(prev => [...prev.slice(0, index + 1), newBlock, ...prev.slice(index + 1)]);
+ };
+
+ const deleteBlock = (id: string) => {
+ if (blocks.length === 1) {
+ setBlocks([{ id: blocks[0].id, type: 'text', content: '' }]);
+ return;
+ }
+ setBlocks(prev => prev.filter(b => b.id !== id));
+ };
+
+ const handleDragStart = (id: string) => setDraggedBlock(id);
+ const handleDragOver = (_id: string) => {};
+ const handleDrop = (targetId: string) => {
+ if (!draggedBlock || draggedBlock === targetId) return;
+ const dragIdx = blocks.findIndex(b => b.id === draggedBlock);
+ const targetIdx = blocks.findIndex(b => b.id === targetId);
+ const newBlocks = [...blocks];
+ const [removed] = newBlocks.splice(dragIdx, 1);
+ newBlocks.splice(targetIdx, 0, removed);
+ setBlocks(newBlocks);
+ setDraggedBlock(null);
+ };
+
+ const handleCoverUpload = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = () => setCoverImage(reader.result as string);
+ reader.readAsDataURL(file);
+ }
+ };
+
+ const handleSpaceKeyEmpty = (blockId: string) => {
+ if (aiPanelBlock === blockId) {
+ // 已打开AI面板,关闭并插入空格
+ setAiPanelBlock(null);
+ updateBlock(blockId, ' ');
+ } else {
+ // 未打开,打开AI面板
+ setSlashMenuBlock(null);
+ setAiPanelBlock(blockId);
+ }
+ };
+
+ // Container-level mouseup: catches selections released anywhere (inside or outside a textarea)
+ const handleEditorMouseUp = useCallback((e: React.MouseEvent) => {
+ // 首先尝试获取 window selection(支持跨块选择)
+ const selection = window.getSelection();
+ const selectedText = selection?.toString().trim();
+
+ if (selectedText && selection && selection.rangeCount > 0) {
+ const range = selection.getRangeAt(0);
+ const rect = range.getBoundingClientRect();
+
+ // 检查是否有有效的位置
+ if (rect.width > 0 && rect.height > 0) {
+ let blockEl = range.startContainer.parentElement?.closest('[data-block-id]');
+ const blockId = blockEl?.getAttribute('data-block-id') ?? '';
+
+ setTextSelection({
+ blockId,
+ text: selectedText,
+ position: {
+ x: Math.max(rect.left, 10),
+ y: Math.max(rect.top - 10, 10)
+ },
+ });
+ return;
+ }
+ }
+
+ // 回退:检查 textarea/input 内的选择
+ const active = document.activeElement;
+ if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
+ const start = active.selectionStart ?? 0;
+ const end = active.selectionEnd ?? 0;
+ if (end > start) {
+ const text = active.value.substring(start, end).trim();
+ if (text) {
+ const blockEl = active.closest('[data-block-id]');
+ const blockId = blockEl?.getAttribute('data-block-id') ?? '';
+ if (blockId) {
+ setTextSelection({
+ blockId,
+ text,
+ position: {
+ x: Math.max(e.clientX, 10),
+ y: Math.max(e.clientY - 60, 10)
+ },
+ });
+ }
+ }
+ }
+ }
+ }, []);
+
+ const handleTextSelection = useCallback(
+ (blockId: string, text: string, rect: DOMRect) => {
+ setTextSelection({
+ blockId,
+ text,
+ position: { x: rect.left, y: rect.top },
+ });
+ },
+ []
+ );
+
+ const handleCloseTextSelection = useCallback(() => {
+ setTextSelection(null);
+ }, []);
+
+ const handleDiffResult = (originalText: string, revisedText: string) => {
+ if (!textSelection) return;
+ setDiffPreview({ blockId: textSelection.blockId, originalText, revisedText });
+ setTextSelection(null);
+ };
+
+ const handleAcceptDiff = (blockId: string, revisedText: string) => {
+ if (!diffPreview) return;
+ setBlocks(prev =>
+ prev.map(b => {
+ if (b.id !== blockId) return b;
+ const replaced = b.content.replace(diffPreview.originalText, revisedText);
+ return { ...b, content: replaced !== b.content ? replaced : revisedText };
+ })
+ );
+ setDiffPreview(null);
+ };
+
+ const handleRejectDiff = () => {
+ setDiffPreview(null);
+ };
+
+ const handleInsertText = (text: string, blockId: string) => {
+ const newBlocks = parseMarkdownToBlocks(text);
+ const index = blocks.findIndex(b => b.id === blockId);
+ const targetBlock = blocks[index];
+ if (targetBlock && targetBlock.content === '') {
+ setBlocks(prev => [
+ ...prev.slice(0, index),
+ ...newBlocks,
+ ...prev.slice(index + 1),
+ ]);
+ } else {
+ setBlocks(prev => [
+ ...prev.slice(0, index + 1),
+ ...newBlocks,
+ ...prev.slice(index + 1),
+ ]);
+ }
+ setSlashMenuBlock(null);
+ setAiPanelBlock(null);
+ };
+
+ const handleInsertBelow = (text: string) => {
+ if (!textSelection) {
+ const newBlocks = parseMarkdownToBlocks(text);
+ setBlocks(prev => [...prev, ...newBlocks]);
+ return;
+ }
+ handleInsertText(text, textSelection.blockId);
+ setTextSelection(null);
+ };
+
+ /**
+ * 解析 Markdown 为 Block[]
+ * 修复:bulletList 使用 textarea 支持长文换行;
+ * numberedList 去除原始数字,编号由 numberedIndex 动态计算。
+ */
+ const parseMarkdownToBlocks = (text: string): Block[] => {
+ const lines = text.split('\n');
+ const newBlocks: Block[] = [];
+ let inCodeBlock = false;
+ let codeContent = '';
+ let tableLines: string[] = [];
+
+ const removeBold = (s: string) => s.replace(/\*\*(.*?)\*\*/g, '$1');
+
+ const flushTable = () => {
+ if (tableLines.length > 0) {
+ newBlocks.push({ id: generateId(), type: 'table', content: tableLines.join('\n') });
+ tableLines = [];
+ }
+ };
+
+ lines.forEach(line => {
+ if (line.startsWith('```')) {
+ flushTable();
+ if (inCodeBlock) {
+ newBlocks.push({ id: generateId(), type: 'code', content: codeContent.trim() });
+ codeContent = '';
+ inCodeBlock = false;
+ } else {
+ inCodeBlock = true;
+ }
+ } else if (inCodeBlock) {
+ codeContent += line + '\n';
+ } else if (line.trim().startsWith('|') && line.trim().endsWith('|')) {
+ // Markdown table row — accumulate lines
+ tableLines.push(line);
+ } else {
+ flushTable();
+ if (line.startsWith('# ')) {
+ newBlocks.push({ id: generateId(), type: 'heading1', content: removeBold(line.slice(2)) });
+ } else if (line.startsWith('## ')) {
+ newBlocks.push({ id: generateId(), type: 'heading2', content: removeBold(line.slice(3)) });
+ } else if (line.startsWith('### ')) {
+ newBlocks.push({ id: generateId(), type: 'heading3', content: removeBold(line.slice(4)) });
+ } else if (line.match(/^[-*+]\s/)) {
+ newBlocks.push({ id: generateId(), type: 'bulletList', content: removeBold(line.slice(2)) });
+ } else if (line.match(/^\d+\.\s/)) {
+ newBlocks.push({ id: generateId(), type: 'numberedList', content: removeBold(line.replace(/^\d+\.\s+/, '')) });
+ } else if (line.startsWith('> ')) {
+ newBlocks.push({ id: generateId(), type: 'quote', content: removeBold(line.slice(2)) });
+ } else if (line.trim() === '---' || line.trim() === '***' || line.trim() === '___') {
+ newBlocks.push({ id: generateId(), type: 'divider', content: '' });
+ } else if (line.trim() === '') {
+ // 跳过空行
+ } else {
+ newBlocks.push({ id: generateId(), type: 'text', content: removeBold(line) });
+ }
+ }
+ });
+
+ flushTable();
+ if (inCodeBlock && codeContent.trim()) {
+ newBlocks.push({ id: generateId(), type: 'code', content: codeContent.trim() });
+ }
+
+ return newBlocks.length > 0 ? newBlocks : [{ id: generateId(), type: 'text', content: text }];
+ };
+
+ const getNoteContext = () => `标题:${title}\n\n${blocksToMarkdown()}`;
+
+ const blocksToMarkdown = (): string => {
+ let numIdx = 0;
+ return blocks
+ .map(block => {
+ if (block.type !== 'numberedList') numIdx = 0;
+ switch (block.type) {
+ case 'heading1': return `# ${block.content}\n`;
+ case 'heading2': return `## ${block.content}\n`;
+ case 'heading3': return `### ${block.content}\n`;
+ case 'bulletList': return `- ${block.content}\n`;
+ case 'numberedList': {
+ numIdx++;
+ return `${numIdx}. ${block.content}\n`;
+ }
+ case 'todo': return `- [${block.checked ? 'x' : ' '}] ${block.content}\n`;
+ case 'quote': return `> ${block.content}\n`;
+ case 'code': return `\`\`\`\n${block.content}\n\`\`\`\n`;
+ case 'divider': return `---\n`;
+ case 'table': return `${block.content}\n\n`;
+ default: return `${block.content}\n\n`;
+ }
+ })
+ .join('');
+ };
+
+ const getNumberedIndex = (blockIndex: number): number => {
+ let count = 0;
+ for (let i = blockIndex; i >= 0; i--) {
+ const t = blocks[i].type;
+ if (t === 'numberedList') {
+ count++;
+ } else if (t === 'text' || t.startsWith('heading')) {
+ // 遇到文本或标题块才停止
+ break;
+ }
+ // bulletList, todo, quote 等不会打断编号
+ }
+ return count;
+ };
+
+ const handleSave = async () => {
+ if (!notebook?.id) return;
+ setSaving(true);
+ try {
+ const content = blocksToMarkdown();
+ const mdContent = coverImage
+ ? `\n\n# ${title}\n\n${content}`
+ : `# ${title}\n\n${content}`;
+ const blob = new Blob([mdContent], { type: 'text/markdown' });
+ const file = new File([blob], `${title}.md`, { type: 'text/markdown' });
+
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('email', user?.email || user?.id || 'default');
+ formData.append('user_id', user?.id || 'default');
+ formData.append('notebook_id', notebook.id);
+ formData.append('notebook_title', notebook?.title || notebook?.name || '');
+
+ const res = await apiFetch('/api/v1/kb/upload', { method: 'POST', body: formData });
+ if (!res.ok) throw new Error('保存失败');
+
+ alert('笔记已保存到知识库!');
+ onSaved?.();
+ onClose();
+ } catch {
+ alert('保存笔记失败');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleExport = () => {
+ const content = blocksToMarkdown();
+ const mdContent = coverImage
+ ? `\n\n# ${title}\n\n${content}`
+ : `# ${title}\n\n${content}`;
+ const blob = new Blob([mdContent], { type: 'text/markdown' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${title}.md`;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ return (
+
+
+
+ coverInputRef.current?.click()}
+ className="p-2 hover:bg-gray-100 rounded group relative"
+ title="添加封面"
+ >
+
+
+ 点击添加封面图片
+
+
+
+
+ setIsFullScreen(!isFullScreen)}
+ className="p-2 hover:bg-gray-100 rounded"
+ title={isFullScreen ? '退出全屏' : '全屏'}
+ >
+ {isFullScreen ? : }
+
+
+ 导出
+
+
+ {saving ? '保存中...' : '保存'}
+
+
+
+
+
+
+
+
+
+ {coverImage && (
+
+
+
setCoverImage(null)}
+ className="absolute top-2 right-2 bg-red-500 text-white px-2 py-1 rounded text-xs"
+ >
+ 移除
+
+
+ )}
+
+
+
+
setTitle(e.target.value)}
+ className="text-4xl font-bold outline-none w-full mb-4 bg-transparent"
+ placeholder="无标题"
+ />
+
+ {blocks.map((block, blockIndex) => (
+
+
{
+ setAiPanelBlock(null);
+ setSlashMenuBlock(id);
+ }}
+ onSpaceKeyEmpty={handleSpaceKeyEmpty}
+ onTextSelection={handleTextSelection}
+ showSlashMenu={slashMenuBlock === block.id}
+ onDragStart={handleDragStart}
+ onDragOver={handleDragOver}
+ onDrop={handleDrop}
+ onToggleTodo={toggleTodo}
+ numberedIndex={
+ block.type === 'numberedList' ? getNumberedIndex(blockIndex) : undefined
+ }
+ />
+
+ {slashMenuBlock === block.id && (
+ changeBlockType(block.id, type)}
+ onClose={() => setSlashMenuBlock(null)}
+ onInsertText={text => handleInsertText(text, block.id)}
+ noteContext={getNoteContext()}
+ user={user}
+ />
+ )}
+
+ {aiPanelBlock === block.id && (
+ setAiPanelBlock(null)}
+ onUpdateMemory={setNoteMemory}
+ />
+ )}
+
+ ))}
+
+
+ {diffPreview && (
+
+ )}
+
+
+ {textSelection && (
+
+ )}
+
+ );
+};
diff --git a/frontend_zh/src/components/notes/SlashMenu.tsx b/frontend_zh/src/components/notes/SlashMenu.tsx
new file mode 100644
index 0000000..8b176e6
--- /dev/null
+++ b/frontend_zh/src/components/notes/SlashMenu.tsx
@@ -0,0 +1,77 @@
+import React, { useState, useEffect } from 'react';
+import { BlockType } from './types';
+import {
+ Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare,
+ Quote, Code, Minus, Image, FileSpreadsheet, Video, Type
+} from 'lucide-react';
+
+interface SlashMenuProps {
+ onSelect: (type: BlockType) => void;
+ onClose: () => void;
+ onInsertText?: (text: string) => void;
+ noteContext?: string;
+ user?: any;
+}
+
+const commands = [
+ { type: 'text' as BlockType, label: '文本', icon: Type, desc: '普通文本' },
+ { type: 'heading1' as BlockType, label: '标题 1', icon: Heading1, desc: '大标题' },
+ { type: 'heading2' as BlockType, label: '标题 2', icon: Heading2, desc: '中标题' },
+ { type: 'heading3' as BlockType, label: '标题 3', icon: Heading3, desc: '小标题' },
+ { type: 'bulletList' as BlockType, label: '项目符号列表', icon: List, desc: '创建简单列表' },
+ { type: 'numberedList' as BlockType, label: '编号列表', icon: ListOrdered, desc: '创建编号列表' },
+ { type: 'todo' as BlockType, label: '待办事项', icon: CheckSquare, desc: '用复选框跟踪任务' },
+ { type: 'quote' as BlockType, label: '引用', icon: Quote, desc: '捕获引用内容' },
+ { type: 'code' as BlockType, label: '代码', icon: Code, desc: '捕获代码片段' },
+ { type: 'divider' as BlockType, label: '分隔线', icon: Minus, desc: '视觉分隔块' },
+ { type: 'image' as BlockType, label: '图片', icon: Image, desc: '嵌入图片' },
+ { type: 'excel' as BlockType, label: 'Excel', icon: FileSpreadsheet, desc: '嵌入Excel文件' },
+ { type: 'video' as BlockType, label: '视频', icon: Video, desc: '嵌入视频' },
+];
+
+export const SlashMenu: React.FC = ({ onSelect, onClose }) => {
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setSelectedIndex(prev => (prev + 1) % commands.length);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setSelectedIndex(prev => (prev - 1 + commands.length) % commands.length);
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ onSelect(commands[selectedIndex].type);
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ onClose();
+ }
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [selectedIndex, onSelect, onClose]);
+
+ return (
+
+
+ 块类型
+
+ {commands.map((cmd, idx) => (
+
onSelect(cmd.type)}
+ className={`w-full px-3 py-2 text-left flex items-start gap-3 transition-colors ${
+ idx === selectedIndex ? 'bg-blue-50' : 'hover:bg-gray-50'
+ }`}
+ >
+
+
+
{cmd.label}
+
{cmd.desc}
+
+
+ ))}
+
+ );
+};
diff --git a/frontend_zh/src/components/notes/TextSelectionToolbar.tsx b/frontend_zh/src/components/notes/TextSelectionToolbar.tsx
new file mode 100644
index 0000000..bf4afac
--- /dev/null
+++ b/frontend_zh/src/components/notes/TextSelectionToolbar.tsx
@@ -0,0 +1,340 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { Sparkles, BookOpen, AlignLeft, Send, Copy, X } from 'lucide-react';
+import { apiFetch } from '../../config/api';
+import { getApiSettings } from '../../services/apiSettingsService';
+import type { KnowledgeFile } from '../../types';
+
+interface TextSelectionToolbarProps {
+ selectedText: string;
+ position: { x: number; y: number };
+ files: KnowledgeFile[];
+ user: any;
+ noteMemory: string;
+ noteContext: string;
+ onDiffResult: (originalText: string, revisedText: string) => void;
+ onInsertBelow: (text: string) => void;
+ onUpdateMemory: (memory: string) => void;
+ onClose: () => void;
+}
+
+const POLISH_STYLES = [
+ { label: '正式风格', prompt: '将以下文字改写为正式、专业的风格。只返回改写后的内容:' },
+ { label: '轻松风格', prompt: '将以下文字改写为轻松、友好的口语化风格。只返回改写后的内容:' },
+ { label: '精简版', prompt: '将以下文字精简改写,保留关键信息。只返回改写后的内容:' },
+ { label: '学术风格', prompt: '将以下文字改写为学术、严谨的写作风格。只返回改写后的内容:' },
+ { label: '生动风格', prompt: '将以下文字改写得更生动有趣、富有感染力。只返回改写后的内容:' },
+];
+
+const ORGANIZE_PRESETS = [
+ { label: '提炼要点', prompt: '将以下内容的关键要点整理为结构化列表:' },
+ { label: '按主题整理', prompt: '将以下内容按主要主题分类,生成清晰的章节结构:' },
+ { label: '转为要点列表', prompt: '将以下文字转化为清晰的要点列表:' },
+ { label: '生成提纲', prompt: '根据以下内容生成层级分明的提纲:' },
+ { label: '展开详述', prompt: '对以下内容进行展开,增加更多细节、案例和深度:' },
+];
+
+type ToolbarMode = 'toolbar' | 'polish' | 'understand' | 'organize' | 'result-understand' | 'result-organize';
+
+export const TextSelectionToolbar: React.FC = ({
+ selectedText,
+ position,
+ files,
+ user,
+ noteMemory,
+ noteContext,
+ onDiffResult,
+ onInsertBelow,
+ onUpdateMemory,
+ onClose,
+}) => {
+ const [mode, setMode] = useState('toolbar');
+ const [selectedFileIds, setSelectedFileIds] = useState>(new Set());
+ const [customPrompt, setCustomPrompt] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [resultText, setResultText] = useState('');
+ const toolbarRef = useRef(null);
+
+ useEffect(() => {
+ const handleMouseDown = (e: MouseEvent) => {
+ if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) {
+ onClose();
+ }
+ };
+ setTimeout(() => document.addEventListener('mousedown', handleMouseDown), 100);
+ return () => document.removeEventListener('mousedown', handleMouseDown);
+ }, [onClose]);
+
+ const callAI = async (instruction: string): Promise => {
+ const apiSettings = getApiSettings(user?.id || null);
+ const selectedFilePaths = files
+ .filter(f => selectedFileIds.has(f.id))
+ .map(f => f.url)
+ .filter((url): url is string => Boolean(url));
+
+ const systemContext = noteMemory
+ ? `你是一个智能写作助手。\n\n上下文:\n${noteMemory}`
+ : `你是一个智能写作助手。`;
+
+ const prompt = `${systemContext}\n\n${instruction}\n\n"${selectedText}"\n\n只返回处理结果,不要添加任何解释或前言。`;
+
+ const res = await apiFetch('/api/v1/kb/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ files: selectedFilePaths,
+ query: prompt,
+ history: [],
+ api_key: apiSettings?.apiKey?.trim() || undefined,
+ api_url: apiSettings?.apiUrl?.trim() || undefined,
+ }),
+ });
+ const data = await res.json();
+ return data.answer || data.response || '无回复';
+ };
+
+ const handlePolish = async (stylePrompt: string, styleName: string) => {
+ setLoading(true);
+ try {
+ const result = await callAI(stylePrompt);
+ onDiffResult(selectedText, result);
+ const entry = `- 润色(${styleName}):"${selectedText.slice(0, 60)}..." → "${result.slice(0, 60)}..."`;
+ onUpdateMemory(noteMemory ? `${noteMemory}\n${entry}`.slice(-2000) : `## 操作历史\n${entry}`);
+ onClose();
+ } catch {
+ setLoading(false);
+ }
+ };
+
+ const handleUnderstand = async () => {
+ setMode('understand');
+ setLoading(true);
+ try {
+ const result = await callAI(
+ '请对以下文字进行清晰、深入的解析。它的含义是什么?关键观点和内涵是什么?'
+ );
+ setResultText(result);
+ setMode('result-understand');
+ const entry = `- 理解:"${selectedText.slice(0, 60)}..."`;
+ onUpdateMemory(noteMemory ? `${noteMemory}\n${entry}`.slice(-2000) : `## 操作历史\n${entry}`);
+ } catch {
+ setResultText('获取AI回复失败。');
+ setMode('result-understand');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleOrganize = async (prompt: string) => {
+ setLoading(true);
+ try {
+ const result = await callAI(prompt);
+ setResultText(result);
+ setMode('result-organize');
+ const entry = `- 整理:"${selectedText.slice(0, 60)}..."`;
+ onUpdateMemory(noteMemory ? `${noteMemory}\n${entry}`.slice(-2000) : `## 操作历史\n${entry}`);
+ } catch {
+ setResultText('获取AI回复失败。');
+ setMode('result-organize');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const toggleFile = (fileId: string) => {
+ setSelectedFileIds(prev => {
+ const next = new Set(prev);
+ if (next.has(fileId)) next.delete(fileId);
+ else next.add(fileId);
+ return next;
+ });
+ };
+
+ const left = Math.min(Math.max(position.x, 8), window.innerWidth - 320);
+ const top = Math.max(position.y - 48, 8);
+
+ const SourceSelector = () =>
+ files.length > 0 ? (
+
+ 参考来源(可选):
+ {files.map(f => (
+ toggleFile(f.id)}
+ className={`px-2 py-0.5 text-xs rounded-full border transition-all ${
+ selectedFileIds.has(f.id)
+ ? 'bg-blue-500 text-white border-blue-500'
+ : 'bg-white text-gray-500 border-gray-200 hover:border-blue-300'
+ }`}
+ >
+ {f.name || f.id}
+
+ ))}
+
+ ) : null;
+
+ return (
+
+ {mode !== 'toolbar' && (
+
+ setMode('toolbar')}
+ className="text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1"
+ >
+ ← 返回
+
+
+
+
+
+ )}
+
+ {mode === 'toolbar' && (
+
+
setMode('polish')}
+ className="flex items-center gap-1.5 px-3 py-2 text-sm hover:bg-purple-50 text-purple-600 rounded-lg font-medium transition-colors"
+ >
+ 润色
+
+
+
+ 理解
+
+
+
setMode('organize')}
+ className="flex items-center gap-1.5 px-3 py-2 text-sm hover:bg-green-50 text-green-600 rounded-lg font-medium transition-colors"
+ >
+ 整理
+
+
+
+
+
+
+ )}
+
+ {mode === 'polish' && (
+
+
+
+
+ 选择润色风格
+
+ {POLISH_STYLES.map(s => (
+
handlePolish(s.prompt, s.label)}
+ disabled={loading}
+ className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-purple-50 text-purple-700 rounded-lg disabled:opacity-50 transition-colors"
+ >
+ {loading ? (
+
+ ) : (
+
+ )}
+ {s.label}
+
+ ))}
+
+
+ )}
+
+ {mode === 'organize' && (
+
+
+
+
+ 笔记整理方式
+
+ {ORGANIZE_PRESETS.map(p => (
+
handleOrganize(p.prompt)}
+ disabled={loading}
+ className="w-full flex items-center gap-2 px-3 py-2 text-sm text-left hover:bg-green-50 text-green-700 rounded-lg disabled:opacity-50 transition-colors"
+ >
+ {loading ? (
+
+ ) : (
+
+ )}
+ {p.label}
+
+ ))}
+
+ setCustomPrompt(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && customPrompt.trim() && handleOrganize(customPrompt)}
+ placeholder="自定义整理要求..."
+ className="flex-1 px-2.5 py-1.5 text-xs border border-gray-200 rounded-lg outline-none focus:border-green-400"
+ />
+ customPrompt.trim() && handleOrganize(customPrompt)}
+ disabled={loading || !customPrompt.trim()}
+ className="px-2.5 py-1.5 bg-green-500 text-white rounded-lg text-xs disabled:opacity-40 hover:bg-green-600 transition-colors"
+ >
+
+
+
+
+
+ )}
+
+ {mode === 'understand' && loading && (
+
+ )}
+
+ {mode === 'result-understand' && (
+
+
+ AI 理解
+
+
+ {resultText}
+
+
{
+ onInsertBelow(resultText);
+ onClose();
+ }}
+ className="mt-2.5 px-3 py-1.5 bg-green-500 text-white rounded-lg text-xs hover:bg-green-600 flex items-center gap-1 transition-colors"
+ >
+ 粘贴到笔记
+
+
+ )}
+
+ {mode === 'result-organize' && (
+
+
+
+ {resultText}
+
+
{
+ onInsertBelow(resultText);
+ onClose();
+ }}
+ className="mt-2.5 px-3 py-1.5 bg-green-500 text-white rounded-lg text-xs hover:bg-green-600 flex items-center gap-1 transition-colors"
+ >
+ 粘贴到笔记
+
+
+ )}
+
+ );
+};
diff --git a/frontend_zh/src/components/notes/types.ts b/frontend_zh/src/components/notes/types.ts
new file mode 100644
index 0000000..3481bed
--- /dev/null
+++ b/frontend_zh/src/components/notes/types.ts
@@ -0,0 +1,33 @@
+export type BlockType =
+ | 'text'
+ | 'heading1'
+ | 'heading2'
+ | 'heading3'
+ | 'bulletList'
+ | 'numberedList'
+ | 'todo'
+ | 'quote'
+ | 'code'
+ | 'divider'
+ | 'table'
+ | 'image'
+ | 'excel'
+ | 'video';
+
+export interface Block {
+ id: string;
+ type: BlockType;
+ content: string;
+ checked?: boolean;
+ url?: string;
+ scale?: number;
+}
+
+export interface NoteDocument {
+ id: string;
+ title: string;
+ coverImage?: string;
+ blocks: Block[];
+ createdAt: string;
+ updatedAt: string;
+}
diff --git a/frontend_zh/src/components/quiz/QuizContainer.tsx b/frontend_zh/src/components/quiz/QuizContainer.tsx
index cd1d5f2..92ee5b6 100644
--- a/frontend_zh/src/components/quiz/QuizContainer.tsx
+++ b/frontend_zh/src/components/quiz/QuizContainer.tsx
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
import { QuizQuestion } from './QuizQuestion';
import { QuizResults } from './QuizResults';
import { QuizReview } from './QuizReview';
@@ -25,6 +26,8 @@ interface QuizContainerProps {
type QuizState = 'taking' | 'results' | 'review';
+const springTransition = { type: 'spring', stiffness: 300, damping: 30 };
+
export const QuizContainer: React.FC = ({
questions,
onClose,
@@ -32,9 +35,11 @@ export const QuizContainer: React.FC = ({
const [currentIndex, setCurrentIndex] = useState(0);
const [userAnswers, setUserAnswers] = useState>({});
const [quizState, setQuizState] = useState('taking');
+ const [direction, setDirection] = useState(1);
const currentQuestion = questions[currentIndex];
const currentAnswer = userAnswers[currentQuestion?.id] || null;
+ const progress = ((currentIndex + 1) / questions.length) * 100;
const handleSelectAnswer = (answer: string) => {
setUserAnswers({
@@ -53,15 +58,16 @@ export const QuizContainer: React.FC = ({
const handleNext = () => {
if (currentIndex < questions.length - 1) {
+ setDirection(1);
setCurrentIndex(currentIndex + 1);
} else {
- // 最后一题,显示结果
setQuizState('results');
}
};
const handlePrevious = () => {
if (currentIndex > 0) {
+ setDirection(-1);
setCurrentIndex(currentIndex - 1);
}
};
@@ -76,7 +82,6 @@ export const QuizContainer: React.FC = ({
setQuizState('review');
};
- // 计算统计数据
const calculateStats = () => {
let correct = 0;
let wrong = 0;
@@ -96,7 +101,6 @@ export const QuizContainer: React.FC = ({
return { correct, wrong, skipped };
};
- // 结果页面
if (quizState === 'results') {
const stats = calculateStats();
return (
@@ -111,7 +115,6 @@ export const QuizContainer: React.FC = ({
);
}
- // 复习页面
if (quizState === 'review') {
return (
= ({
);
}
- // 答题页面
return (
- {/* Progress */}
+ {/* iOS Progress Bar */}
-
+
Question {currentIndex + 1} of {questions.length}
-
- {/* Question */}
-
-
-
+ {/* Question with spring transition */}
+
+ 0 ? 30 : -30, opacity: 0 }}
+ animate={{ x: 0, opacity: 1 }}
+ exit={{ x: direction > 0 ? -30 : 30, opacity: 0 }}
+ transition={springTransition}
+ className="bg-white border border-ios-gray-100 rounded-ios-lg p-6 mb-6 shadow-ios-sm"
+ >
+
+
+
{/* Navigation */}
-
Previous
-
+
-
Skip
-
+
-
{currentIndex === questions.length - 1 ? 'Finish' : 'Next'}
-
+
);
diff --git a/frontend_zh/src/components/quiz/QuizQuestion.tsx b/frontend_zh/src/components/quiz/QuizQuestion.tsx
index ad3ec2f..8bb1d11 100644
--- a/frontend_zh/src/components/quiz/QuizQuestion.tsx
+++ b/frontend_zh/src/components/quiz/QuizQuestion.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { CheckCircle, XCircle, Circle } from 'lucide-react';
+import { motion } from 'framer-motion';
interface QuizOption {
label: string;
@@ -27,61 +27,88 @@ export const QuizQuestion: React.FC
= ({
}) => {
const getOptionStyle = (optionLabel: string) => {
if (!showResult) {
- // 答题模式
if (selectedAnswer === optionLabel) {
- return 'border-blue-500 bg-blue-50';
+ return 'border-primary bg-primary/5 shadow-ios-sm';
}
- return 'border-gray-300 hover:border-blue-400 hover:bg-gray-50';
+ return 'border-ios-gray-200 hover:border-primary/40 hover:bg-ios-gray-50';
} else {
- // 结果展示模式
if (optionLabel === correctAnswer) {
- return 'border-green-500 bg-green-50';
+ return 'border-green-500 bg-green-50 shadow-ios-sm';
}
if (selectedAnswer === optionLabel && !isCorrect) {
return 'border-red-500 bg-red-50';
}
- return 'border-gray-300 bg-gray-50';
+ return 'border-ios-gray-200 bg-ios-gray-50';
}
};
- const getOptionIcon = (optionLabel: string) => {
+ const getRadioStyle = (optionLabel: string) => {
if (!showResult) {
- return selectedAnswer === optionLabel ? (
-
- ) : (
-
- );
+ if (selectedAnswer === optionLabel) {
+ return (
+
+
+
+
+
+ );
+ }
+ return
;
} else {
if (optionLabel === correctAnswer) {
- return ;
+ return (
+
+
+
+
+
+ );
}
if (selectedAnswer === optionLabel && !isCorrect) {
- return ;
+ return (
+
+ );
}
- return ;
+ return
;
}
};
return (
-
{question}
+
{question}
{options.map((option) => (
-
!showResult && onSelectAnswer(option.label)}
disabled={showResult}
- className={`w-full p-4 rounded-lg border-2 transition-all text-left flex items-start gap-3 ${getOptionStyle(option.label)} ${
+ className={`w-full p-4 rounded-ios-lg border-2 transition-all text-left flex items-start gap-3 ${getOptionStyle(option.label)} ${
showResult ? 'cursor-default' : 'cursor-pointer'
}`}
>
- {getOptionIcon(option.label)}
+ {getRadioStyle(option.label)}
- {option.label}. {' '}
- {option.text}
+ {option.label}. {' '}
+ {option.text}
-
+
))}
diff --git a/frontend_zh/src/components/quiz/QuizResults.tsx b/frontend_zh/src/components/quiz/QuizResults.tsx
index 3c8ce08..c4e6ec1 100644
--- a/frontend_zh/src/components/quiz/QuizResults.tsx
+++ b/frontend_zh/src/components/quiz/QuizResults.tsx
@@ -1,5 +1,6 @@
import React from 'react';
-import { CheckCircle, XCircle, SkipForward, RotateCcw, Eye } from 'lucide-react';
+import { motion } from 'framer-motion';
+import { RotateCcw, Eye } from 'lucide-react';
interface QuizResultsProps {
totalQuestions: number;
@@ -21,101 +22,133 @@ export const QuizResults: React.FC = ({
const percentage = Math.round((correctCount / totalQuestions) * 100);
const getScoreColor = () => {
+ if (percentage >= 80) return '#34C759';
+ if (percentage >= 60) return '#FF9500';
+ return '#FF3B30';
+ };
+
+ const getScoreTextColor = () => {
if (percentage >= 80) return 'text-green-600';
- if (percentage >= 60) return 'text-yellow-600';
- return 'text-red-600';
+ if (percentage >= 60) return 'text-orange-500';
+ return 'text-red-500';
};
const getScoreMessage = () => {
- if (percentage >= 80) return 'Excellent! 🎉';
- if (percentage >= 60) return 'Good job! 👍';
- return 'Keep practicing! 💪';
+ if (percentage >= 80) return 'Excellent!';
+ if (percentage >= 60) return 'Good job!';
+ return 'Keep practicing!';
};
+ const circumference = 2 * Math.PI * 88;
+
return (
{/* Score Display */}
-
Quiz Complete!
-
{getScoreMessage()}
+
+ Quiz Complete!
+
+
+ {getScoreMessage()}
+
{/* Score Circle */}