'use client'; import { useState, useEffect, useMemo, useRef, useDeferredValue } from 'react'; import { useRouter } from 'next/navigation'; import { motion, AnimatePresence } from 'motion/react'; import { ArrowUp, Check, ChevronDown, Clock, Copy, ImagePlus, Pencil, Trash2, Search, Settings, Sun, Moon, Monitor, ChevronUp, Upload, Sparkles, Atom, X, Presentation, } 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 { normalizeDocumentMimeType } from '@/lib/document/mime'; import type { UserRequirements } from '@/lib/types/generation'; import { useSettingsStore } from '@/lib/store/settings'; import { hasUsableLLMProvider } from '@/lib/store/settings-validation'; import { useUserProfileStore, AVATAR_OPTIONS } from '@/lib/store/user-profile'; import { StageListItem, listStages, deleteStageData, renameStage, getFirstSlideByStages, revokeThumbnailSlideMediaUrls, } from '@/lib/utils/stage-storage'; import { SlideThumbnail } from '@/components/slide-renderer/SlideThumbnail'; import type { Slide } from '@openmaic/dsl'; 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'; import { shouldShowVocationalTestUi } from '@/lib/config/feature-flags'; import { useImportPptx } from '@/lib/import/use-import-pptx'; import { InteractiveModeButton } from '@/components/generation/interactive-mode-button'; const log = createLogger('Home'); const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled'; const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen'; const INTERACTIVE_MODE_STORAGE_KEY = 'interactiveModeEnabled'; // PPTX import is still scaffolding: `useImportPptx` has no `onImported` consumer // yet, so the flow only logs the parsed slides. Hide the entry point behind a // flag until it's wired end-to-end, so the UI doesn't expose a no-op button. // Enable with NEXT_PUBLIC_ENABLE_PPTX_IMPORT=true. const PPTX_IMPORT_ENABLED = process.env.NEXT_PUBLIC_ENABLE_PPTX_IMPORT === 'true'; interface FormState { pdfFile: File | null; requirement: string; webSearch: boolean; interactiveMode: boolean; vocationalTestMode: boolean; } const initialFormState: FormState = { pdfFile: null, requirement: '', webSearch: false, interactiveMode: false, vocationalTestMode: false, }; function HomePage() { const { t } = useI18n(); const { theme, setTheme } = useTheme(); const router = useRouter(); const showVocationalTestUi = shouldShowVocationalTestUi(); 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' }); // A usable LLM provider exists ⇒ a concrete model is always selected (#580 // invariant). Gate generation on this single condition (state A vs B) // instead of inspecting modelId directly. const providersConfig = useSettingsStore((s) => s.providersConfig); const hasUsableProvider = hasUsableLLMProvider(providersConfig); 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 localStorage on mount. The previous derived-state // pattern initialised `prev` from the cached value itself, so on the first client // render the comparison was always equal and the restore never fired. Use an effect // so the cache is hydrated into the form once we know the live requirement is empty. const draftRestoredRef = useRef(false); /* eslint-disable react-hooks/set-state-in-effect -- Hydration from localStorage must happen in effect */ useEffect(() => { if (draftRestoredRef.current) return; if (!cachedRequirement) return; draftRestoredRef.current = true; setForm((prev) => (prev.requirement ? prev : { ...prev, requirement: cachedRequirement })); }, [cachedRequirement]); /* eslint-enable react-hooks/set-state-in-effect */ 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); const thumbnailsRef = useRef>({}); const replaceThumbnails = (slides: Record) => { const previous = thumbnailsRef.current; thumbnailsRef.current = slides; setThumbnails(slides); window.setTimeout(() => revokeThumbnailSlideMediaUrls(previous), 0); }; // 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)); replaceThumbnails(slides); } else { replaceThumbnails({}); } } catch (err) { log.error('Failed to load classrooms:', err); } }; const { importing, fileInputRef, triggerFileSelect, handleFileChange } = useImportClassroom( () => { loadClassrooms(); }, ); const { importing: pptxImporting, fileInputRef: pptxFileInputRef, triggerFileSelect: triggerPptxFileSelect, handleFileChange: handlePptxFileChange, } = useImportPptx(); 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(); return () => { revokeThumbnailSlideMediaUrls(thumbnailsRef.current); thumbnailsRef.current = {}; }; }, []); 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 handleGenerate = async () => { // No model/provider guard here: generation is gated by `canGenerate` // (requires a usable provider), and under the #580 invariant a usable // provider always has a concrete model. State A (no usable provider) // surfaces through the toolbar's single Configure-Provider affordance. 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.vocationalTestMode ? true : form.interactiveMode, ...(form.vocationalTestMode ? { taskEngineMode: true } : {}), }; let pdfStorageKey: string | undefined; let pdfFileName: string | undefined; let documentMimeType: 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; documentMimeType = normalizeDocumentMimeType({ mimeType: form.pdfFile.type, fileName: 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, documentMimeType, pdfProviderId, pdfProviderConfig, sceneOutlines: null, currentStep: 'generating' as const, }; sessionStorage.setItem('generationSession', JSON.stringify(sessionState)); router.push('/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() && hasUsableProvider; const handleKeyDown = (e: React.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); if (canGenerate) handleGenerate(); } }; return (
{PPTX_IMPORT_ENABLED && ( )} {/* ═══ 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 */}