- You are responsible for maintaining the security of your LongCut account and the credentials associated with it.
+ You are responsible for maintaining the security of your Little universe account and the credentials associated with it.
You must provide accurate information when you sign up and keep your contact details up to date so we can send
important notices about your subscription.
@@ -34,7 +34,7 @@ export default function TermsPage() {
Subscriptions & Billing
- LongCut offers both free access and paid Pro subscriptions that deliver additional features and higher usage
+ Little universe offers both free access and paid Pro subscriptions that deliver additional features and higher usage
limits. When you activate a paid plan, Stripe securely processes your payment information on our behalf. You
authorize us to charge the applicable subscription fees (and any related taxes) at the start of each billing
period until you cancel.
@@ -71,7 +71,7 @@ export default function TermsPage() {
Cancellation
- You can cancel your subscription at any time from your LongCut account settings. Navigate to{' '}
+ You can cancel your subscription at any time from your Little universe account settings. Navigate to{' '}
Settings → Manage billing
{' '}
@@ -83,7 +83,7 @@ export default function TermsPage() {
Acceptable Use
- You agree not to misuse LongCut, interfere with other users, or attempt to access the service using automated
+ You agree not to misuse Little universe, interfere with other users, or attempt to access the service using automated
scripts at a rate that would degrade performance. We may suspend or terminate accounts that violate these
Terms or applicable law.
@@ -93,7 +93,7 @@ export default function TermsPage() {
Changes to These Terms
We may update these Terms from time to time. If we make material changes, we will notify you via email or an
- in-app message and indicate the effective date. Your continued use of LongCut after the update becomes effective
+ in-app message and indicate the effective date. Your continued use of Little universe after the update becomes effective
means you accept the revised Terms.
diff --git a/app/v/[slug]/page.tsx b/app/v/[slug]/page.tsx
index d6e68e40..f954d81c 100644
--- a/app/v/[slug]/page.tsx
+++ b/app/v/[slug]/page.tsx
@@ -86,7 +86,7 @@ export async function generateMetadata({ params }: PageProps): Promise
if (!resolved) {
return {
- title: 'Video Not Found - LongCut',
+ title: 'Video Not Found - Little universe',
description: 'This video analysis could not be found.'
};
}
@@ -106,7 +106,7 @@ export async function generateMetadata({ params }: PageProps): Promise
const thumbnailUrl = video.thumbnail_url || `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`;
return {
- title: `${video.title} - Transcript & Analysis | LongCut`,
+ title: `${video.title} - Transcript & Analysis | Little universe`,
description,
keywords: [
video.title,
@@ -123,7 +123,7 @@ export async function generateMetadata({ params }: PageProps): Promise
description: description,
type: 'video.other',
url: `https://longcut.ai/v/${slugForMeta}`,
- siteName: 'LongCut',
+ siteName: 'Little universe',
images: [
{
url: thumbnailUrl,
@@ -251,7 +251,7 @@ export default async function VideoPage({ params }: PageProps) {
},
"publisher": {
"@type": "Organization",
- "name": "LongCut",
+ "name": "Little universe",
"url": "https://longcut.ai"
},
"author": {
@@ -275,7 +275,7 @@ export default async function VideoPage({ params }: PageProps) {
},
"publisher": {
"@type": "Organization",
- "name": "LongCut",
+ "name": "Little universe",
"url": "https://longcut.ai"
},
"mainEntityOfPage": {
diff --git a/components/ai-assistant-floating.tsx b/components/ai-assistant-floating.tsx
new file mode 100644
index 00000000..4b9d6b9f
--- /dev/null
+++ b/components/ai-assistant-floating.tsx
@@ -0,0 +1,299 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { MessageSquare, X, Minimize2, Maximize2, Pin, PinOff, GripVertical } from "lucide-react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { AIChat } from "@/components/ai-chat";
+import { AIProviderSelector } from "@/components/ai-provider-selector";
+import { cn } from "@/lib/utils";
+import {
+ TranscriptSegment,
+ Topic,
+ Citation,
+ NoteSource,
+ NoteMetadata,
+ VideoInfo,
+ TranslationRequestHandler,
+} from "@/lib/types";
+import { SelectionActionPayload } from "@/components/selection-actions";
+
+interface AIAssistantFloatingProps {
+ transcript: TranscriptSegment[];
+ topics: Topic[];
+ videoId: string;
+ videoTitle?: string;
+ videoInfo?: VideoInfo | null;
+ onCitationClick: (citation: Citation) => void;
+ onTimestampClick: (
+ seconds: number,
+ endSeconds?: number,
+ isCitation?: boolean,
+ citationText?: string,
+ ) => void;
+ cachedSuggestedQuestions?: string[] | null;
+ onSaveNote?: (payload: {
+ text: string;
+ source: NoteSource;
+ sourceId?: string | null;
+ metadata?: NoteMetadata | null;
+ }) => Promise;
+ onTakeNoteFromSelection?: (payload: SelectionActionPayload) => void;
+ selectedLanguage?: string | null;
+ translationCache?: Map;
+ onRequestTranslation?: TranslationRequestHandler;
+ isAuthenticated?: boolean;
+ onRequestSignIn?: () => void;
+ // External control props
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}
+
+export function AIAssistantFloating({
+ transcript,
+ topics,
+ videoId,
+ videoTitle,
+ videoInfo,
+ onCitationClick,
+ onTimestampClick,
+ cachedSuggestedQuestions,
+ onSaveNote,
+ onTakeNoteFromSelection,
+ selectedLanguage,
+ translationCache,
+ onRequestTranslation,
+ isAuthenticated,
+ onRequestSignIn,
+ open,
+ onOpenChange,
+}: AIAssistantFloatingProps) {
+ // Use internal state if not controlled externally
+ const [internalOpen, setInternalOpen] = useState(false);
+ const isOpen = open !== undefined ? open : internalOpen;
+ const handleOpenChange = onOpenChange || setInternalOpen;
+
+ // Prevent closing when pinned - wrapper function
+ const handleOpenChangeWrapper = (newOpen: boolean) => {
+ // If trying to close while pinned, ignore it
+ if (!newOpen && isPinned) {
+ return;
+ }
+ handleOpenChange(newOpen);
+ };
+ const [isMaximized, setIsMaximized] = useState(false);
+
+ // Draggable state
+ const [isPinned, setIsPinned] = useState(false);
+ const [position, setPosition] = useState({ x: 0, y: 0 });
+ const [isDragging, setIsDragging] = useState(false);
+ const dragStartPos = useRef({ x: 0, y: 0 });
+ const dragStartMousePos = useRef({ x: 0, y: 0 });
+ const dialogRef = useRef(null);
+
+ // Load saved position on mount
+ useEffect(() => {
+ const savedPosition = localStorage.getItem('ai-assistant-position');
+ const savedPinned = localStorage.getItem('ai-assistant-pinned');
+ if (savedPosition) {
+ setPosition(JSON.parse(savedPosition));
+ }
+ if (savedPinned) {
+ setIsPinned(JSON.parse(savedPinned));
+ }
+ }, []);
+
+ // Save position to localStorage
+ useEffect(() => {
+ if (isPinned) {
+ localStorage.setItem('ai-assistant-position', JSON.stringify(position));
+ localStorage.setItem('ai-assistant-pinned', JSON.stringify(isPinned));
+ } else {
+ localStorage.removeItem('ai-assistant-position');
+ localStorage.removeItem('ai-assistant-pinned');
+ }
+ }, [position, isPinned]);
+
+ // Drag handlers
+ const handleMouseDown = (e: React.MouseEvent) => {
+ if (!isPinned) return;
+ setIsDragging(true);
+ dragStartPos.current = { ...position };
+ dragStartMousePos.current = { x: e.clientX, y: e.clientY };
+ e.preventDefault();
+ };
+
+ useEffect(() => {
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!isDragging || !isPinned) return;
+
+ const deltaX = e.clientX - dragStartMousePos.current.x;
+ const deltaY = e.clientY - dragStartMousePos.current.y;
+
+ const newX = dragStartPos.current.x + deltaX;
+ const newY = dragStartPos.current.y + deltaY;
+
+ // Constrain to viewport
+ const maxX = window.innerWidth - 100;
+ const maxY = window.innerHeight - 100;
+
+ setPosition({
+ x: Math.max(0, Math.min(newX, maxX)),
+ y: Math.max(0, Math.min(newY, maxY)),
+ });
+ };
+
+ const handleMouseUp = () => {
+ setIsDragging(false);
+ };
+
+ if (isDragging) {
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ }
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [isDragging, isPinned]);
+
+ return (
+ <>
+ {/* Floating Action Button */}
+
+
+ {/* Custom Draggable Dialog */}
+
+
+ {/* Only show overlay when not pinned */}
+ {!isPinned && (
+
+ )}
+
+
+ {/* Header */}
+
+
+ {isPinned &&
}
+
+
+ AI 助手
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Content */}
+
+
+
+
+ >
+ );
+}
diff --git a/components/ai-provider-selector.tsx b/components/ai-provider-selector.tsx
new file mode 100644
index 00000000..6d4e9469
--- /dev/null
+++ b/components/ai-provider-selector.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { ChevronDown } from "lucide-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { toast } from "sonner";
+
+const PROVIDERS = [
+ { value: "grok", label: "Grok", description: "xAI Grok - Fast & capable" },
+ { value: "gemini", label: "Gemini", description: "Google Gemini AI" },
+ { value: "deepseek", label: "DeepSeek", description: "DeepSeek with web search" },
+] as const;
+
+type ProviderValue = typeof PROVIDERS[number]["value"];
+
+const STORAGE_KEY = "ai-provider-preference";
+
+export function AIProviderSelector() {
+ const [currentProvider, setCurrentProvider] = useState("grok");
+ const [isOpen, setIsOpen] = useState(false);
+
+ // Load saved preference on mount
+ useEffect(() => {
+ const saved = localStorage.getItem(STORAGE_KEY);
+ if (saved && PROVIDERS.some(p => p.value === saved)) {
+ setCurrentProvider(saved as ProviderValue);
+ } else {
+ // Check current environment
+ const checkProvider = async () => {
+ try {
+ const res = await fetch("/api/ai/provider");
+ if (res.ok) {
+ const data = await res.json();
+ if (data.provider) {
+ setCurrentProvider(data.provider);
+ }
+ }
+ } catch {
+ // Ignore error, use default
+ }
+ };
+ void checkProvider();
+ }
+ }, []);
+
+ const handleProviderChange = async (value: ProviderValue) => {
+ setIsOpen(false);
+
+ // Call API to switch provider
+ try {
+ const res = await fetch("/api/ai/provider", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ provider: value }),
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ toast.error(`切换失败: ${data.error || '未知错误'}`);
+ return;
+ }
+
+ // Success - update local state and storage
+ setCurrentProvider(value);
+ localStorage.setItem(STORAGE_KEY, value);
+ toast.success(`已切换到 ${PROVIDERS.find(p => p.value === value)?.label}`);
+ } catch (error) {
+ console.error("Error switching provider:", error);
+ toast.error("切换提供商时发生错误");
+ }
+ };
+
+ const current = PROVIDERS.find(p => p.value === currentProvider) || PROVIDERS[0];
+
+ return (
+
+
+
+
+
+ {PROVIDERS.map((provider) => (
+ handleProviderChange(provider.value)}
+ className="flex flex-col items-start gap-1 py-2"
+ >
+
+ {provider.label}
+ {provider.value === currentProvider && (
+ (Active)
+ )}
+
+
+ {provider.description}
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/auth-modal.tsx b/components/auth-modal.tsx
index 30829450..e6cda443 100644
--- a/components/auth-modal.tsx
+++ b/components/auth-modal.tsx
@@ -156,7 +156,7 @@ export function AuthModal({ open, onOpenChange, onSuccess, trigger = 'manual', c
}
default:
return {
- title: 'Sign in to LongCut',
+ title: 'Sign in to Little universe',
description: 'Create an account or sign in to save your video analyses and access them anytime.',
benefits: [
'Save your analyzed videos',
diff --git a/components/highlights-panel.tsx b/components/highlights-panel.tsx
index 31dc7f9c..93768884 100644
--- a/components/highlights-panel.tsx
+++ b/components/highlights-panel.tsx
@@ -15,6 +15,15 @@ const DEFAULT_LABELS = {
generatingYourReels: "Generating your reels...",
};
+// Pastel color palette from reference design
+const CATEGORY_COLORS = [
+ "bg-[#FF8A80]", // Coral
+ "bg-[#80CBC4]", // Mint green
+ "bg-[#F48FB1]", // Light pink
+ "bg-[#B39DDB]", // Lavender
+ "bg-[#81D4FA]", // Light blue
+];
+
interface HighlightsPanelProps {
topics: Topic[];
selectedTopic: Topic | null;
@@ -89,12 +98,26 @@ export function HighlightsPanel({
};
}, [selectedLanguage, onRequestTranslation]);
+ // Get color for topic based on index
+ const getTopicColor = (index: number) => {
+ return CATEGORY_COLORS[index % CATEGORY_COLORS.length];
+ };
+
return (
-
-
+
+
-
+
{formatDuration(currentTime)} / {formatDuration(videoDuration)}
@@ -121,7 +144,10 @@ export function HighlightsPanel({
size="sm"
variant={isPlayingAll ? "secondary" : "default"}
onClick={onPlayAll}
- className="h-7 text-xs"
+ className="h-8 px-4 text-xs text-white border-0 shadow-md rounded-full"
+ style={{
+ background: "linear-gradient(135deg, #81D4FA 0%, #4FC3F7 100%)",
+ }}
>
{isPlayingAll ? (
<>
@@ -142,8 +168,8 @@ export function HighlightsPanel({
{/* Loading overlay */}
{isLoadingThemeTopics && (
-
-
+
+
{translatedLabels.generatingYourReels}
diff --git a/components/history-sidebar.tsx b/components/history-sidebar.tsx
new file mode 100644
index 00000000..80170e5d
--- /dev/null
+++ b/components/history-sidebar.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { History, ChevronLeft, ChevronRight, X } from "lucide-react";
+import { cn } from "@/lib/utils";
+import Link from "next/link";
+
+const HISTORY_STORAGE_KEY = "longcut-watch-history";
+const MAX_HISTORY_ITEMS = 50;
+
+export interface WatchHistoryItem {
+ videoId: string;
+ title: string;
+ thumbnail: string;
+ watchedAt: number; // timestamp
+ duration?: number;
+ lastPosition?: number; // seconds
+}
+
+interface HistorySidebarProps {
+ currentVideoId?: string;
+ onCollapsedChange?: (collapsed: boolean) => void;
+ defaultCollapsed?: boolean;
+}
+
+export function useWatchHistory() {
+ const [history, setHistory] = useState([]);
+
+ useEffect(() => {
+ const stored = localStorage.getItem(HISTORY_STORAGE_KEY);
+ if (stored) {
+ try {
+ setHistory(JSON.parse(stored));
+ } catch {
+ setHistory([]);
+ }
+ }
+ }, []);
+
+ const addToHistory = useCallback(
+ (item: Omit) => {
+ setHistory((prev) => {
+ // Remove existing entry for same video
+ const filtered = prev.filter((h) => h.videoId !== item.videoId);
+ const newHistory = [
+ { ...item, watchedAt: Date.now() },
+ ...filtered,
+ ].slice(0, MAX_HISTORY_ITEMS);
+
+ localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(newHistory));
+ return newHistory;
+ });
+ },
+ [],
+ );
+
+ const removeFromHistory = useCallback((videoId: string) => {
+ setHistory((prev) => {
+ const filtered = prev.filter((h) => h.videoId !== videoId);
+ localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(filtered));
+ return filtered;
+ });
+ }, []);
+
+ const clearHistory = useCallback(() => {
+ localStorage.removeItem(HISTORY_STORAGE_KEY);
+ setHistory([]);
+ }, []);
+
+ return { history, addToHistory, removeFromHistory, clearHistory };
+}
+
+export function HistorySidebar({
+ currentVideoId,
+ onCollapsedChange,
+ defaultCollapsed = false,
+}: HistorySidebarProps) {
+ const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
+ const { history, removeFromHistory } = useWatchHistory();
+
+ const toggleCollapsed = () => {
+ const newState = !isCollapsed;
+ setIsCollapsed(newState);
+ onCollapsedChange?.(newState);
+ };
+
+ // Format relative time
+ const formatRelativeTime = (timestamp: number) => {
+ const diff = Date.now() - timestamp;
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(diff / 3600000);
+ const days = Math.floor(diff / 86400000);
+
+ if (minutes < 1) return "刚刚";
+ if (minutes < 60) return `${minutes}分钟前`;
+ if (hours < 24) return `${hours}小时前`;
+ if (days < 7) return `${days}天前`;
+ return new Date(timestamp).toLocaleDateString("zh-CN");
+ };
+
+ if (isCollapsed) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ {/* History List */}
+
+
+ {history.length === 0 ? (
+
+ 暂无观看记录
+
+ ) : (
+ history.map((item) => (
+
+
+
+

+ {currentVideoId === item.videoId && (
+
+
+ 当前
+
+
+ )}
+
+
+
+ {item.title}
+
+
+ {formatRelativeTime(item.watchedAt)}
+
+
+
+ {/* Delete button */}
+
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/components/notes-panel.tsx b/components/notes-panel.tsx
index fc9be09e..7e0f6011 100644
--- a/components/notes-panel.tsx
+++ b/components/notes-panel.tsx
@@ -1,21 +1,24 @@
-import { useMemo, type ReactNode } from "react";
+import { useMemo, useState, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Note, NoteSource, NoteMetadata } from "@/lib/types";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { Trash2, Clock, Plus } from "lucide-react";
+import { Trash2, Clock, Plus, Download, Edit, Search, Filter, X } from "lucide-react";
import { NoteEditor } from "@/components/note-editor";
import { cn } from "@/lib/utils";
+import { exportNotesToMarkdown } from "@/lib/markdown-exporter";
+import { getLocalNotes, type LocalNote } from "@/lib/local-notes";
function formatDateOnly(dateString: string): string {
const date = new Date(dateString);
- return date.toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric'
+ return date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric'
});
}
@@ -80,6 +83,7 @@ export interface EditingNote {
text: string;
metadata?: NoteMetadata | null;
source?: string;
+ id?: string; // Note ID for editing existing note
}
interface NotesPanelProps {
@@ -88,54 +92,148 @@ interface NotesPanelProps {
editingNote?: EditingNote | null;
onSaveEditingNote?: (payload: { noteText: string; selectedText: string; metadata?: NoteMetadata }) => void;
onCancelEditing?: () => void;
+ onEditNote?: (note: Note) => void; // New: Edit existing note
isAuthenticated?: boolean;
onSignInClick?: () => void;
currentTime?: number;
onTimestampClick?: (seconds: number) => void;
onAddNote?: () => void;
+ // Export props
+ videoInfo?: { youtubeId: string; title?: string; author?: string; duration?: number; description?: string; thumbnailUrl?: string } | null;
+ topics?: any[]; // Topic array from video analysis
}
function getSourceLabel(source: NoteSource) {
switch (source) {
case "chat":
- return "AI Message";
+ return "💬 AI 对话";
case "takeaways":
- return "Takeaways";
+ return "🎯 关键要点";
case "transcript":
- return "Transcript";
+ return "🎬 视频片段";
default:
- return "Custom";
+ return "✏️ 自定义笔记";
}
}
+type FilterType = 'all' | 'transcript' | 'chat' | 'custom' | 'takeaways';
+
export function NotesPanel({
notes = [],
onDeleteNote,
editingNote,
onSaveEditingNote,
onCancelEditing,
+ onEditNote,
isAuthenticated = true,
onSignInClick,
currentTime,
onTimestampClick,
- onAddNote
+ onAddNote,
+ videoInfo,
+ topics
}: NotesPanelProps) {
+ const [isExporting, setIsExporting] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [filterType, setFilterType] = useState('all');
+ const [showFilters, setShowFilters] = useState(false);
+
+ const handleExport = async () => {
+ if (!videoInfo) return;
+
+ setIsExporting(true);
+ try {
+ // Get local notes for this video
+ const localNotes = getLocalNotes(videoInfo.youtubeId);
+
+ // Convert cloud notes to LocalNote format
+ const cloudNotesAsLocal: LocalNote[] = notes.map(note => ({
+ id: note.id,
+ youtubeId: videoInfo.youtubeId,
+ source: note.source,
+ sourceId: note.sourceId || undefined,
+ text: note.text,
+ metadata: note.metadata || undefined,
+ createdAt: note.createdAt,
+ updatedAt: note.updatedAt,
+ synced: true
+ }));
+
+ // Combine with displayed notes (removing duplicates)
+ const allNotes: LocalNote[] = [
+ ...cloudNotesAsLocal,
+ ...localNotes.filter(ln => !cloudNotesAsLocal.find(n => n.id === ln.id))
+ ];
+
+ // Export to markdown - convert videoInfo to VideoInfo format
+ const exportVideoInfo = videoInfo ? {
+ videoId: videoInfo.youtubeId,
+ youtubeId: videoInfo.youtubeId,
+ title: videoInfo.title || '未命名视频',
+ author: videoInfo.author || '未知作者',
+ thumbnail: videoInfo.thumbnailUrl || '',
+ duration: videoInfo.duration || 0,
+ description: videoInfo.description
+ } : null;
+
+ if (exportVideoInfo) {
+ await exportNotesToMarkdown(exportVideoInfo, allNotes, topics);
+ }
+ } catch (error) {
+ console.error('Export failed:', error);
+ } finally {
+ setIsExporting(false);
+ }
+ };
+
+ const handleEditClick = (note: Note) => {
+ if (onEditNote) {
+ onEditNote(note);
+ }
+ };
+
+ // Filter and search notes
+ const filteredNotes = useMemo(() => {
+ let filtered = notes;
+
+ // Apply source filter
+ if (filterType !== 'all') {
+ filtered = filtered.filter(note => note.source === filterType);
+ }
+
+ // Apply search filter
+ if (searchQuery.trim()) {
+ const query = searchQuery.toLowerCase();
+ filtered = filtered.filter(note =>
+ note.text?.toLowerCase().includes(query) ||
+ note.metadata?.selectedText?.toLowerCase().includes(query) ||
+ note.metadata?.selectionContext?.toLowerCase().includes(query)
+ );
+ }
+
+ return filtered;
+ }, [notes, filterType, searchQuery]);
+
+ // Group filtered notes by source
const groupedNotes = useMemo(() => {
- return notes.reduce>((acc, note) => {
+ return filteredNotes.reduce>((acc, note) => {
const list = acc[note.source] || [];
list.push(note);
acc[note.source] = list;
return acc;
}, {} as Record);
- }, [notes]);
+ }, [filteredNotes]);
+
+ const noteCount = notes.length;
+ const filteredCount = filteredNotes.length;
if (!isAuthenticated) {
return (
-
Sign in to save notes
+
登录以保存笔记
- Highlight transcript moments and keep your takeaways in one place.
+ 高亮字幕或聊天内容来记录笔记
);
}
- if (!notes.length && !editingNote) {
- return (
-
-
Your saved notes will appear here. Highlight transcript or chat text to take a note.
- {onAddNote && (
-
- )}
-
- );
- }
-
return (
-
- {/* Add Note Button */}
- {!editingNote && onAddNote && (
-
- )}
+
+ {/* Top Action Bar */}
+
+ {/* Search Bar */}
+
+
+ setSearchQuery(e.target.value)}
+ className="pl-9 h-9 text-xs rounded-xl border-slate-200 bg-white/50 focus:bg-white"
+ />
+ {searchQuery && (
+
+ )}
+
+
+ {/* Action Buttons Row */}
+
+ {/* Export Button */}
+ {videoInfo && (
+
+
+
+
+
+ 导出为 Markdown 文件
+
+
+ )}
+
+ {/* Filter Toggle */}
+
+
+
+
+
+ 筛选笔记类型
+
+
+
+ {/* Add Note Button */}
+ {!editingNote && onAddNote && (
+
+ )}
+
+
+ {/* Filter Options (Expandable) */}
+ {showFilters && (
+
+ {[
+ { value: 'all' as FilterType, label: '全部' },
+ { value: 'transcript' as FilterType, label: '🎬 视频' },
+ { value: 'chat' as FilterType, label: '💬 对话' },
+ { value: 'custom' as FilterType, label: '✏️ 自定义' },
+ { value: 'takeaways' as FilterType, label: '🎯 要点' },
+ ].map((filter) => (
+
+ ))}
+
+ )}
+
+ {/* Note Count */}
+ {(searchQuery || filterType !== 'all') && (
+
+ {filteredCount} / {noteCount} 条笔记
+
+ )}
+
{/* Note Editor - shown when editing */}
{editingNote && onSaveEditingNote && onCancelEditing && (
@@ -193,11 +370,28 @@ export function NotesPanel({
/>
)}
+ {/* Empty State */}
+ {!filteredNotes.length && !editingNote && (
+
+ {searchQuery || filterType !== 'all' ? (
+
没有找到匹配的笔记
+ ) : (
+ <>
+
你的笔记将显示在这里
+
高亮字幕或聊天内容来创建笔记
+ >
+ )}
+
+ )}
+
{/* Saved Notes - grouped by source */}
{Object.entries(groupedNotes).map(([source, sourceNotes]) => (
-
+
{getSourceLabel(source as NoteSource)}
+
+ {sourceNotes.length}
+
{sourceNotes.map((note) => {
@@ -263,9 +457,12 @@ export function NotesPanel({
!isTranscriptNote && note.metadata?.transcript?.segmentIndex !== undefined;
return (
-
+
-
+
handleEditClick(note)}
+ >
{quoteText && (
{shouldShowSegmentInfo && note.metadata?.transcript && note.metadata.transcript.segmentIndex !== undefined && (
- Segment #{note.metadata.transcript.segmentIndex + 1}
+ 片段 #{note.metadata.transcript.segmentIndex + 1}
)}
- {onDeleteNote && (
-
-
-
-
-
- Delete note
-
-
- )}
+
+ {/* Action Buttons - Always visible on hover */}
+
+ {onEditNote && (
+
+ )}
+ {onDeleteNote && (
+
+ )}
+
);
diff --git a/components/right-column-panel.tsx b/components/right-column-panel.tsx
new file mode 100644
index 00000000..5d295e0c
--- /dev/null
+++ b/components/right-column-panel.tsx
@@ -0,0 +1,357 @@
+"use client";
+
+import { useState, useImperativeHandle, forwardRef } from "react";
+import {
+ ResizablePanelGroup,
+ ResizablePanel,
+ ResizableHandle,
+} from "@/components/ui/resizable";
+import { TranscriptViewer } from "@/components/transcript-viewer";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { Languages, PenLine, Columns2, LayoutList } from "lucide-react";
+import {
+ TranscriptSegment,
+ Topic,
+ Citation,
+ Note,
+ NoteSource,
+ NoteMetadata,
+ VideoInfo,
+ TranslationRequestHandler,
+} from "@/lib/types";
+import { SelectionActionPayload } from "@/components/selection-actions";
+import { NotesPanel, EditingNote } from "@/components/notes-panel";
+import { cn } from "@/lib/utils";
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { LanguageSelector } from "@/components/language-selector";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+const translationSelectorEnabled = (() => {
+ const raw = process.env.NEXT_PUBLIC_ENABLE_TRANSLATION_SELECTOR;
+ if (!raw) {
+ return false;
+ }
+ const normalized = raw.toLowerCase();
+ return (
+ normalized === "true" ||
+ normalized === "1" ||
+ normalized === "yes" ||
+ normalized === "on"
+ );
+})();
+
+type ViewMode = "tabs" | "split";
+
+interface RightColumnPanelProps {
+ transcript: TranscriptSegment[];
+ selectedTopic: Topic | null;
+ onTimestampClick: (
+ seconds: number,
+ endSeconds?: number,
+ isCitation?: boolean,
+ citationText?: string,
+ isWithinHighlightReel?: boolean,
+ isWithinCitationHighlight?: boolean,
+ ) => void;
+ currentTime?: number;
+ topics?: Topic[];
+ citationHighlight?: Citation | null;
+ videoId: string;
+ videoTitle?: string;
+ videoInfo?: VideoInfo | null;
+ onCitationClick: (citation: Citation) => void;
+ notes?: Note[];
+ onSaveNote?: (payload: {
+ text: string;
+ source: NoteSource;
+ sourceId?: string | null;
+ metadata?: NoteMetadata | null;
+ }) => Promise
;
+ onTakeNoteFromSelection?: (payload: SelectionActionPayload) => void;
+ editingNote?: EditingNote | null;
+ onSaveEditingNote?: (payload: {
+ noteText: string;
+ selectedText: string;
+ metadata?: NoteMetadata;
+ }) => void;
+ onCancelEditing?: () => void;
+ onEditNote?: (note: Note) => void;
+ isAuthenticated?: boolean;
+ onRequestSignIn?: () => void;
+ selectedLanguage?: string | null;
+ translationCache?: Map;
+ onRequestTranslation?: TranslationRequestHandler;
+ onLanguageChange?: (languageCode: string | null) => void;
+ availableLanguages?: string[];
+ currentSourceLanguage?: string;
+ onRequestExport?: () => void;
+ exportButtonState?: {
+ tooltip?: string;
+ disabled?: boolean;
+ badgeLabel?: string;
+ isLoading?: boolean;
+ };
+ onAddNote?: () => void;
+}
+
+export interface RightColumnPanelHandle {
+ switchToTranscript: () => void;
+ switchToNotes: () => void;
+ setViewMode: (mode: ViewMode) => void;
+}
+
+export const RightColumnPanel = forwardRef<
+ RightColumnPanelHandle,
+ RightColumnPanelProps
+>(
+ (
+ {
+ transcript,
+ selectedTopic,
+ onTimestampClick,
+ currentTime,
+ topics,
+ citationHighlight,
+ videoId,
+ videoTitle,
+ videoInfo,
+ onCitationClick,
+ notes,
+ onSaveNote,
+ onTakeNoteFromSelection,
+ editingNote,
+ onSaveEditingNote,
+ onCancelEditing,
+ onEditNote,
+ isAuthenticated,
+ onRequestSignIn,
+ selectedLanguage = null,
+ translationCache,
+ onRequestTranslation,
+ onLanguageChange,
+ availableLanguages,
+ currentSourceLanguage,
+ onRequestExport,
+ exportButtonState,
+ onAddNote,
+ },
+ ref,
+ ) => {
+ const [viewMode, setViewMode] = useState("tabs");
+ const [activeTab, setActiveTab] = useState<"transcript" | "notes">(
+ "transcript",
+ );
+ const showTranslationSelector = translationSelectorEnabled;
+
+ // Expose methods to parent
+ useImperativeHandle(ref, () => ({
+ switchToTranscript: () => {
+ setActiveTab("transcript");
+ },
+ switchToNotes: () => {
+ setActiveTab("notes");
+ },
+ setViewMode: (mode: ViewMode) => {
+ setViewMode(mode);
+ },
+ }));
+
+ // Transcript component (reused in both modes)
+ const transcriptComponent = (
+
+ );
+
+ // Notes component (reused in both modes)
+ const notesComponent = (
+
+
+
+ );
+
+ return (
+
+ {/* Header with mode toggle */}
+
+
+ {showTranslationSelector ? (
+
{
+ if (tab === "transcript" || tab === "notes") {
+ setActiveTab(tab);
+ }
+ }}
+ onLanguageChange={onLanguageChange}
+ onRequestSignIn={onRequestSignIn}
+ />
+ ) : (
+
+
+
+ )}
+
+
+ {/* Notes tab button (only in tabs mode) */}
+ {viewMode === "tabs" && (
+
+ )}
+
+ {/* View mode toggle */}
+
+
+
+
+
+
+ {viewMode === "split" ? "切换到标签模式" : "切换到分屏模式"}
+
+
+
+
+
+ {/* Content area */}
+
+ {viewMode === "split" ? (
+ /* Split mode: Transcript on top, Notes on bottom with resizable divider */
+
+
+
+ {transcriptComponent}
+
+
+
+
+
+
+
+ {notesComponent}
+
+
+
+
+ ) : (
+ /* Tabs mode: Show one at a time */
+ <>
+
+ {transcriptComponent}
+
+
+ {notesComponent}
+
+ >
+ )}
+
+
+ );
+ },
+);
+
+RightColumnPanel.displayName = "RightColumnPanel";
diff --git a/components/right-column-tabs.tsx b/components/right-column-tabs.tsx
index 5d90fa88..de5a47b0 100644
--- a/components/right-column-tabs.tsx
+++ b/components/right-column-tabs.tsx
@@ -6,7 +6,16 @@ import { AIChat } from "@/components/ai-chat";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Languages, MessageSquare, PenLine } from "lucide-react";
-import { TranscriptSegment, Topic, Citation, Note, NoteSource, NoteMetadata, VideoInfo, TranslationRequestHandler } from "@/lib/types";
+import {
+ TranscriptSegment,
+ Topic,
+ Citation,
+ Note,
+ NoteSource,
+ NoteMetadata,
+ VideoInfo,
+ TranslationRequestHandler,
+} from "@/lib/types";
import { SelectionActionPayload } from "@/components/selection-actions";
import { NotesPanel, EditingNote } from "@/components/notes-panel";
import { cn } from "@/lib/utils";
@@ -19,13 +28,25 @@ const translationSelectorEnabled = (() => {
return false;
}
const normalized = raw.toLowerCase();
- return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
+ return (
+ normalized === "true" ||
+ normalized === "1" ||
+ normalized === "yes" ||
+ normalized === "on"
+ );
})();
interface RightColumnTabsProps {
transcript: TranscriptSegment[];
selectedTopic: Topic | null;
- onTimestampClick: (seconds: number, endSeconds?: number, isCitation?: boolean, citationText?: string, isWithinHighlightReel?: boolean, isWithinCitationHighlight?: boolean) => void;
+ onTimestampClick: (
+ seconds: number,
+ endSeconds?: number,
+ isCitation?: boolean,
+ citationText?: string,
+ isWithinHighlightReel?: boolean,
+ isWithinCitationHighlight?: boolean,
+ ) => void;
currentTime?: number;
topics?: Topic[];
citationHighlight?: Citation | null;
@@ -36,10 +57,19 @@ interface RightColumnTabsProps {
showChatTab?: boolean;
cachedSuggestedQuestions?: string[] | null;
notes?: Note[];
- onSaveNote?: (payload: { text: string; source: NoteSource; sourceId?: string | null; metadata?: NoteMetadata | null }) => Promise;
+ onSaveNote?: (payload: {
+ text: string;
+ source: NoteSource;
+ sourceId?: string | null;
+ metadata?: NoteMetadata | null;
+ }) => Promise;
onTakeNoteFromSelection?: (payload: SelectionActionPayload) => void;
editingNote?: EditingNote | null;
- onSaveEditingNote?: (payload: { noteText: string; selectedText: string; metadata?: NoteMetadata }) => void;
+ onSaveEditingNote?: (payload: {
+ noteText: string;
+ selectedText: string;
+ metadata?: NoteMetadata;
+ }) => void;
onCancelEditing?: () => void;
isAuthenticated?: boolean;
onRequestSignIn?: () => void;
@@ -65,117 +95,129 @@ export interface RightColumnTabsHandle {
switchToNotes: () => void;
}
-export const RightColumnTabs = forwardRef(({
- transcript,
- selectedTopic,
- onTimestampClick,
- currentTime,
- topics,
- citationHighlight,
- videoId,
- videoTitle,
- videoInfo,
- onCitationClick,
- showChatTab,
- cachedSuggestedQuestions,
- notes,
- onSaveNote,
- onTakeNoteFromSelection,
- editingNote,
- onSaveEditingNote,
- onCancelEditing,
- isAuthenticated,
- onRequestSignIn,
- selectedLanguage = null,
- translationCache,
- onRequestTranslation,
- onLanguageChange,
- availableLanguages,
- currentSourceLanguage,
- onRequestExport,
- exportButtonState,
- onAddNote
-}, ref) => {
- const [activeTab, setActiveTab] = useState<"transcript" | "chat" | "notes">("transcript");
- const showTranslationSelector = translationSelectorEnabled;
-
- // Expose methods to parent to switch tabs
- useImperativeHandle(ref, () => ({
- switchToTranscript: () => {
- setActiveTab("transcript");
- },
- switchToChat: () => {
- if (showChatTab) {
- setActiveTab("chat");
- }
+export const RightColumnTabs = forwardRef<
+ RightColumnTabsHandle,
+ RightColumnTabsProps
+>(
+ (
+ {
+ transcript,
+ selectedTopic,
+ onTimestampClick,
+ currentTime,
+ topics,
+ citationHighlight,
+ videoId,
+ videoTitle,
+ videoInfo,
+ onCitationClick,
+ showChatTab,
+ cachedSuggestedQuestions,
+ notes,
+ onSaveNote,
+ onTakeNoteFromSelection,
+ editingNote,
+ onSaveEditingNote,
+ onCancelEditing,
+ isAuthenticated,
+ onRequestSignIn,
+ selectedLanguage = null,
+ translationCache,
+ onRequestTranslation,
+ onLanguageChange,
+ availableLanguages,
+ currentSourceLanguage,
+ onRequestExport,
+ exportButtonState,
+ onAddNote,
},
- switchToNotes: () => {
- setActiveTab("notes");
- }
- }));
+ ref,
+ ) => {
+ const [activeTab, setActiveTab] = useState<"transcript" | "chat" | "notes">(
+ "transcript",
+ );
+ const showTranslationSelector = translationSelectorEnabled;
- useEffect(() => {
- // If chat tab is removed while active, switch to transcript
- if (!showChatTab && activeTab === "chat") {
- setActiveTab("transcript");
- }
- }, [showChatTab, activeTab]);
+ // Expose methods to parent to switch tabs
+ useImperativeHandle(ref, () => ({
+ switchToTranscript: () => {
+ setActiveTab("transcript");
+ },
+ switchToChat: () => {
+ if (showChatTab) {
+ setActiveTab("chat");
+ }
+ },
+ switchToNotes: () => {
+ setActiveTab("notes");
+ },
+ }));
- return (
-
-
-
- {showTranslationSelector ? (
-
- ) : (
-
-
- )}
+
+ {showChatTab && (
+
setActiveTab("chat")}
+ className={cn(
+ "flex-1 justify-center gap-2 rounded-2xl",
+ activeTab === "chat"
+ ? "bg-neutral-100 text-foreground"
+ : "text-muted-foreground hover:text-foreground hover:bg-white/50",
+ )}
+ >
+
+ Chat
+
+ )}
+ {/* Notes Tab - Temporarily Disabled
Notes
-
-
-
- {/* Keep both components mounted but toggle visibility */}
-
-
-
-
+
+
+ {/* Keep both components mounted but toggle visibility */}
+
+
+
+
+ {/* Notes Panel - Temporarily Disabled
@@ -246,9 +300,11 @@ export const RightColumnTabs = forwardRef
-
-
- );
-});
+ */}
+
+
+ );
+ },
+);
RightColumnTabs.displayName = "RightColumnTabs";
diff --git a/components/topic-card.tsx b/components/topic-card.tsx
index bb149efb..5c81c1e1 100644
--- a/components/topic-card.tsx
+++ b/components/topic-card.tsx
@@ -5,6 +5,15 @@ import { Topic, TranslationRequestHandler } from "@/lib/types";
import { formatDuration, getTopicHSLColor } from "@/lib/utils";
import { cn } from "@/lib/utils";
+// Pastel color palette from reference design
+const CATEGORY_COLORS = [
+ "#FF8A80", // Coral
+ "#80CBC4", // Mint green
+ "#F48FB1", // Light pink
+ "#B39DDB", // Lavender
+ "#81D4FA", // Light blue
+];
+
interface TopicCardProps {
topic: Topic;
isSelected: boolean;
@@ -17,7 +26,7 @@ interface TopicCardProps {
}
export function TopicCard({ topic, isSelected, onClick, topicIndex, onPlayTopic, videoId, selectedLanguage = null, onRequestTranslation }: TopicCardProps) {
- const topicColor = getTopicHSLColor(topicIndex, videoId);
+ const topicColor = CATEGORY_COLORS[topicIndex % CATEGORY_COLORS.length];
const [translatedTitle, setTranslatedTitle] = useState
(null);
const [isLoadingTranslation, setIsLoadingTranslation] = useState(false);
@@ -35,12 +44,12 @@ export function TopicCard({ topic, isSelected, onClick, topicIndex, onPlayTopic,
// Request translation
setIsLoadingTranslation(true);
-
+
// Cache key includes source text to avoid collisions when topic ids are reused
const cacheKey = `topic-title:${selectedLanguage}:${topic.title}`;
-
+
let isCancelled = false;
-
+
onRequestTranslation(topic.title, cacheKey, 'topic')
.then(translation => {
if (!isCancelled) {
@@ -71,34 +80,30 @@ export function TopicCard({ topic, isSelected, onClick, topicIndex, onPlayTopic,
onPlayTopic();
}
};
-
+
return (
-
+
{selectedLanguage !== null
? (isLoadingTranslation ? "Translating..." : translatedTitle || topic.title)
: topic.title
@@ -107,7 +112,7 @@ export function TopicCard({ topic, isSelected, onClick, topicIndex, onPlayTopic,
-
+
{formatDuration(topic.duration)}
diff --git a/components/transcript-viewer.tsx b/components/transcript-viewer.tsx
index b55a71f9..4811e8c8 100644
--- a/components/transcript-viewer.tsx
+++ b/components/transcript-viewer.tsx
@@ -560,6 +560,7 @@ export function TranscriptViewer({
// But usually click implies mousedown and mouseup at same location.
// Seek to the start of the segment
+ console.log('[TranscriptViewer] handleSegmentClick:', segment.start, segment.text.substring(0, 50));
onTimestampClick(segment.start);
};
diff --git a/components/ui/resizable.tsx b/components/ui/resizable.tsx
new file mode 100644
index 00000000..abaf0edf
--- /dev/null
+++ b/components/ui/resizable.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { GripVertical } from "lucide-react";
+import {
+ Group,
+ Panel,
+ Separator,
+ type GroupProps,
+} from "react-resizable-panels";
+
+import { cn } from "@/lib/utils";
+
+// Wrapper that supports "direction" alias (for shadcn compatibility) while using the library's "orientation" prop
+interface ResizablePanelGroupProps extends Omit {
+ direction?: "horizontal" | "vertical";
+ orientation?: "horizontal" | "vertical";
+}
+
+const ResizablePanelGroup = ({
+ className,
+ direction,
+ orientation,
+ ...props
+}: ResizablePanelGroupProps) => (
+
+);
+
+const ResizablePanel = Panel;
+
+const ResizableHandle = ({
+ withHandle,
+ className,
+ ...props
+}: React.ComponentProps & {
+ withHandle?: boolean;
+}) => (
+ div]:rotate-90",
+ className,
+ )}
+ {...props}
+ >
+ {withHandle && (
+
+
+
+ )}
+
+);
+
+export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
diff --git a/components/url-input-with-branding.tsx b/components/url-input-with-branding.tsx
index 73e89a83..dd66be04 100644
--- a/components/url-input-with-branding.tsx
+++ b/components/url-input-with-branding.tsx
@@ -66,21 +66,21 @@ export function UrlInputWithBranding({ onSubmit, isLoading = false, initialUrl,
>
{/* Top row: Branding + Input field only */}