import { useState, useEffect, useMemo, useRef, useDeferredValue } from 'react'; import { useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'motion/react'; import { ArrowUp, Check, ChevronDown, Clock, Copy, ImagePlus, Pencil, Trash2, Search, Settings, Sun, Moon, Monitor, BotOff, ChevronUp, Upload, Sparkles, Atom, X, } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; import { LanguageSwitcher } from '@/components/language-switcher'; import { createLogger } from '@/lib/logger'; import { Button } from '@/components/ui/button'; import { InputGroup, InputGroupInput, InputGroupButton } from '@/components/ui/input-group'; import { Textarea as UITextarea } from '@/components/ui/textarea'; import { cn } from '@/lib/utils'; import { SettingsDialog } from '@/components/settings'; import { GenerationToolbar } from '@/components/generation/generation-toolbar'; import { AgentBar } from '@/components/agent/agent-bar'; import { useTheme } from '@/lib/hooks/use-theme'; import { nanoid } from 'nanoid'; import { storePdfBlob } from '@/lib/utils/image-storage'; import type { UserRequirements } from '@/lib/types/generation'; import { useSettingsStore } from '@/lib/store/settings'; import { useUserProfileStore, AVATAR_OPTIONS } from '@/lib/store/user-profile'; import { StageListItem, listStages, deleteStageData, renameStage, getFirstSlideByStages, } from '@/lib/utils/stage-storage'; import { ThumbnailSlide } from '@/components/slide-renderer/components/ThumbnailSlide'; import type { Slide } from '@/lib/types/slides'; import { useMediaGenerationStore } from '@/lib/store/media-generation'; import { toast } from 'sonner'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useDraftCache } from '@/lib/hooks/use-draft-cache'; import { SpeechButton } from '@/components/audio/speech-button'; import { useImportClassroom } from '@/lib/import/use-import-classroom'; const log = createLogger('Home'); const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled'; const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen'; const INTERACTIVE_MODE_STORAGE_KEY = 'interactiveModeEnabled'; interface FormState { pdfFile: File | null; requirement: string; webSearch: boolean; interactiveMode: boolean; } const initialFormState: FormState = { pdfFile: null, requirement: '', webSearch: false, interactiveMode: false, }; function HomePage() { const { t } = useI18n(); const { theme, setTheme } = useTheme(); const navigate = useNavigate(); const [form, setForm] = useState(initialFormState); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsSection, setSettingsSection] = useState< import('@/lib/types/settings').SettingsSection | undefined >(undefined); // Draft cache for requirement text const { cachedValue: cachedRequirement, updateCache: updateRequirementCache } = useDraftCache({ key: 'requirementDraft' }); // Model setup state const currentModelId = useSettingsStore((s) => s.modelId); const [recentOpen, setRecentOpen] = useState(true); const persistRecentOpen = (next: boolean) => { setRecentOpen(next); try { localStorage.setItem(RECENT_OPEN_STORAGE_KEY, String(next)); } catch { /* ignore */ } }; // Hydrate client-only state after mount (avoids SSR mismatch) /* eslint-disable react-hooks/set-state-in-effect -- Hydration from localStorage must happen in effect */ useEffect(() => { try { const saved = localStorage.getItem(RECENT_OPEN_STORAGE_KEY); if (saved !== null) setRecentOpen(saved !== 'false'); } catch { /* localStorage unavailable */ } try { const savedWebSearch = localStorage.getItem(WEB_SEARCH_STORAGE_KEY); const savedInteractiveMode = localStorage.getItem(INTERACTIVE_MODE_STORAGE_KEY); const updates: Partial = {}; if (savedWebSearch === 'true') updates.webSearch = true; if (savedInteractiveMode === 'true') updates.interactiveMode = true; if (Object.keys(updates).length > 0) { setForm((prev) => ({ ...prev, ...updates })); } } catch { /* localStorage unavailable */ } }, []); /* eslint-enable react-hooks/set-state-in-effect */ // Restore requirement draft from cache (derived state pattern — no effect needed) const [prevCachedRequirement, setPrevCachedRequirement] = useState(cachedRequirement); if (cachedRequirement !== prevCachedRequirement) { setPrevCachedRequirement(cachedRequirement); if (cachedRequirement) { setForm((prev) => ({ ...prev, requirement: cachedRequirement })); } } const [themeOpen, setThemeOpen] = useState(false); const [error, setError] = useState(null); const [classrooms, setClassrooms] = useState([]); const [thumbnails, setThumbnails] = useState>({}); const [pendingDeleteId, setPendingDeleteId] = useState(null); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const searchInputRef = useRef(null); const searchButtonRef = useRef(null); const toolbarRef = useRef(null); const textareaRef = useRef(null); // Close dropdowns when clicking outside useEffect(() => { if (!themeOpen) return; const handleClickOutside = (e: MouseEvent) => { if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) { setThemeOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [themeOpen]); const loadClassrooms = async () => { try { const list = await listStages(); setClassrooms(list); // Load first slide thumbnails if (list.length > 0) { const slides = await getFirstSlideByStages(list.map((c) => c.id)); setThumbnails(slides); } } catch (err) { log.error('Failed to load classrooms:', err); } }; const { importing, fileInputRef, triggerFileSelect, handleFileChange } = useImportClassroom( () => { loadClassrooms(); }, ); useEffect(() => { // Clear stale media store to prevent cross-course thumbnail contamination. // The store may hold tasks from a previously visited classroom whose elementIds // (gen_img_1, etc.) collide with other courses' placeholders. useMediaGenerationStore.getState().revokeObjectUrls(); useMediaGenerationStore.setState({ tasks: {} }); // eslint-disable-next-line react-hooks/set-state-in-effect -- Store hydration on mount loadClassrooms(); }, []); const handleDelete = (id: string, e: React.MouseEvent) => { e.stopPropagation(); setPendingDeleteId(id); }; const confirmDelete = async (id: string) => { setPendingDeleteId(null); try { await deleteStageData(id); await loadClassrooms(); } catch (err) { log.error('Failed to delete classroom:', err); toast.error('Failed to delete classroom'); } }; const handleRename = async (id: string, newName: string) => { try { await renameStage(id, newName); setClassrooms((prev) => prev.map((c) => (c.id === id ? { ...c, name: newName } : c))); } catch (err) { log.error('Failed to rename classroom:', err); toast.error(t('classroom.renameFailed')); } }; const deferredSearchQuery = useDeferredValue(searchQuery); const filteredClassrooms = useMemo(() => { const q = deferredSearchQuery.trim().toLowerCase(); if (!q) return classrooms; return classrooms.filter((c) => { const name = c.name?.toLowerCase() ?? ''; const desc = c.description?.toLowerCase() ?? ''; return name.includes(q) || desc.includes(q); }); }, [classrooms, deferredSearchQuery]); const updateForm = (field: K, value: FormState[K]) => { setForm((prev) => ({ ...prev, [field]: value })); try { if (field === 'webSearch') localStorage.setItem(WEB_SEARCH_STORAGE_KEY, String(value)); if (field === 'interactiveMode') localStorage.setItem(INTERACTIVE_MODE_STORAGE_KEY, String(value)); if (field === 'requirement') updateRequirementCache(value as string); } catch { /* ignore */ } }; const showSetupToast = (icon: React.ReactNode, title: string, desc: string) => { toast.custom( (id) => (
{ toast.dismiss(id); setSettingsOpen(true); }} >
{icon}

