| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137 |
- 'use client';
- import { useState, useEffect, useRef } from 'react';
- import { useRouter } from 'next/navigation';
- import { motion, AnimatePresence } from 'motion/react';
- import {
- ArrowUp,
- Check,
- ChevronDown,
- Clock,
- Copy,
- ImagePlus,
- Pencil,
- Trash2,
- Settings,
- Sun,
- Moon,
- Monitor,
- BotOff,
- ChevronUp,
- } 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 { 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';
- const log = createLogger('Home');
- const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled';
- const LANGUAGE_STORAGE_KEY = 'generationLanguage';
- const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen';
- interface FormState {
- pdfFile: File | null;
- requirement: string;
- language: 'zh-CN' | 'en-US';
- webSearch: boolean;
- }
- const initialFormState: FormState = {
- pdfFile: null,
- requirement: '',
- language: 'zh-CN',
- webSearch: false,
- };
- function HomePage() {
- const { t } = useI18n();
- const { theme, setTheme } = useTheme();
- const router = useRouter();
- const [form, setForm] = useState<FormState>(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<string>({ key: 'requirementDraft' });
- // Model setup state
- const currentModelId = useSettingsStore((s) => s.modelId);
- const [recentOpen, setRecentOpen] = useState(true);
- // 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 savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY);
- const updates: Partial<FormState> = {};
- if (savedWebSearch === 'true') updates.webSearch = true;
- if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US') {
- updates.language = savedLanguage;
- } else {
- const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US';
- updates.language = detected;
- }
- 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<string | null>(null);
- const [classrooms, setClassrooms] = useState<StageListItem[]>([]);
- const [thumbnails, setThumbnails] = useState<Record<string, Slide>>({});
- const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
- const toolbarRef = useRef<HTMLDivElement>(null);
- const textareaRef = useRef<HTMLTextAreaElement>(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);
- }
- };
- 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 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 === 'language') localStorage.setItem(LANGUAGE_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) => (
- <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 () => {
- // Validate setup before proceeding
- 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,
- language: form.language,
- userNickname: userProfile.nickname || undefined,
- userBio: userProfile.bio || undefined,
- webSearch: form.webSearch || undefined,
- };
- 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));
- 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();
- 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">
- {/* ═══ 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="OpenMAIC"
- 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
- language={form.language}
- onLanguageChange={(lang) => updateForm('language', lang)}
- 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>
- {/* 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>
- </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 */}
- <button
- onClick={() => {
- const next = !recentOpen;
- setRecentOpen(next);
- try {
- localStorage.setItem(RECENT_OPEN_STORAGE_KEY, String(next));
- } catch {
- /* ignore */
- }
- }}
- className="group w-full flex items-center gap-4 py-2 cursor-pointer"
- >
- <div className="flex-1 h-px bg-border/40 group-hover:bg-border/70 transition-colors" />
- <span className="shrink-0 flex items-center gap-2 text-[13px] text-muted-foreground/60 group-hover:text-foreground/70 transition-colors select-none">
- <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>
- </span>
- <div className="flex-1 h-px bg-border/40 group-hover:bg-border/70 transition-colors" />
- </button>
- {/* 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"
- >
- <div className="pt-8 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-5 gap-y-8">
- {classrooms.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={() => router.push(`/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">
- OpenMAIC Open Source Project
- </div>
- </div>
- );
- }
- // ─── Greeting Bar — avatar + "Hi, Name", click to edit in-place ────
- 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');
- // Click-outside to collapse
- 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>
- );
- }
- // ─── Classroom Card — clean, minimal style ──────────────────────
- 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}
- {/* 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 />;
- }
|