'use client'; import { useState, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { motion, AnimatePresence } from 'motion/react'; import { ArrowUp, BookOpen, Hammer, Monitor, Moon, Settings, Sparkles, Sun } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; import { LanguageSwitcher } from '@/components/language-switcher'; import { createLogger } from '@/lib/logger'; import { cn } from '@/lib/utils'; import { SettingsDialog } from '@/components/settings'; import { GenerationToolbar } from '@/components/generation/generation-toolbar'; import { useTheme } from '@/lib/hooks/use-theme'; import { nanoid } from 'nanoid'; import { dedupeCourseMaterialFiles } from '@/lib/document/course-materials'; import type { SelectedCourseMaterial } from '@/lib/types/generation'; import { useSettingsStore } from '@/lib/store/settings'; import { hasUsableLLMProvider } from '@/lib/store/settings-validation'; 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 { shouldShowVocationalTestUi } from '@/lib/config/feature-flags'; import { InteractiveModeButton } from '@/components/generation/interactive-mode-button'; import { GenerationModeTabs, type GenerationMode, } from '@/components/generation/generation-mode-tabs'; import { LargeCourseWorkbench } from '@/components/generation/large-course-workbench'; import { opsApiPath } from '@/lib/ops/api-fetch'; import { storeDocumentBlob } from '@/lib/utils/image-storage'; import { createForegroundGenerationSession } from '@/lib/generation/foreground-session'; import { isOpsWorkbenchEnabled, isSettingsManagementEnabled } from '@/lib/config/deployment-role'; const log = createLogger('Home'); const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled'; const INTERACTIVE_MODE_STORAGE_KEY = 'interactiveModeEnabled'; interface FormState { courseMaterials: SelectedCourseMaterial[]; requirement: string; webSearch: boolean; interactiveMode: boolean; vocationalTestMode: boolean; } const initialFormState: FormState = { courseMaterials: [], 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); const [generationMode, setGenerationMode] = useState('normal'); // 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 [themeOpen, setThemeOpen] = useState(false); const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const toolbarRef = useRef(null); const opsWorkbenchEnabled = isOpsWorkbenchEnabled(); const settingsManagementEnabled = isSettingsManagementEnabled(); // Hydrate client-only state after mount (avoids SSR mismatch) useEffect(() => { 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 */ } }, []); // 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); useEffect(() => { if (draftRestoredRef.current) return; if (!cachedRequirement) return; draftRestoredRef.current = true; setForm((prev) => (prev.requirement ? prev : { ...prev, requirement: cachedRequirement })); }, [cachedRequirement]); // 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]); 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: {} }); }, []); 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 addCourseMaterials = (files: File[]) => { setForm((prev) => { const dedupedFiles = dedupeCourseMaterialFiles(prev.courseMaterials, files); const startOrder = prev.courseMaterials.length + 1; const additions = dedupedFiles.map((file, index) => ({ id: nanoid(8), file, name: file.name, size: file.size, lastModified: file.lastModified, type: file.type, order: startOrder + index, })); return additions.length > 0 ? { ...prev, courseMaterials: [...prev.courseMaterials, ...additions] } : prev; }); }; const removeCourseMaterial = (id: string) => { setForm((prev) => ({ ...prev, courseMaterials: prev.courseMaterials .filter((item) => item.id !== id) .map((item, index) => ({ ...item, order: index + 1 })), })); }; const usesForegroundGeneration = form.courseMaterials.length > 0 || form.interactiveMode || form.vocationalTestMode; // Plain requirements use the durable server-side task flow. Features that // still need browser-owned files or the original interactive/vocational // outline pipeline are handed to the existing generation-preview flow. const startGeneration = async () => { if (!form.requirement.trim()) { setError(t('upload.requirementRequired')); return; } setError(null); if (isSubmitting) return; setIsSubmitting(true); try { if (usesForegroundGeneration) { const settings = useSettingsStore.getState(); const pdfProviderConfig = settings.pdfProvidersConfig[settings.pdfProviderId]; const session = await createForegroundGenerationSession({ sessionId: nanoid(10), requirement: form.requirement, webSearch: form.webSearch, interactiveMode: form.interactiveMode, taskEngineMode: form.vocationalTestMode, courseMaterials: form.courseMaterials, pdfProviderId: settings.pdfProviderId, pdfProviderConfig, storeDocument: storeDocumentBlob, }); sessionStorage.setItem('generationSession', JSON.stringify(session)); router.push('/generation-preview'); return; } const response = await fetch('/api/generate-classroom', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ requirement: form.requirement, ...(form.webSearch ? { enableWebSearch: true } : {}), }), }); const body = (await response.json()) as { success?: boolean; jobId?: string; error?: string; details?: string; }; if (!response.ok || !body.success || !body.jobId) { throw new Error(body.details ?? body.error ?? `HTTP ${response.status}`); } toast.success(t('jobs.started'), { action: { label: t('jobs.viewTasks'), onClick: () => router.push('/tasks'), }, }); } catch (err) { log.error('Failed to start generation:', err); setError(err instanceof Error ? err.message : t('upload.generateFailed')); } finally { setIsSubmitting(false); } }; const canGenerate = !!form.requirement.trim() && hasUsableProvider && !isSubmitting; const handleKeyDown = (e: React.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); if (canGenerate) void startGeneration(); } }; return (
{/* ═══ Top-right pill ═══ */}
{/* Language Selector */} setThemeOpen(false)} />
{/* Theme Selector */}
{themeOpen && (
)}
{/* Published course catalog — available on every learner surface. */}
{/* Generation tasks — managed on the dedicated /tasks page */} {settingsManagementEnabled && ( <>
{/* Settings are operator-managed; learner deployments do not expose this control. */}
)}
{settingsManagementEnabled && ( { setSettingsOpen(open); if (!open) setSettingsSection(undefined); }} initialSection={settingsSection} /> )} {/* ═══ Background Decor ═══ */}
{/* ═══ Hero section: title + input (centered, wider) ═══ */} {/* ── Logo ── */} {/* ── Slogan ── */} {t('home.slogan')}
{generationMode === 'normal' ? ( <> {/* ── Unified input area ── */}
{/* Textarea */}