|
|
|
|
| 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<FormState>(initialFormState); |
| const [settingsOpen, setSettingsOpen] = useState(false); |
| const [settingsSection, setSettingsSection] = useState< |
| import('@/lib/types/settings').SettingsSection | undefined |
| >(undefined); |
|
|
| |
| const { cachedValue: cachedRequirement, updateCache: updateRequirementCache } = |
| useDraftCache<string>({ key: 'requirementDraft' }); |
|
|
| |
| 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 { |
| |
| } |
| }; |
|
|
| |
| |
| useEffect(() => { |
| try { |
| const saved = localStorage.getItem(RECENT_OPEN_STORAGE_KEY); |
| if (saved !== null) setRecentOpen(saved !== 'false'); |
| } catch { |
| |
| } |
| try { |
| const savedWebSearch = localStorage.getItem(WEB_SEARCH_STORAGE_KEY); |
| const savedInteractiveMode = localStorage.getItem(INTERACTIVE_MODE_STORAGE_KEY); |
| const updates: Partial<FormState> = {}; |
| if (savedWebSearch === 'true') updates.webSearch = true; |
| if (savedInteractiveMode === 'true') updates.interactiveMode = true; |
| if (Object.keys(updates).length > 0) { |
| setForm((prev) => ({ ...prev, ...updates })); |
| } |
| } catch { |
| |
| } |
| }, []); |
| |
|
|
| |
| 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<string | null>(null); |
| const [classrooms, setClassrooms] = useState<StageListItem[]>([]); |
| const [thumbnails, setThumbnails] = useState<Record<string, Slide>>({}); |
| const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null); |
| const [searchOpen, setSearchOpen] = useState(false); |
| const [searchQuery, setSearchQuery] = useState(''); |
| const searchInputRef = useRef<HTMLInputElement>(null); |
| const searchButtonRef = useRef<HTMLButtonElement>(null); |
| const toolbarRef = useRef<HTMLDivElement>(null); |
| const textareaRef = useRef<HTMLTextAreaElement>(null); |
|
|
| |
| 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); |
| |
| 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(() => { |
| |
| |
| |
| useMediaGenerationStore.getState().revokeObjectUrls(); |
| useMediaGenerationStore.setState({ tasks: {} }); |
|
|
| |
| 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 = <K extends keyof FormState>(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 { |
| |
| } |
| }; |
|
|
| const showSetupToast = (icon: React.ReactNode, title: string, desc: string) => { |
| toast.custom( |
| (id) => ( |
| <div |
| className="w-[356px] rounded-xl border border-amber-200/60 dark:border-amber-800/40 bg-gradient-to-r from-amber-50 via-white to-amber-50 dark:from-amber-950/60 dark:via-slate-900 dark:to-amber-950/60 shadow-lg shadow-amber-500/8 dark:shadow-amber-900/20 p-4 flex items-start gap-3 cursor-pointer" |
| onClick={() => { |
| toast.dismiss(id); |
| setSettingsOpen(true); |
| }} |
| > |
| <div className="shrink-0 mt-0.5 size-9 rounded-lg bg-amber-100 dark:bg-amber-900/40 flex items-center justify-center ring-1 ring-amber-200/50 dark:ring-amber-800/30"> |
| {icon} |
| </div> |
| <div className="flex-1 min-w-0"> |
| <p className="text-sm font-semibold text-amber-900 dark:text-amber-200 leading-tight"> |
| {title} |
| </p> |
| <p className="text-xs text-amber-700/80 dark:text-amber-400/70 mt-0.5 leading-relaxed"> |
| {desc} |
| </p> |
| </div> |
| <div className="shrink-0 mt-1 text-[10px] font-medium text-amber-500 dark:text-amber-500/70 tracking-wide"> |
| <Settings className="size-3.5 animate-[spin_3s_linear_infinite]" /> |
| </div> |
| </div> |
| ), |
| { duration: 4000 }, |
| ); |
| }; |
|
|
| const handleGenerate = async () => { |
| |
| if (!currentModelId) { |
| showSetupToast( |
| <BotOff className="size-4.5 text-amber-600 dark:text-amber-400" />, |
| 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<HTMLTextAreaElement>) => { |
| if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { |
| e.preventDefault(); |
| if (canGenerate) handleGenerate(); |
| } |
| }; |
|
|
| return ( |
| <div className="min-h-[100dvh] w-full bg-gradient-to-b from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 flex flex-col items-center p-4 pt-16 md:p-8 md:pt-16 overflow-x-hidden"> |
| <input |
| ref={fileInputRef} |
| type="file" |
| accept=".zip" |
| onChange={handleFileChange} |
| className="hidden" |
| /> |
| {/* ═══ Top-right pill (unchanged) ═══ */} |
| <div |
| ref={toolbarRef} |
| className="fixed top-4 right-4 z-50 flex items-center gap-1 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md px-2 py-1.5 rounded-full border border-gray-100/50 dark:border-gray-700/50 shadow-sm" |
| > |
| {/* Language Selector */} |
| <LanguageSwitcher onOpen={() => setThemeOpen(false)} /> |
| |
| <div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" /> |
| |
| {/* Theme Selector */} |
| <div className="relative"> |
| <button |
| onClick={() => { |
| setThemeOpen(!themeOpen); |
| }} |
| className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all" |
| > |
| {theme === 'light' && <Sun className="w-4 h-4" />} |
| {theme === 'dark' && <Moon className="w-4 h-4" />} |
| {theme === 'system' && <Monitor className="w-4 h-4" />} |
| </button> |
| {themeOpen && ( |
| <div className="absolute top-full mt-2 right-0 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg overflow-hidden z-50 min-w-[140px]"> |
| <button |
| onClick={() => { |
| setTheme('light'); |
| setThemeOpen(false); |
| }} |
| className={cn( |
| 'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2', |
| theme === 'light' && |
| 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400', |
| )} |
| > |
| <Sun className="w-4 h-4" /> |
| {t('settings.themeOptions.light')} |
| </button> |
| <button |
| onClick={() => { |
| setTheme('dark'); |
| setThemeOpen(false); |
| }} |
| className={cn( |
| 'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2', |
| theme === 'dark' && |
| 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400', |
| )} |
| > |
| <Moon className="w-4 h-4" /> |
| {t('settings.themeOptions.dark')} |
| </button> |
| <button |
| onClick={() => { |
| setTheme('system'); |
| setThemeOpen(false); |
| }} |
| className={cn( |
| 'w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2', |
| theme === 'system' && |
| 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400', |
| )} |
| > |
| <Monitor className="w-4 h-4" /> |
| {t('settings.themeOptions.system')} |
| </button> |
| </div> |
| )} |
| </div> |
| |
| <div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" /> |
| |
| {/* Settings Button */} |
| <div className="relative"> |
| <button |
| onClick={() => setSettingsOpen(true)} |
| className="p-2 rounded-full text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm transition-all group" |
| > |
| <Settings className="w-4 h-4 group-hover:rotate-90 transition-transform duration-500" /> |
| </button> |
| </div> |
| </div> |
| <SettingsDialog |
| open={settingsOpen} |
| onOpenChange={(open) => { |
| setSettingsOpen(open); |
| if (!open) setSettingsSection(undefined); |
| }} |
| initialSection={settingsSection} |
| /> |
| |
| {/* ═══ Background Decor ═══ */} |
| <div className="absolute inset-0 overflow-hidden pointer-events-none"> |
| <div |
| className="absolute top-0 left-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl animate-pulse" |
| style={{ animationDuration: '4s' }} |
| /> |
| <div |
| className="absolute bottom-0 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl animate-pulse" |
| style={{ animationDuration: '6s' }} |
| /> |
| </div> |
| |
| {/* ═══ Hero section: title + input (centered, wider) ═══ */} |
| <motion.div |
| initial={{ opacity: 0, y: 20 }} |
| animate={{ opacity: 1, y: 0 }} |
| transition={{ duration: 0.6, ease: 'easeOut' }} |
| className={cn( |
| 'relative z-20 w-full max-w-[800px] flex flex-col items-center', |
| classrooms.length === 0 ? 'justify-center min-h-[calc(100dvh-8rem)]' : 'mt-[10vh]', |
| )} |
| > |
| {/* ── Logo ── */} |
| <motion.img |
| src="/logo-horizontal.png" |
| alt="MultiMind Classroom" |
| initial={{ opacity: 0, scale: 0.9 }} |
| animate={{ opacity: 1, scale: 1 }} |
| transition={{ |
| delay: 0.1, |
| type: 'spring', |
| stiffness: 200, |
| damping: 20, |
| }} |
| className="h-12 md:h-16 mb-2 -ml-2 md:-ml-3" |
| /> |
| |
| {/* ── Slogan ── */} |
| <motion.p |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| transition={{ delay: 0.25 }} |
| className="text-sm text-muted-foreground/60 mb-8" |
| > |
| {t('home.slogan')} |
| </motion.p> |
| |
| {/* ── Unified input area ── */} |
| <motion.div |
| initial={{ opacity: 0, scale: 0.97 }} |
| animate={{ opacity: 1, scale: 1 }} |
| transition={{ delay: 0.35 }} |
| className="w-full" |
| > |
| <div className="w-full rounded-2xl border border-border/60 bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl shadow-xl shadow-black/[0.03] dark:shadow-black/20 transition-shadow focus-within:shadow-2xl focus-within:shadow-violet-500/[0.06]"> |
| {/* ── Greeting + Profile + Agents ── */} |
| <div className="relative z-20 flex items-start justify-between"> |
| <GreetingBar /> |
| <div className="pr-3 pt-3.5 shrink-0"> |
| <AgentBar /> |
| </div> |
| </div> |
| |
| {/* Textarea */} |
| <textarea |
| ref={textareaRef} |
| placeholder={t('upload.requirementPlaceholder')} |
| className="w-full resize-none border-0 bg-transparent px-4 pt-1 pb-2 text-[13px] leading-relaxed placeholder:text-muted-foreground/40 focus:outline-none min-h-[140px] max-h-[300px]" |
| value={form.requirement} |
| onChange={(e) => updateForm('requirement', e.target.value)} |
| onKeyDown={handleKeyDown} |
| rows={4} |
| /> |
| |
| {/* Toolbar row */} |
| <div className="px-3 pb-3 flex items-end gap-2"> |
| <div className="flex-1 min-w-0"> |
| <GenerationToolbar |
| webSearch={form.webSearch} |
| onWebSearchChange={(v) => updateForm('webSearch', v)} |
| onSettingsOpen={(section) => { |
| setSettingsSection(section); |
| setSettingsOpen(true); |
| }} |
| pdfFile={form.pdfFile} |
| onPdfFileChange={(f) => updateForm('pdfFile', f)} |
| onPdfError={setError} |
| /> |
| </div> |
| |
| {/* Interactive mode toggle */} |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <motion.button |
| whileTap={{ scale: 0.95 }} |
| transition={{ type: 'spring', stiffness: 400, damping: 17 }} |
| onClick={() => updateForm('interactiveMode', !form.interactiveMode)} |
| className={cn( |
| 'relative inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all cursor-pointer select-none whitespace-nowrap border shrink-0 h-8', |
| form.interactiveMode |
| ? 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300 border-cyan-500 shadow-[0_0_12px_rgba(6,182,212,0.35)] dark:shadow-[0_0_12px_rgba(6,182,212,0.25)]' |
| : 'border-cyan-300/60 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-50 dark:hover:bg-cyan-900/20', |
| )} |
| > |
| {form.interactiveMode && ( |
| <span |
| className="absolute inset-[-4px] rounded-full border border-cyan-400/40 dark:border-cyan-400/25" |
| style={{ |
| animation: 'interactive-mode-breathe 2s ease-in-out infinite', |
| }} |
| /> |
| )} |
| <Atom className="size-3.5 relative z-10 animate-[spin_3s_linear_infinite]" /> |
| <span className="relative z-10">{t('toolbar.interactiveModeLabel')}</span> |
| </motion.button> |
| </TooltipTrigger> |
| <TooltipContent side="top" className="text-xs"> |
| {t('toolbar.interactiveModeHint')} |
| </TooltipContent> |
| </Tooltip> |
| |
| {/* Voice input */} |
| <SpeechButton |
| size="md" |
| onTranscription={(text) => { |
| setForm((prev) => { |
| const next = prev.requirement + (prev.requirement ? ' ' : '') + text; |
| updateRequirementCache(next); |
| return { ...prev, requirement: next }; |
| }); |
| }} |
| /> |
| |
| {/* Send button */} |
| <button |
| onClick={handleGenerate} |
| disabled={!canGenerate} |
| className={cn( |
| 'shrink-0 h-8 rounded-lg flex items-center justify-center gap-1.5 transition-all px-3', |
| canGenerate |
| ? 'bg-primary text-primary-foreground hover:opacity-90 shadow-sm cursor-pointer' |
| : 'bg-muted text-muted-foreground/40 cursor-not-allowed', |
| )} |
| > |
| <span className="text-xs font-medium">{t('toolbar.enterClassroom')}</span> |
| <ArrowUp className="size-3.5" /> |
| </button> |
| </div> |
| </div> |
| </motion.div> |
| |
| {/* ── Error ── */} |
| <AnimatePresence> |
| {error && ( |
| <motion.div |
| initial={{ opacity: 0, height: 0 }} |
| animate={{ opacity: 1, height: 'auto' }} |
| exit={{ opacity: 0, height: 0 }} |
| className="mt-3 w-full p-3 bg-destructive/10 border border-destructive/20 rounded-lg" |
| > |
| <p className="text-sm text-destructive">{error}</p> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| |
| {/* ── Import button (empty state) ── */} |
| {classrooms.length === 0 && ( |
| <button |
| onClick={triggerFileSelect} |
| disabled={importing} |
| className="relative z-10 mt-4 flex items-center gap-1.5 text-[12px] text-muted-foreground/40 hover:text-foreground/60 transition-colors" |
| > |
| <Upload className="size-3.5" /> |
| <span>{t('import.classroom')}</span> |
| </button> |
| )} |
| </motion.div> |
| |
| {/* ═══ Recent classrooms — collapsible ═══ */} |
| {classrooms.length > 0 && ( |
| <motion.div |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| transition={{ delay: 0.5 }} |
| className="relative z-10 mt-10 w-full max-w-6xl flex flex-col items-center" |
| > |
| {/* Trigger — divider-line with centered text */} |
| <div className="group w-full flex items-center gap-4 py-2"> |
| <div className="flex-1 h-px bg-border/40 group-hover:bg-border/70 transition-colors" /> |
| <div className="shrink-0 flex items-center gap-3 text-[13px] text-muted-foreground/60 select-none"> |
| <button |
| onClick={() => persistRecentOpen(!recentOpen)} |
| className="flex items-center gap-2 hover:text-foreground/70 transition-colors cursor-pointer" |
| > |
| <Clock className="size-3.5" /> |
| {t('classroom.recentClassrooms')} |
| <span className="text-[11px] tabular-nums opacity-60">{classrooms.length}</span> |
| <motion.div |
| animate={{ rotate: recentOpen ? 180 : 0 }} |
| transition={{ duration: 0.3, ease: 'easeInOut' }} |
| > |
| <ChevronDown className="size-3.5" /> |
| </motion.div> |
| </button> |
| |
| {/* Search toggle — icon that expands into an input in place */} |
| <AnimatePresence initial={false}> |
| {!searchOpen ? ( |
| <motion.button |
| key="search-icon" |
| ref={searchButtonRef} |
| type="button" |
| aria-label={t('classroom.searchAriaLabel')} |
| onClick={() => { |
| setSearchOpen(true); |
| if (!recentOpen) persistRecentOpen(true); |
| requestAnimationFrame(() => searchInputRef.current?.focus()); |
| }} |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| exit={{ opacity: 0 }} |
| transition={{ duration: 0.12, ease: 'easeOut' }} |
| className="flex items-center justify-center size-6 rounded-full text-muted-foreground/50 hover:text-foreground/70 hover:bg-muted/50 transition-colors cursor-pointer" |
| > |
| <Search className="size-3.5" /> |
| </motion.button> |
| ) : ( |
| <motion.div |
| key="search-input" |
| initial={{ opacity: 0, width: 0 }} |
| animate={{ opacity: 1, width: 200 }} |
| exit={{ opacity: 0, width: 0 }} |
| transition={{ duration: 0.18, ease: [0.25, 0.1, 0.25, 1] }} |
| className="overflow-hidden" |
| > |
| <InputGroup |
| className={cn( |
| 'h-7 text-[12px] rounded-full bg-muted/40 border-transparent shadow-none', |
| 'transition-colors', |
| 'hover:bg-muted/60', |
| 'has-[[data-slot=input-group-control]:focus-visible]:bg-muted/60', |
| 'has-[[data-slot=input-group-control]:focus-visible]:border-transparent', |
| 'has-[[data-slot=input-group-control]:focus-visible]:ring-0', |
| )} |
| > |
| <InputGroupInput |
| ref={searchInputRef} |
| value={searchQuery} |
| onChange={(e) => setSearchQuery(e.target.value)} |
| onKeyDown={(e) => { |
| if (e.key === 'Escape') { |
| e.preventDefault(); |
| if (searchQuery) { |
| setSearchQuery(''); |
| } else { |
| setSearchOpen(false); |
| requestAnimationFrame(() => searchButtonRef.current?.focus()); |
| } |
| } |
| }} |
| onBlur={() => { |
| if (!searchQuery) { |
| setSearchOpen(false); |
| } |
| }} |
| placeholder={t('classroom.searchPlaceholder')} |
| aria-label={t('classroom.searchAriaLabel')} |
| className="h-7 pl-3 placeholder:text-muted-foreground/50" |
| /> |
| {searchQuery && ( |
| <InputGroupButton |
| size="icon-xs" |
| aria-label={t('classroom.clearSearch')} |
| onMouseDown={(e) => e.preventDefault()} |
| onClick={() => { |
| setSearchQuery(''); |
| searchInputRef.current?.focus(); |
| }} |
| > |
| <X /> |
| </InputGroupButton> |
| )} |
| </InputGroup> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| |
| <button |
| onClick={triggerFileSelect} |
| disabled={importing} |
| className="group/import grid grid-cols-[auto_0fr] hover:grid-cols-[auto_1fr] items-center gap-1 rounded-full px-1.5 py-0.5 text-[12px] text-muted-foreground/35 hover:text-muted-foreground/70 hover:bg-muted/50 transition-all duration-200 cursor-pointer" |
| > |
| <Upload className="size-3" /> |
| <span className="overflow-hidden opacity-0 group-hover/import:opacity-100 transition-opacity duration-200 whitespace-nowrap"> |
| {t('import.classroom')} |
| </span> |
| </button> |
| </div> |
| <div className="flex-1 h-px bg-border/40 group-hover:bg-border/70 transition-colors" /> |
| </div> |
| |
| {/* Expandable content */} |
| <AnimatePresence> |
| {recentOpen && ( |
| <motion.div |
| initial={{ height: 0, opacity: 0 }} |
| animate={{ height: 'auto', opacity: 1 }} |
| exit={{ height: 0, opacity: 0 }} |
| transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }} |
| className="w-full overflow-hidden" |
| > |
| {searchQuery.trim() && filteredClassrooms.length === 0 ? ( |
| <div className="pt-8 pb-2 text-center text-[13px] text-muted-foreground/60"> |
| {t('classroom.searchEmpty')} |
| </div> |
| ) : ( |
| <div className="pt-8 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-5 gap-y-8"> |
| {filteredClassrooms.map((classroom, i) => ( |
| <motion.div |
| key={classroom.id} |
| initial={{ opacity: 0, y: 16 }} |
| animate={{ opacity: 1, y: 0 }} |
| transition={{ |
| delay: i * 0.04, |
| duration: 0.35, |
| ease: 'easeOut', |
| }} |
| > |
| <ClassroomCard |
| classroom={classroom} |
| slide={thumbnails[classroom.id]} |
| formatDate={formatDate} |
| onDelete={handleDelete} |
| onRename={handleRename} |
| confirmingDelete={pendingDeleteId === classroom.id} |
| onConfirmDelete={() => confirmDelete(classroom.id)} |
| onCancelDelete={() => setPendingDeleteId(null)} |
| onClick={() => navigate(`/classroom/${classroom.id}`)} /> |
| </motion.div> |
| ))} |
| </div> |
| )} |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </motion.div> |
| )} |
| |
| {/* Footer — flows with content, at the very end */} |
| <div className="mt-auto pt-12 pb-4 text-center text-xs text-muted-foreground/40"> |
| MultiMind Classroom |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| const MAX_AVATAR_SIZE = 5 * 1024 * 1024; |
|
|
| function isCustomAvatar(src: string) { |
| return src.startsWith('data:'); |
| } |
|
|
| function GreetingBar() { |
| const { t } = useI18n(); |
| const avatar = useUserProfileStore((s) => s.avatar); |
| const nickname = useUserProfileStore((s) => s.nickname); |
| const bio = useUserProfileStore((s) => s.bio); |
| const setAvatar = useUserProfileStore((s) => s.setAvatar); |
| const setNickname = useUserProfileStore((s) => s.setNickname); |
| const setBio = useUserProfileStore((s) => s.setBio); |
|
|
| const [open, setOpen] = useState(false); |
| const [editingName, setEditingName] = useState(false); |
| const [nameDraft, setNameDraft] = useState(''); |
| const [avatarPickerOpen, setAvatarPickerOpen] = useState(false); |
| const nameInputRef = useRef<HTMLInputElement>(null); |
| const avatarInputRef = useRef<HTMLInputElement>(null); |
| const containerRef = useRef<HTMLDivElement>(null); |
|
|
| const displayName = nickname || t('profile.defaultNickname'); |
|
|
| |
| useEffect(() => { |
| if (!open) return; |
| const handler = (e: MouseEvent) => { |
| if (containerRef.current && !containerRef.current.contains(e.target as Node)) { |
| setOpen(false); |
| setEditingName(false); |
| setAvatarPickerOpen(false); |
| } |
| }; |
| document.addEventListener('mousedown', handler); |
| return () => document.removeEventListener('mousedown', handler); |
| }, [open]); |
|
|
| const startEditName = () => { |
| setNameDraft(nickname); |
| setEditingName(true); |
| setTimeout(() => nameInputRef.current?.focus(), 50); |
| }; |
|
|
| const commitName = () => { |
| setNickname(nameDraft.trim()); |
| setEditingName(false); |
| }; |
|
|
| const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => { |
| const file = e.target.files?.[0]; |
| if (!file) return; |
| if (file.size > MAX_AVATAR_SIZE) { |
| toast.error(t('profile.fileTooLarge')); |
| return; |
| } |
| if (!file.type.startsWith('image/')) { |
| toast.error(t('profile.invalidFileType')); |
| return; |
| } |
| const reader = new FileReader(); |
| reader.onload = () => { |
| const img = new window.Image(); |
| img.onload = () => { |
| const canvas = document.createElement('canvas'); |
| canvas.width = 128; |
| canvas.height = 128; |
| const ctx = canvas.getContext('2d')!; |
| const scale = Math.max(128 / img.width, 128 / img.height); |
| const w = img.width * scale; |
| const h = img.height * scale; |
| ctx.drawImage(img, (128 - w) / 2, (128 - h) / 2, w, h); |
| setAvatar(canvas.toDataURL('image/jpeg', 0.85)); |
| }; |
| img.src = reader.result as string; |
| }; |
| reader.readAsDataURL(file); |
| e.target.value = ''; |
| }; |
|
|
| return ( |
| <div ref={containerRef} className="relative pl-4 pr-2 pt-3.5 pb-1 w-auto"> |
| <input |
| ref={avatarInputRef} |
| type="file" |
| accept="image/*" |
| className="hidden" |
| onChange={handleAvatarUpload} |
| /> |
| |
| {/* ── Collapsed pill (always in flow) ── */} |
| {!open && ( |
| <div |
| className="flex items-center gap-2.5 cursor-pointer transition-all duration-200 group rounded-full px-2.5 py-1.5 border border-border/50 text-muted-foreground/70 hover:text-foreground hover:bg-muted/60 active:scale-[0.97]" |
| onClick={() => setOpen(true)} |
| > |
| <div className="shrink-0 relative"> |
| <div className="size-8 rounded-full overflow-hidden ring-[1.5px] ring-border/30 group-hover:ring-violet-400/60 dark:group-hover:ring-violet-400/40 transition-all duration-300"> |
| <img src={avatar} alt="" className="size-full object-cover" /> |
| </div> |
| <div className="absolute -bottom-0.5 -right-0.5 size-3.5 rounded-full bg-white dark:bg-slate-800 border border-border/40 flex items-center justify-center opacity-60 group-hover:opacity-100 transition-opacity"> |
| <Pencil className="size-[7px] text-muted-foreground/70" /> |
| </div> |
| </div> |
| <div className="flex-1 min-w-0"> |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <span className="leading-none select-none flex items-center gap-1"> |
| <span className="text-[13px] font-semibold text-foreground/85 group-hover:text-foreground transition-colors"> |
| {t('home.greetingWithName', { name: displayName })} |
| </span> |
| <ChevronDown className="size-3 text-muted-foreground/30 group-hover:text-muted-foreground/60 transition-colors shrink-0" /> |
| </span> |
| </TooltipTrigger> |
| <TooltipContent side="bottom" sideOffset={4}> |
| {t('profile.editTooltip')} |
| </TooltipContent> |
| </Tooltip> |
| </div> |
| </div> |
| )} |
| |
| {/* ── Expanded panel (absolute, floating) ── */} |
| <AnimatePresence> |
| {open && ( |
| <motion.div |
| initial={{ opacity: 0, y: -4, scale: 0.97 }} |
| animate={{ opacity: 1, y: 0, scale: 1 }} |
| exit={{ opacity: 0, y: -4, scale: 0.97 }} |
| transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }} |
| className="absolute left-4 top-3.5 z-50 w-64" |
| > |
| <div className="rounded-2xl bg-white/95 dark:bg-slate-800/95 backdrop-blur-sm ring-1 ring-black/[0.04] dark:ring-white/[0.06] shadow-[0_1px_8px_-2px_rgba(0,0,0,0.06)] dark:shadow-[0_1px_8px_-2px_rgba(0,0,0,0.3)] px-2.5 py-2"> |
| {/* ── Row: avatar + name ── */} |
| <div |
| className="flex items-center gap-2.5 cursor-pointer transition-all duration-200" |
| onClick={() => { |
| setOpen(false); |
| setEditingName(false); |
| setAvatarPickerOpen(false); |
| }} |
| > |
| {/* Avatar */} |
| <div |
| className="shrink-0 relative cursor-pointer" |
| onClick={(e) => { |
| e.stopPropagation(); |
| setAvatarPickerOpen(!avatarPickerOpen); |
| }} |
| > |
| <div className="size-8 rounded-full overflow-hidden ring-[1.5px] ring-violet-300/70 dark:ring-violet-500/40 transition-all duration-300"> |
| <img src={avatar} alt="" className="size-full object-cover" /> |
| </div> |
| <motion.div |
| initial={{ scale: 0 }} |
| animate={{ scale: 1 }} |
| className="absolute -bottom-0.5 -right-0.5 size-3.5 rounded-full bg-white dark:bg-slate-800 border border-border/60 flex items-center justify-center" |
| > |
| <ChevronDown |
| className={cn( |
| 'size-2 text-muted-foreground/70 transition-transform duration-200', |
| avatarPickerOpen && 'rotate-180', |
| )} |
| /> |
| </motion.div> |
| </div> |
| |
| {/* Text */} |
| <div className="flex-1 min-w-0"> |
| {editingName ? ( |
| <div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}> |
| <input |
| ref={nameInputRef} |
| value={nameDraft} |
| onChange={(e) => setNameDraft(e.target.value)} |
| onKeyDown={(e) => { |
| if (e.key === 'Enter') commitName(); |
| if (e.key === 'Escape') { |
| setEditingName(false); |
| } |
| }} |
| onBlur={commitName} |
| maxLength={20} |
| placeholder={t('profile.defaultNickname')} |
| className="flex-1 min-w-0 h-6 bg-transparent border-b border-border/80 text-[13px] font-semibold text-foreground outline-none placeholder:text-muted-foreground/40" |
| /> |
| <button |
| onClick={commitName} |
| className="shrink-0 size-5 rounded flex items-center justify-center text-violet-500 hover:bg-violet-100 dark:hover:bg-violet-900/30" |
| > |
| <Check className="size-3" /> |
| </button> |
| </div> |
| ) : ( |
| <span |
| onClick={(e) => { |
| e.stopPropagation(); |
| startEditName(); |
| }} |
| className="group/name inline-flex items-center gap-1 cursor-pointer" |
| > |
| <span className="text-[13px] font-semibold text-foreground/85 group-hover/name:text-foreground transition-colors"> |
| {displayName} |
| </span> |
| <Pencil className="size-2.5 text-muted-foreground/30 opacity-0 group-hover/name:opacity-100 transition-opacity" /> |
| </span> |
| )} |
| </div> |
| |
| {/* Collapse arrow */} |
| <motion.div |
| initial={{ opacity: 0, y: -2 }} |
| animate={{ opacity: 1, y: 0 }} |
| className="shrink-0 size-6 rounded-full flex items-center justify-center hover:bg-black/[0.04] dark:hover:bg-white/[0.06] transition-colors" |
| > |
| <ChevronUp className="size-3.5 text-muted-foreground/50" /> |
| </motion.div> |
| </div> |
| |
| {/* ── Expandable content ── */} |
| <div className="pt-2" onClick={(e) => e.stopPropagation()}> |
| {/* Avatar picker */} |
| <AnimatePresence> |
| {avatarPickerOpen && ( |
| <motion.div |
| initial={{ height: 0, opacity: 0 }} |
| animate={{ height: 'auto', opacity: 1 }} |
| exit={{ height: 0, opacity: 0 }} |
| transition={{ duration: 0.15, ease: 'easeInOut' }} |
| className="overflow-hidden" |
| > |
| <div className="p-1 pb-2.5 flex items-center gap-1.5 flex-wrap"> |
| {AVATAR_OPTIONS.map((url) => ( |
| <button |
| key={url} |
| onClick={() => setAvatar(url)} |
| className={cn( |
| 'size-7 rounded-full overflow-hidden bg-gray-50 dark:bg-gray-800 cursor-pointer transition-all duration-150', |
| 'hover:scale-110 active:scale-95', |
| avatar === url |
| ? 'ring-2 ring-violet-400 dark:ring-violet-500 ring-offset-0' |
| : 'hover:ring-1 hover:ring-muted-foreground/30', |
| )} |
| > |
| <img src={url} alt="" className="size-full" /> |
| </button> |
| ))} |
| <label |
| className={cn( |
| 'size-7 rounded-full flex items-center justify-center cursor-pointer transition-all duration-150 border border-dashed', |
| 'hover:scale-110 active:scale-95', |
| isCustomAvatar(avatar) |
| ? 'ring-2 ring-violet-400 dark:ring-violet-500 ring-offset-0 border-violet-300 dark:border-violet-600 bg-violet-50 dark:bg-violet-900/30' |
| : 'border-muted-foreground/30 text-muted-foreground/50 hover:border-muted-foreground/50', |
| )} |
| onClick={() => avatarInputRef.current?.click()} |
| title={t('profile.uploadAvatar')} |
| > |
| <ImagePlus className="size-3" /> |
| </label> |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| |
| {/* Bio */} |
| <UITextarea |
| value={bio} |
| onChange={(e) => setBio(e.target.value)} |
| placeholder={t('profile.bioPlaceholder')} |
| maxLength={200} |
| rows={2} |
| className="resize-none border-border/40 bg-transparent min-h-[72px] !text-[13px] !leading-relaxed placeholder:!text-[11px] placeholder:!leading-relaxed focus-visible:ring-1 focus-visible:ring-border/60" |
| /> |
| </div> |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </div> |
| ); |
| } |
|
|
| |
| function ClassroomCard({ |
| classroom, |
| slide, |
| formatDate, |
| onDelete, |
| onRename, |
| confirmingDelete, |
| onConfirmDelete, |
| onCancelDelete, |
| onClick, |
| }: { |
| classroom: StageListItem; |
| slide?: Slide; |
| formatDate: (ts: number) => string; |
| onDelete: (id: string, e: React.MouseEvent) => void; |
| onRename: (id: string, newName: string) => void; |
| confirmingDelete: boolean; |
| onConfirmDelete: () => void; |
| onCancelDelete: () => void; |
| onClick: () => void; |
| }) { |
| const { t } = useI18n(); |
| const thumbRef = useRef<HTMLDivElement>(null); |
| const [thumbWidth, setThumbWidth] = useState(0); |
| const [editing, setEditing] = useState(false); |
| const [nameDraft, setNameDraft] = useState(''); |
| const nameInputRef = useRef<HTMLInputElement>(null); |
|
|
| useEffect(() => { |
| const el = thumbRef.current; |
| if (!el) return; |
| const ro = new ResizeObserver(([entry]) => { |
| setThumbWidth(Math.round(entry.contentRect.width)); |
| }); |
| ro.observe(el); |
| return () => ro.disconnect(); |
| }, []); |
|
|
| useEffect(() => { |
| if (editing) nameInputRef.current?.focus(); |
| }, [editing]); |
|
|
| const startRename = (e: React.MouseEvent) => { |
| e.stopPropagation(); |
| setNameDraft(classroom.name); |
| setEditing(true); |
| }; |
|
|
| const commitRename = () => { |
| if (!editing) return; |
| const trimmed = nameDraft.trim(); |
| if (trimmed && trimmed !== classroom.name) { |
| onRename(classroom.id, trimmed); |
| } |
| setEditing(false); |
| }; |
|
|
| return ( |
| <div className="group cursor-pointer" onClick={confirmingDelete ? undefined : onClick}> |
| {/* Thumbnail — large radius, no border, subtle bg */} |
| <div |
| ref={thumbRef} |
| className="relative w-full aspect-[16/9] rounded-2xl bg-slate-100 dark:bg-slate-800/80 overflow-hidden transition-transform duration-200 group-hover:scale-[1.02]" |
| > |
| {slide && thumbWidth > 0 ? ( |
| <ThumbnailSlide |
| slide={slide} |
| size={thumbWidth} |
| viewportSize={slide.viewportSize ?? 1000} |
| viewportRatio={slide.viewportRatio ?? 0.5625} |
| /> |
| ) : !slide ? ( |
| <div className="absolute inset-0 flex items-center justify-center"> |
| <div className="size-12 rounded-2xl bg-gradient-to-br from-violet-100 to-blue-100 dark:from-violet-900/30 dark:to-blue-900/30 flex items-center justify-center"> |
| <span className="text-xl opacity-50">📄</span> |
| </div> |
| </div> |
| ) : null} |
| |
| {classroom.interactiveMode && ( |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <span |
| aria-label={t('toolbar.interactiveModeLabel')} |
| onClick={(e) => e.stopPropagation()} |
| className="absolute bottom-2 left-2 inline-flex items-center justify-center size-5 rounded-full bg-white/70 dark:bg-slate-900/60 text-cyan-600 dark:text-cyan-300 backdrop-blur-sm shadow-sm ring-1 ring-cyan-500/30 z-10" |
| > |
| <Atom className="size-3" /> |
| </span> |
| </TooltipTrigger> |
| {/* Negative sideOffset compensates for the global Tooltip Arrow's |
| rotate-45 bounding box, which Radix reserves as spacing. */} |
| <TooltipContent |
| side="top" |
| align="start" |
| sideOffset={-4} |
| collisionPadding={0} |
| className="text-xs" |
| > |
| {t('toolbar.interactiveModeLabel')} |
| </TooltipContent> |
| </Tooltip> |
| )} |
| |
| {/* Delete — top-right, only on hover */} |
| <AnimatePresence> |
| {!confirmingDelete && ( |
| <motion.div |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| exit={{ opacity: 0 }} |
| transition={{ duration: 0.15 }} |
| > |
| <Button |
| size="icon" |
| variant="ghost" |
| className="absolute top-2 right-2 size-7 opacity-0 group-hover:opacity-100 transition-opacity bg-black/30 hover:bg-destructive/80 text-white hover:text-white backdrop-blur-sm rounded-full" |
| onClick={(e) => { |
| e.stopPropagation(); |
| onDelete(classroom.id, e); |
| }} |
| > |
| <Trash2 className="size-3.5" /> |
| </Button> |
| <Button |
| size="icon" |
| variant="ghost" |
| className="absolute top-2 right-11 size-7 opacity-0 group-hover:opacity-100 transition-opacity bg-black/30 hover:bg-black/50 text-white hover:text-white backdrop-blur-sm rounded-full" |
| onClick={startRename} |
| > |
| <Pencil className="size-3.5" /> |
| </Button> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| |
| {/* Inline delete confirmation overlay */} |
| <AnimatePresence> |
| {confirmingDelete && ( |
| <motion.div |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| exit={{ opacity: 0 }} |
| transition={{ duration: 0.15 }} |
| className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/50 backdrop-blur-[6px]" |
| onClick={(e) => e.stopPropagation()} |
| > |
| <span className="text-[13px] font-medium text-white/90"> |
| {t('classroom.deleteConfirmTitle')}? |
| </span> |
| <div className="flex gap-2"> |
| <button |
| className="px-3.5 py-1 rounded-lg text-[12px] font-medium bg-white/15 text-white/80 hover:bg-white/25 backdrop-blur-sm transition-colors" |
| onClick={onCancelDelete} |
| > |
| {t('common.cancel')} |
| </button> |
| <button |
| className="px-3.5 py-1 rounded-lg text-[12px] font-medium bg-red-500/90 text-white hover:bg-red-500 transition-colors" |
| onClick={onConfirmDelete} |
| > |
| {t('classroom.delete')} |
| </button> |
| </div> |
| </motion.div> |
| )} |
| </AnimatePresence> |
| </div> |
| |
| {/* Info — outside the thumbnail */} |
| <div className="mt-2.5 px-1 flex items-center gap-2"> |
| <span className="shrink-0 inline-flex items-center rounded-full bg-violet-100 dark:bg-violet-900/30 px-2 py-0.5 text-[11px] font-medium text-violet-600 dark:text-violet-400"> |
| {classroom.sceneCount} {t('classroom.slides')} · {formatDate(classroom.updatedAt)} |
| </span> |
| {editing ? ( |
| <div className="flex-1 min-w-0" onClick={(e) => e.stopPropagation()}> |
| <input |
| ref={nameInputRef} |
| value={nameDraft} |
| onChange={(e) => setNameDraft(e.target.value)} |
| onKeyDown={(e) => { |
| if (e.key === 'Enter') commitRename(); |
| if (e.key === 'Escape') setEditing(false); |
| }} |
| onBlur={commitRename} |
| maxLength={100} |
| placeholder={t('classroom.renamePlaceholder')} |
| className="w-full bg-transparent border-b border-violet-400/60 text-[15px] font-medium text-foreground/90 outline-none placeholder:text-muted-foreground/40" |
| /> |
| </div> |
| ) : ( |
| <Tooltip> |
| <TooltipTrigger asChild> |
| <p |
| className="font-medium text-[15px] truncate text-foreground/90 min-w-0 cursor-text" |
| onDoubleClick={startRename} |
| > |
| {classroom.name} |
| </p> |
| </TooltipTrigger> |
| <TooltipContent |
| side="bottom" |
| sideOffset={4} |
| className="!max-w-[min(90vw,32rem)] break-words whitespace-normal" |
| > |
| <div className="flex items-center gap-1.5"> |
| <span className="break-all">{classroom.name}</span> |
| <button |
| className="shrink-0 p-0.5 rounded hover:bg-foreground/10 transition-colors" |
| onClick={(e) => { |
| e.stopPropagation(); |
| navigator.clipboard.writeText(classroom.name); |
| toast.success(t('classroom.nameCopied')); |
| }} |
| > |
| <Copy className="size-3 opacity-60" /> |
| </button> |
| </div> |
| </TooltipContent> |
| </Tooltip> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|
| export default function Page() { |
| return <HomePage />; |
| } |
|
|