{title}

{desc}

), { duration: 4000 }, ); }; const handleGenerate = async () => { // Validate setup before proceeding if (!currentModelId) { showSetupToast( , t('settings.modelNotConfigured'), t('settings.setupNeeded'), ); setSettingsOpen(true); return; } if (!form.requirement.trim()) { setError(t('upload.requirementRequired')); return; } setError(null); try { const userProfile = useUserProfileStore.getState(); const requirements: UserRequirements = { requirement: form.requirement, userNickname: userProfile.nickname || undefined, userBio: userProfile.bio || undefined, webSearch: form.webSearch || undefined, interactiveMode: form.interactiveMode, }; let pdfStorageKey: string | undefined; let pdfFileName: string | undefined; let pdfProviderId: string | undefined; let pdfProviderConfig: { apiKey?: string; baseUrl?: string } | undefined; if (form.pdfFile) { pdfStorageKey = await storePdfBlob(form.pdfFile); pdfFileName = form.pdfFile.name; const settings = useSettingsStore.getState(); pdfProviderId = settings.pdfProviderId; const providerCfg = settings.pdfProvidersConfig?.[settings.pdfProviderId]; if (providerCfg) { pdfProviderConfig = { apiKey: providerCfg.apiKey, baseUrl: providerCfg.baseUrl, }; } } const sessionState = { sessionId: nanoid(), requirements, pdfText: '', pdfImages: [], imageStorageIds: [], pdfStorageKey, pdfFileName, pdfProviderId, pdfProviderConfig, sceneOutlines: null, currentStep: 'generating' as const, }; sessionStorage.setItem('generationSession', JSON.stringify(sessionState)); navigate('/generation-preview'); } catch (err) { log.error('Error preparing generation:', err); setError(err instanceof Error ? err.message : t('upload.generateFailed')); } }; const formatDate = (timestamp: number) => { const date = new Date(timestamp); const now = new Date(); const diffTime = Math.abs(now.getTime() - date.getTime()); const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); if (diffDays === 0) return t('classroom.today'); if (diffDays === 1) return t('classroom.yesterday'); if (diffDays < 7) return `${diffDays} ${t('classroom.daysAgo')}`; return date.toLocaleDateString(); }; const canGenerate = !!form.requirement.trim(); const handleKeyDown = (e: React.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); if (canGenerate) handleGenerate(); } }; return (
{/* ═══ Top-right pill (unchanged) ═══ */}
{/* Language Selector */} setThemeOpen(false)} />
{/* Theme Selector */}
{themeOpen && (
)}
{/* Settings Button */}
{ setSettingsOpen(open); if (!open) setSettingsSection(undefined); }} initialSection={settingsSection} /> {/* ═══ Background Decor ═══ */}
{/* ═══ Hero section: title + input (centered, wider) ═══ */} {/* ── Logo ── */} {/* ── Slogan ── */} {t('home.slogan')} {/* ── Unified input area ── */}
{/* ── Greeting + Profile + Agents ── */}
{/* Textarea */}