page.tsx 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137
  1. 'use client';
  2. import { useState, useEffect, useRef } from 'react';
  3. import { useRouter } from 'next/navigation';
  4. import { motion, AnimatePresence } from 'motion/react';
  5. import {
  6. ArrowUp,
  7. Check,
  8. ChevronDown,
  9. Clock,
  10. Copy,
  11. ImagePlus,
  12. Pencil,
  13. Trash2,
  14. Settings,
  15. Sun,
  16. Moon,
  17. Monitor,
  18. BotOff,
  19. ChevronUp,
  20. } from 'lucide-react';
  21. import { useI18n } from '@/lib/hooks/use-i18n';
  22. import { LanguageSwitcher } from '@/components/language-switcher';
  23. import { createLogger } from '@/lib/logger';
  24. import { Button } from '@/components/ui/button';
  25. import { Textarea as UITextarea } from '@/components/ui/textarea';
  26. import { cn } from '@/lib/utils';
  27. import { SettingsDialog } from '@/components/settings';
  28. import { GenerationToolbar } from '@/components/generation/generation-toolbar';
  29. import { AgentBar } from '@/components/agent/agent-bar';
  30. import { useTheme } from '@/lib/hooks/use-theme';
  31. import { nanoid } from 'nanoid';
  32. import { storePdfBlob } from '@/lib/utils/image-storage';
  33. import type { UserRequirements } from '@/lib/types/generation';
  34. import { useSettingsStore } from '@/lib/store/settings';
  35. import { useUserProfileStore, AVATAR_OPTIONS } from '@/lib/store/user-profile';
  36. import {
  37. StageListItem,
  38. listStages,
  39. deleteStageData,
  40. renameStage,
  41. getFirstSlideByStages,
  42. } from '@/lib/utils/stage-storage';
  43. import { ThumbnailSlide } from '@/components/slide-renderer/components/ThumbnailSlide';
  44. import type { Slide } from '@/lib/types/slides';
  45. import { useMediaGenerationStore } from '@/lib/store/media-generation';
  46. import { toast } from 'sonner';
  47. import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
  48. import { useDraftCache } from '@/lib/hooks/use-draft-cache';
  49. import { SpeechButton } from '@/components/audio/speech-button';
  50. const log = createLogger('Home');
  51. const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled';
  52. const LANGUAGE_STORAGE_KEY = 'generationLanguage';
  53. const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen';
  54. interface FormState {
  55. pdfFile: File | null;
  56. requirement: string;
  57. language: 'zh-CN' | 'en-US';
  58. webSearch: boolean;
  59. }
  60. const initialFormState: FormState = {
  61. pdfFile: null,
  62. requirement: '',
  63. language: 'zh-CN',
  64. webSearch: false,
  65. };
  66. function HomePage() {
  67. const { t } = useI18n();
  68. const { theme, setTheme } = useTheme();
  69. const router = useRouter();
  70. const [form, setForm] = useState<FormState>(initialFormState);
  71. const [settingsOpen, setSettingsOpen] = useState(false);
  72. const [settingsSection, setSettingsSection] = useState<
  73. import('@/lib/types/settings').SettingsSection | undefined
  74. >(undefined);
  75. // Draft cache for requirement text
  76. const { cachedValue: cachedRequirement, updateCache: updateRequirementCache } =
  77. useDraftCache<string>({ key: 'requirementDraft' });
  78. // Model setup state
  79. const currentModelId = useSettingsStore((s) => s.modelId);
  80. const [recentOpen, setRecentOpen] = useState(true);
  81. // Hydrate client-only state after mount (avoids SSR mismatch)
  82. /* eslint-disable react-hooks/set-state-in-effect -- Hydration from localStorage must happen in effect */
  83. useEffect(() => {
  84. try {
  85. const saved = localStorage.getItem(RECENT_OPEN_STORAGE_KEY);
  86. if (saved !== null) setRecentOpen(saved !== 'false');
  87. } catch {
  88. /* localStorage unavailable */
  89. }
  90. try {
  91. const savedWebSearch = localStorage.getItem(WEB_SEARCH_STORAGE_KEY);
  92. const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY);
  93. const updates: Partial<FormState> = {};
  94. if (savedWebSearch === 'true') updates.webSearch = true;
  95. if (savedLanguage === 'zh-CN' || savedLanguage === 'en-US') {
  96. updates.language = savedLanguage;
  97. } else {
  98. const detected = navigator.language?.startsWith('zh') ? 'zh-CN' : 'en-US';
  99. updates.language = detected;
  100. }
  101. if (Object.keys(updates).length > 0) {
  102. setForm((prev) => ({ ...prev, ...updates }));
  103. }
  104. } catch {
  105. /* localStorage unavailable */
  106. }
  107. }, []);
  108. /* eslint-enable react-hooks/set-state-in-effect */
  109. // Restore requirement draft from cache (derived state pattern — no effect needed)
  110. const [prevCachedRequirement, setPrevCachedRequirement] = useState(cachedRequirement);
  111. if (cachedRequirement !== prevCachedRequirement) {
  112. setPrevCachedRequirement(cachedRequirement);
  113. if (cachedRequirement) {
  114. setForm((prev) => ({ ...prev, requirement: cachedRequirement }));
  115. }
  116. }
  117. const [themeOpen, setThemeOpen] = useState(false);
  118. const [error, setError] = useState<string | null>(null);
  119. const [classrooms, setClassrooms] = useState<StageListItem[]>([]);
  120. const [thumbnails, setThumbnails] = useState<Record<string, Slide>>({});
  121. const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
  122. const toolbarRef = useRef<HTMLDivElement>(null);
  123. const textareaRef = useRef<HTMLTextAreaElement>(null);
  124. // Close dropdowns when clicking outside
  125. useEffect(() => {
  126. if (!themeOpen) return;
  127. const handleClickOutside = (e: MouseEvent) => {
  128. if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) {
  129. setThemeOpen(false);
  130. }
  131. };
  132. document.addEventListener('mousedown', handleClickOutside);
  133. return () => document.removeEventListener('mousedown', handleClickOutside);
  134. }, [themeOpen]);
  135. const loadClassrooms = async () => {
  136. try {
  137. const list = await listStages();
  138. setClassrooms(list);
  139. // Load first slide thumbnails
  140. if (list.length > 0) {
  141. const slides = await getFirstSlideByStages(list.map((c) => c.id));
  142. setThumbnails(slides);
  143. }
  144. } catch (err) {
  145. log.error('Failed to load classrooms:', err);
  146. }
  147. };
  148. useEffect(() => {
  149. // Clear stale media store to prevent cross-course thumbnail contamination.
  150. // The store may hold tasks from a previously visited classroom whose elementIds
  151. // (gen_img_1, etc.) collide with other courses' placeholders.
  152. useMediaGenerationStore.getState().revokeObjectUrls();
  153. useMediaGenerationStore.setState({ tasks: {} });
  154. // eslint-disable-next-line react-hooks/set-state-in-effect -- Store hydration on mount
  155. loadClassrooms();
  156. }, []);
  157. const handleDelete = (id: string, e: React.MouseEvent) => {
  158. e.stopPropagation();
  159. setPendingDeleteId(id);
  160. };
  161. const confirmDelete = async (id: string) => {
  162. setPendingDeleteId(null);
  163. try {
  164. await deleteStageData(id);
  165. await loadClassrooms();
  166. } catch (err) {
  167. log.error('Failed to delete classroom:', err);
  168. toast.error('Failed to delete classroom');
  169. }
  170. };
  171. const handleRename = async (id: string, newName: string) => {
  172. try {
  173. await renameStage(id, newName);
  174. setClassrooms((prev) => prev.map((c) => (c.id === id ? { ...c, name: newName } : c)));
  175. } catch (err) {
  176. log.error('Failed to rename classroom:', err);
  177. toast.error(t('classroom.renameFailed'));
  178. }
  179. };
  180. const updateForm = <K extends keyof FormState>(field: K, value: FormState[K]) => {
  181. setForm((prev) => ({ ...prev, [field]: value }));
  182. try {
  183. if (field === 'webSearch') localStorage.setItem(WEB_SEARCH_STORAGE_KEY, String(value));
  184. if (field === 'language') localStorage.setItem(LANGUAGE_STORAGE_KEY, String(value));
  185. if (field === 'requirement') updateRequirementCache(value as string);
  186. } catch {
  187. /* ignore */
  188. }
  189. };
  190. const showSetupToast = (icon: React.ReactNode, title: string, desc: string) => {
  191. toast.custom(
  192. (id) => (
  193. <div
  194. 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"
  195. onClick={() => {
  196. toast.dismiss(id);
  197. setSettingsOpen(true);
  198. }}
  199. >
  200. <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">
  201. {icon}
  202. </div>
  203. <div className="flex-1 min-w-0">
  204. <p className="text-sm font-semibold text-amber-900 dark:text-amber-200 leading-tight">
  205. {title}
  206. </p>
  207. <p className="text-xs text-amber-700/80 dark:text-amber-400/70 mt-0.5 leading-relaxed">
  208. {desc}
  209. </p>
  210. </div>
  211. <div className="shrink-0 mt-1 text-[10px] font-medium text-amber-500 dark:text-amber-500/70 tracking-wide">
  212. <Settings className="size-3.5 animate-[spin_3s_linear_infinite]" />
  213. </div>
  214. </div>
  215. ),
  216. { duration: 4000 },
  217. );
  218. };
  219. const handleGenerate = async () => {
  220. // Validate setup before proceeding
  221. if (!currentModelId) {
  222. showSetupToast(
  223. <BotOff className="size-4.5 text-amber-600 dark:text-amber-400" />,
  224. t('settings.modelNotConfigured'),
  225. t('settings.setupNeeded'),
  226. );
  227. setSettingsOpen(true);
  228. return;
  229. }
  230. if (!form.requirement.trim()) {
  231. setError(t('upload.requirementRequired'));
  232. return;
  233. }
  234. setError(null);
  235. try {
  236. const userProfile = useUserProfileStore.getState();
  237. const requirements: UserRequirements = {
  238. requirement: form.requirement,
  239. language: form.language,
  240. userNickname: userProfile.nickname || undefined,
  241. userBio: userProfile.bio || undefined,
  242. webSearch: form.webSearch || undefined,
  243. };
  244. let pdfStorageKey: string | undefined;
  245. let pdfFileName: string | undefined;
  246. let pdfProviderId: string | undefined;
  247. let pdfProviderConfig: { apiKey?: string; baseUrl?: string } | undefined;
  248. if (form.pdfFile) {
  249. pdfStorageKey = await storePdfBlob(form.pdfFile);
  250. pdfFileName = form.pdfFile.name;
  251. const settings = useSettingsStore.getState();
  252. pdfProviderId = settings.pdfProviderId;
  253. const providerCfg = settings.pdfProvidersConfig?.[settings.pdfProviderId];
  254. if (providerCfg) {
  255. pdfProviderConfig = {
  256. apiKey: providerCfg.apiKey,
  257. baseUrl: providerCfg.baseUrl,
  258. };
  259. }
  260. }
  261. const sessionState = {
  262. sessionId: nanoid(),
  263. requirements,
  264. pdfText: '',
  265. pdfImages: [],
  266. imageStorageIds: [],
  267. pdfStorageKey,
  268. pdfFileName,
  269. pdfProviderId,
  270. pdfProviderConfig,
  271. sceneOutlines: null,
  272. currentStep: 'generating' as const,
  273. };
  274. sessionStorage.setItem('generationSession', JSON.stringify(sessionState));
  275. router.push('/generation-preview');
  276. } catch (err) {
  277. log.error('Error preparing generation:', err);
  278. setError(err instanceof Error ? err.message : t('upload.generateFailed'));
  279. }
  280. };
  281. const formatDate = (timestamp: number) => {
  282. const date = new Date(timestamp);
  283. const now = new Date();
  284. const diffTime = Math.abs(now.getTime() - date.getTime());
  285. const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
  286. if (diffDays === 0) return t('classroom.today');
  287. if (diffDays === 1) return t('classroom.yesterday');
  288. if (diffDays < 7) return `${diffDays} ${t('classroom.daysAgo')}`;
  289. return date.toLocaleDateString();
  290. };
  291. const canGenerate = !!form.requirement.trim();
  292. const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  293. if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
  294. e.preventDefault();
  295. if (canGenerate) handleGenerate();
  296. }
  297. };
  298. return (
  299. <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">
  300. {/* ═══ Top-right pill (unchanged) ═══ */}
  301. <div
  302. ref={toolbarRef}
  303. 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"
  304. >
  305. {/* Language Selector */}
  306. <LanguageSwitcher onOpen={() => setThemeOpen(false)} />
  307. <div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" />
  308. {/* Theme Selector */}
  309. <div className="relative">
  310. <button
  311. onClick={() => {
  312. setThemeOpen(!themeOpen);
  313. }}
  314. 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"
  315. >
  316. {theme === 'light' && <Sun className="w-4 h-4" />}
  317. {theme === 'dark' && <Moon className="w-4 h-4" />}
  318. {theme === 'system' && <Monitor className="w-4 h-4" />}
  319. </button>
  320. {themeOpen && (
  321. <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]">
  322. <button
  323. onClick={() => {
  324. setTheme('light');
  325. setThemeOpen(false);
  326. }}
  327. className={cn(
  328. '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',
  329. theme === 'light' &&
  330. 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
  331. )}
  332. >
  333. <Sun className="w-4 h-4" />
  334. {t('settings.themeOptions.light')}
  335. </button>
  336. <button
  337. onClick={() => {
  338. setTheme('dark');
  339. setThemeOpen(false);
  340. }}
  341. className={cn(
  342. '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',
  343. theme === 'dark' &&
  344. 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
  345. )}
  346. >
  347. <Moon className="w-4 h-4" />
  348. {t('settings.themeOptions.dark')}
  349. </button>
  350. <button
  351. onClick={() => {
  352. setTheme('system');
  353. setThemeOpen(false);
  354. }}
  355. className={cn(
  356. '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',
  357. theme === 'system' &&
  358. 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
  359. )}
  360. >
  361. <Monitor className="w-4 h-4" />
  362. {t('settings.themeOptions.system')}
  363. </button>
  364. </div>
  365. )}
  366. </div>
  367. <div className="w-[1px] h-4 bg-gray-200 dark:bg-gray-700" />
  368. {/* Settings Button */}
  369. <div className="relative">
  370. <button
  371. onClick={() => setSettingsOpen(true)}
  372. 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"
  373. >
  374. <Settings className="w-4 h-4 group-hover:rotate-90 transition-transform duration-500" />
  375. </button>
  376. </div>
  377. </div>
  378. <SettingsDialog
  379. open={settingsOpen}
  380. onOpenChange={(open) => {
  381. setSettingsOpen(open);
  382. if (!open) setSettingsSection(undefined);
  383. }}
  384. initialSection={settingsSection}
  385. />
  386. {/* ═══ Background Decor ═══ */}
  387. <div className="absolute inset-0 overflow-hidden pointer-events-none">
  388. <div
  389. className="absolute top-0 left-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl animate-pulse"
  390. style={{ animationDuration: '4s' }}
  391. />
  392. <div
  393. className="absolute bottom-0 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl animate-pulse"
  394. style={{ animationDuration: '6s' }}
  395. />
  396. </div>
  397. {/* ═══ Hero section: title + input (centered, wider) ═══ */}
  398. <motion.div
  399. initial={{ opacity: 0, y: 20 }}
  400. animate={{ opacity: 1, y: 0 }}
  401. transition={{ duration: 0.6, ease: 'easeOut' }}
  402. className={cn(
  403. 'relative z-20 w-full max-w-[800px] flex flex-col items-center',
  404. classrooms.length === 0 ? 'justify-center min-h-[calc(100dvh-8rem)]' : 'mt-[10vh]',
  405. )}
  406. >
  407. {/* ── Logo ── */}
  408. <motion.img
  409. src="/logo-horizontal.png"
  410. alt="OpenMAIC"
  411. initial={{ opacity: 0, scale: 0.9 }}
  412. animate={{ opacity: 1, scale: 1 }}
  413. transition={{
  414. delay: 0.1,
  415. type: 'spring',
  416. stiffness: 200,
  417. damping: 20,
  418. }}
  419. className="h-12 md:h-16 mb-2 -ml-2 md:-ml-3"
  420. />
  421. {/* ── Slogan ── */}
  422. <motion.p
  423. initial={{ opacity: 0 }}
  424. animate={{ opacity: 1 }}
  425. transition={{ delay: 0.25 }}
  426. className="text-sm text-muted-foreground/60 mb-8"
  427. >
  428. {t('home.slogan')}
  429. </motion.p>
  430. {/* ── Unified input area ── */}
  431. <motion.div
  432. initial={{ opacity: 0, scale: 0.97 }}
  433. animate={{ opacity: 1, scale: 1 }}
  434. transition={{ delay: 0.35 }}
  435. className="w-full"
  436. >
  437. <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]">
  438. {/* ── Greeting + Profile + Agents ── */}
  439. <div className="relative z-20 flex items-start justify-between">
  440. <GreetingBar />
  441. <div className="pr-3 pt-3.5 shrink-0">
  442. <AgentBar />
  443. </div>
  444. </div>
  445. {/* Textarea */}
  446. <textarea
  447. ref={textareaRef}
  448. placeholder={t('upload.requirementPlaceholder')}
  449. 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]"
  450. value={form.requirement}
  451. onChange={(e) => updateForm('requirement', e.target.value)}
  452. onKeyDown={handleKeyDown}
  453. rows={4}
  454. />
  455. {/* Toolbar row */}
  456. <div className="px-3 pb-3 flex items-end gap-2">
  457. <div className="flex-1 min-w-0">
  458. <GenerationToolbar
  459. language={form.language}
  460. onLanguageChange={(lang) => updateForm('language', lang)}
  461. webSearch={form.webSearch}
  462. onWebSearchChange={(v) => updateForm('webSearch', v)}
  463. onSettingsOpen={(section) => {
  464. setSettingsSection(section);
  465. setSettingsOpen(true);
  466. }}
  467. pdfFile={form.pdfFile}
  468. onPdfFileChange={(f) => updateForm('pdfFile', f)}
  469. onPdfError={setError}
  470. />
  471. </div>
  472. {/* Voice input */}
  473. <SpeechButton
  474. size="md"
  475. onTranscription={(text) => {
  476. setForm((prev) => {
  477. const next = prev.requirement + (prev.requirement ? ' ' : '') + text;
  478. updateRequirementCache(next);
  479. return { ...prev, requirement: next };
  480. });
  481. }}
  482. />
  483. {/* Send button */}
  484. <button
  485. onClick={handleGenerate}
  486. disabled={!canGenerate}
  487. className={cn(
  488. 'shrink-0 h-8 rounded-lg flex items-center justify-center gap-1.5 transition-all px-3',
  489. canGenerate
  490. ? 'bg-primary text-primary-foreground hover:opacity-90 shadow-sm cursor-pointer'
  491. : 'bg-muted text-muted-foreground/40 cursor-not-allowed',
  492. )}
  493. >
  494. <span className="text-xs font-medium">{t('toolbar.enterClassroom')}</span>
  495. <ArrowUp className="size-3.5" />
  496. </button>
  497. </div>
  498. </div>
  499. </motion.div>
  500. {/* ── Error ── */}
  501. <AnimatePresence>
  502. {error && (
  503. <motion.div
  504. initial={{ opacity: 0, height: 0 }}
  505. animate={{ opacity: 1, height: 'auto' }}
  506. exit={{ opacity: 0, height: 0 }}
  507. className="mt-3 w-full p-3 bg-destructive/10 border border-destructive/20 rounded-lg"
  508. >
  509. <p className="text-sm text-destructive">{error}</p>
  510. </motion.div>
  511. )}
  512. </AnimatePresence>
  513. </motion.div>
  514. {/* ═══ Recent classrooms — collapsible ═══ */}
  515. {classrooms.length > 0 && (
  516. <motion.div
  517. initial={{ opacity: 0 }}
  518. animate={{ opacity: 1 }}
  519. transition={{ delay: 0.5 }}
  520. className="relative z-10 mt-10 w-full max-w-6xl flex flex-col items-center"
  521. >
  522. {/* Trigger — divider-line with centered text */}
  523. <button
  524. onClick={() => {
  525. const next = !recentOpen;
  526. setRecentOpen(next);
  527. try {
  528. localStorage.setItem(RECENT_OPEN_STORAGE_KEY, String(next));
  529. } catch {
  530. /* ignore */
  531. }
  532. }}
  533. className="group w-full flex items-center gap-4 py-2 cursor-pointer"
  534. >
  535. <div className="flex-1 h-px bg-border/40 group-hover:bg-border/70 transition-colors" />
  536. <span className="shrink-0 flex items-center gap-2 text-[13px] text-muted-foreground/60 group-hover:text-foreground/70 transition-colors select-none">
  537. <Clock className="size-3.5" />
  538. {t('classroom.recentClassrooms')}
  539. <span className="text-[11px] tabular-nums opacity-60">{classrooms.length}</span>
  540. <motion.div
  541. animate={{ rotate: recentOpen ? 180 : 0 }}
  542. transition={{ duration: 0.3, ease: 'easeInOut' }}
  543. >
  544. <ChevronDown className="size-3.5" />
  545. </motion.div>
  546. </span>
  547. <div className="flex-1 h-px bg-border/40 group-hover:bg-border/70 transition-colors" />
  548. </button>
  549. {/* Expandable content */}
  550. <AnimatePresence>
  551. {recentOpen && (
  552. <motion.div
  553. initial={{ height: 0, opacity: 0 }}
  554. animate={{ height: 'auto', opacity: 1 }}
  555. exit={{ height: 0, opacity: 0 }}
  556. transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
  557. className="w-full overflow-hidden"
  558. >
  559. <div className="pt-8 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-5 gap-y-8">
  560. {classrooms.map((classroom, i) => (
  561. <motion.div
  562. key={classroom.id}
  563. initial={{ opacity: 0, y: 16 }}
  564. animate={{ opacity: 1, y: 0 }}
  565. transition={{
  566. delay: i * 0.04,
  567. duration: 0.35,
  568. ease: 'easeOut',
  569. }}
  570. >
  571. <ClassroomCard
  572. classroom={classroom}
  573. slide={thumbnails[classroom.id]}
  574. formatDate={formatDate}
  575. onDelete={handleDelete}
  576. onRename={handleRename}
  577. confirmingDelete={pendingDeleteId === classroom.id}
  578. onConfirmDelete={() => confirmDelete(classroom.id)}
  579. onCancelDelete={() => setPendingDeleteId(null)}
  580. onClick={() => router.push(`/classroom/${classroom.id}`)}
  581. />
  582. </motion.div>
  583. ))}
  584. </div>
  585. </motion.div>
  586. )}
  587. </AnimatePresence>
  588. </motion.div>
  589. )}
  590. {/* Footer — flows with content, at the very end */}
  591. <div className="mt-auto pt-12 pb-4 text-center text-xs text-muted-foreground/40">
  592. OpenMAIC Open Source Project
  593. </div>
  594. </div>
  595. );
  596. }
  597. // ─── Greeting Bar — avatar + "Hi, Name", click to edit in-place ────
  598. const MAX_AVATAR_SIZE = 5 * 1024 * 1024;
  599. function isCustomAvatar(src: string) {
  600. return src.startsWith('data:');
  601. }
  602. function GreetingBar() {
  603. const { t } = useI18n();
  604. const avatar = useUserProfileStore((s) => s.avatar);
  605. const nickname = useUserProfileStore((s) => s.nickname);
  606. const bio = useUserProfileStore((s) => s.bio);
  607. const setAvatar = useUserProfileStore((s) => s.setAvatar);
  608. const setNickname = useUserProfileStore((s) => s.setNickname);
  609. const setBio = useUserProfileStore((s) => s.setBio);
  610. const [open, setOpen] = useState(false);
  611. const [editingName, setEditingName] = useState(false);
  612. const [nameDraft, setNameDraft] = useState('');
  613. const [avatarPickerOpen, setAvatarPickerOpen] = useState(false);
  614. const nameInputRef = useRef<HTMLInputElement>(null);
  615. const avatarInputRef = useRef<HTMLInputElement>(null);
  616. const containerRef = useRef<HTMLDivElement>(null);
  617. const displayName = nickname || t('profile.defaultNickname');
  618. // Click-outside to collapse
  619. useEffect(() => {
  620. if (!open) return;
  621. const handler = (e: MouseEvent) => {
  622. if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
  623. setOpen(false);
  624. setEditingName(false);
  625. setAvatarPickerOpen(false);
  626. }
  627. };
  628. document.addEventListener('mousedown', handler);
  629. return () => document.removeEventListener('mousedown', handler);
  630. }, [open]);
  631. const startEditName = () => {
  632. setNameDraft(nickname);
  633. setEditingName(true);
  634. setTimeout(() => nameInputRef.current?.focus(), 50);
  635. };
  636. const commitName = () => {
  637. setNickname(nameDraft.trim());
  638. setEditingName(false);
  639. };
  640. const handleAvatarUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
  641. const file = e.target.files?.[0];
  642. if (!file) return;
  643. if (file.size > MAX_AVATAR_SIZE) {
  644. toast.error(t('profile.fileTooLarge'));
  645. return;
  646. }
  647. if (!file.type.startsWith('image/')) {
  648. toast.error(t('profile.invalidFileType'));
  649. return;
  650. }
  651. const reader = new FileReader();
  652. reader.onload = () => {
  653. const img = new window.Image();
  654. img.onload = () => {
  655. const canvas = document.createElement('canvas');
  656. canvas.width = 128;
  657. canvas.height = 128;
  658. const ctx = canvas.getContext('2d')!;
  659. const scale = Math.max(128 / img.width, 128 / img.height);
  660. const w = img.width * scale;
  661. const h = img.height * scale;
  662. ctx.drawImage(img, (128 - w) / 2, (128 - h) / 2, w, h);
  663. setAvatar(canvas.toDataURL('image/jpeg', 0.85));
  664. };
  665. img.src = reader.result as string;
  666. };
  667. reader.readAsDataURL(file);
  668. e.target.value = '';
  669. };
  670. return (
  671. <div ref={containerRef} className="relative pl-4 pr-2 pt-3.5 pb-1 w-auto">
  672. <input
  673. ref={avatarInputRef}
  674. type="file"
  675. accept="image/*"
  676. className="hidden"
  677. onChange={handleAvatarUpload}
  678. />
  679. {/* ── Collapsed pill (always in flow) ── */}
  680. {!open && (
  681. <div
  682. 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]"
  683. onClick={() => setOpen(true)}
  684. >
  685. <div className="shrink-0 relative">
  686. <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">
  687. <img src={avatar} alt="" className="size-full object-cover" />
  688. </div>
  689. <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">
  690. <Pencil className="size-[7px] text-muted-foreground/70" />
  691. </div>
  692. </div>
  693. <div className="flex-1 min-w-0">
  694. <Tooltip>
  695. <TooltipTrigger asChild>
  696. <span className="leading-none select-none flex items-center gap-1">
  697. <span className="text-[13px] font-semibold text-foreground/85 group-hover:text-foreground transition-colors">
  698. {t('home.greetingWithName', { name: displayName })}
  699. </span>
  700. <ChevronDown className="size-3 text-muted-foreground/30 group-hover:text-muted-foreground/60 transition-colors shrink-0" />
  701. </span>
  702. </TooltipTrigger>
  703. <TooltipContent side="bottom" sideOffset={4}>
  704. {t('profile.editTooltip')}
  705. </TooltipContent>
  706. </Tooltip>
  707. </div>
  708. </div>
  709. )}
  710. {/* ── Expanded panel (absolute, floating) ── */}
  711. <AnimatePresence>
  712. {open && (
  713. <motion.div
  714. initial={{ opacity: 0, y: -4, scale: 0.97 }}
  715. animate={{ opacity: 1, y: 0, scale: 1 }}
  716. exit={{ opacity: 0, y: -4, scale: 0.97 }}
  717. transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
  718. className="absolute left-4 top-3.5 z-50 w-64"
  719. >
  720. <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">
  721. {/* ── Row: avatar + name ── */}
  722. <div
  723. className="flex items-center gap-2.5 cursor-pointer transition-all duration-200"
  724. onClick={() => {
  725. setOpen(false);
  726. setEditingName(false);
  727. setAvatarPickerOpen(false);
  728. }}
  729. >
  730. {/* Avatar */}
  731. <div
  732. className="shrink-0 relative cursor-pointer"
  733. onClick={(e) => {
  734. e.stopPropagation();
  735. setAvatarPickerOpen(!avatarPickerOpen);
  736. }}
  737. >
  738. <div className="size-8 rounded-full overflow-hidden ring-[1.5px] ring-violet-300/70 dark:ring-violet-500/40 transition-all duration-300">
  739. <img src={avatar} alt="" className="size-full object-cover" />
  740. </div>
  741. <motion.div
  742. initial={{ scale: 0 }}
  743. animate={{ scale: 1 }}
  744. 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"
  745. >
  746. <ChevronDown
  747. className={cn(
  748. 'size-2 text-muted-foreground/70 transition-transform duration-200',
  749. avatarPickerOpen && 'rotate-180',
  750. )}
  751. />
  752. </motion.div>
  753. </div>
  754. {/* Text */}
  755. <div className="flex-1 min-w-0">
  756. {editingName ? (
  757. <div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
  758. <input
  759. ref={nameInputRef}
  760. value={nameDraft}
  761. onChange={(e) => setNameDraft(e.target.value)}
  762. onKeyDown={(e) => {
  763. if (e.key === 'Enter') commitName();
  764. if (e.key === 'Escape') {
  765. setEditingName(false);
  766. }
  767. }}
  768. onBlur={commitName}
  769. maxLength={20}
  770. placeholder={t('profile.defaultNickname')}
  771. 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"
  772. />
  773. <button
  774. onClick={commitName}
  775. className="shrink-0 size-5 rounded flex items-center justify-center text-violet-500 hover:bg-violet-100 dark:hover:bg-violet-900/30"
  776. >
  777. <Check className="size-3" />
  778. </button>
  779. </div>
  780. ) : (
  781. <span
  782. onClick={(e) => {
  783. e.stopPropagation();
  784. startEditName();
  785. }}
  786. className="group/name inline-flex items-center gap-1 cursor-pointer"
  787. >
  788. <span className="text-[13px] font-semibold text-foreground/85 group-hover/name:text-foreground transition-colors">
  789. {displayName}
  790. </span>
  791. <Pencil className="size-2.5 text-muted-foreground/30 opacity-0 group-hover/name:opacity-100 transition-opacity" />
  792. </span>
  793. )}
  794. </div>
  795. {/* Collapse arrow */}
  796. <motion.div
  797. initial={{ opacity: 0, y: -2 }}
  798. animate={{ opacity: 1, y: 0 }}
  799. 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"
  800. >
  801. <ChevronUp className="size-3.5 text-muted-foreground/50" />
  802. </motion.div>
  803. </div>
  804. {/* ── Expandable content ── */}
  805. <div className="pt-2" onClick={(e) => e.stopPropagation()}>
  806. {/* Avatar picker */}
  807. <AnimatePresence>
  808. {avatarPickerOpen && (
  809. <motion.div
  810. initial={{ height: 0, opacity: 0 }}
  811. animate={{ height: 'auto', opacity: 1 }}
  812. exit={{ height: 0, opacity: 0 }}
  813. transition={{ duration: 0.15, ease: 'easeInOut' }}
  814. className="overflow-hidden"
  815. >
  816. <div className="p-1 pb-2.5 flex items-center gap-1.5 flex-wrap">
  817. {AVATAR_OPTIONS.map((url) => (
  818. <button
  819. key={url}
  820. onClick={() => setAvatar(url)}
  821. className={cn(
  822. 'size-7 rounded-full overflow-hidden bg-gray-50 dark:bg-gray-800 cursor-pointer transition-all duration-150',
  823. 'hover:scale-110 active:scale-95',
  824. avatar === url
  825. ? 'ring-2 ring-violet-400 dark:ring-violet-500 ring-offset-0'
  826. : 'hover:ring-1 hover:ring-muted-foreground/30',
  827. )}
  828. >
  829. <img src={url} alt="" className="size-full" />
  830. </button>
  831. ))}
  832. <label
  833. className={cn(
  834. 'size-7 rounded-full flex items-center justify-center cursor-pointer transition-all duration-150 border border-dashed',
  835. 'hover:scale-110 active:scale-95',
  836. isCustomAvatar(avatar)
  837. ? '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'
  838. : 'border-muted-foreground/30 text-muted-foreground/50 hover:border-muted-foreground/50',
  839. )}
  840. onClick={() => avatarInputRef.current?.click()}
  841. title={t('profile.uploadAvatar')}
  842. >
  843. <ImagePlus className="size-3" />
  844. </label>
  845. </div>
  846. </motion.div>
  847. )}
  848. </AnimatePresence>
  849. {/* Bio */}
  850. <UITextarea
  851. value={bio}
  852. onChange={(e) => setBio(e.target.value)}
  853. placeholder={t('profile.bioPlaceholder')}
  854. maxLength={200}
  855. rows={2}
  856. 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"
  857. />
  858. </div>
  859. </div>
  860. </motion.div>
  861. )}
  862. </AnimatePresence>
  863. </div>
  864. );
  865. }
  866. // ─── Classroom Card — clean, minimal style ──────────────────────
  867. function ClassroomCard({
  868. classroom,
  869. slide,
  870. formatDate,
  871. onDelete,
  872. onRename,
  873. confirmingDelete,
  874. onConfirmDelete,
  875. onCancelDelete,
  876. onClick,
  877. }: {
  878. classroom: StageListItem;
  879. slide?: Slide;
  880. formatDate: (ts: number) => string;
  881. onDelete: (id: string, e: React.MouseEvent) => void;
  882. onRename: (id: string, newName: string) => void;
  883. confirmingDelete: boolean;
  884. onConfirmDelete: () => void;
  885. onCancelDelete: () => void;
  886. onClick: () => void;
  887. }) {
  888. const { t } = useI18n();
  889. const thumbRef = useRef<HTMLDivElement>(null);
  890. const [thumbWidth, setThumbWidth] = useState(0);
  891. const [editing, setEditing] = useState(false);
  892. const [nameDraft, setNameDraft] = useState('');
  893. const nameInputRef = useRef<HTMLInputElement>(null);
  894. useEffect(() => {
  895. const el = thumbRef.current;
  896. if (!el) return;
  897. const ro = new ResizeObserver(([entry]) => {
  898. setThumbWidth(Math.round(entry.contentRect.width));
  899. });
  900. ro.observe(el);
  901. return () => ro.disconnect();
  902. }, []);
  903. useEffect(() => {
  904. if (editing) nameInputRef.current?.focus();
  905. }, [editing]);
  906. const startRename = (e: React.MouseEvent) => {
  907. e.stopPropagation();
  908. setNameDraft(classroom.name);
  909. setEditing(true);
  910. };
  911. const commitRename = () => {
  912. if (!editing) return;
  913. const trimmed = nameDraft.trim();
  914. if (trimmed && trimmed !== classroom.name) {
  915. onRename(classroom.id, trimmed);
  916. }
  917. setEditing(false);
  918. };
  919. return (
  920. <div className="group cursor-pointer" onClick={confirmingDelete ? undefined : onClick}>
  921. {/* Thumbnail — large radius, no border, subtle bg */}
  922. <div
  923. ref={thumbRef}
  924. 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]"
  925. >
  926. {slide && thumbWidth > 0 ? (
  927. <ThumbnailSlide
  928. slide={slide}
  929. size={thumbWidth}
  930. viewportSize={slide.viewportSize ?? 1000}
  931. viewportRatio={slide.viewportRatio ?? 0.5625}
  932. />
  933. ) : !slide ? (
  934. <div className="absolute inset-0 flex items-center justify-center">
  935. <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">
  936. <span className="text-xl opacity-50">📄</span>
  937. </div>
  938. </div>
  939. ) : null}
  940. {/* Delete — top-right, only on hover */}
  941. <AnimatePresence>
  942. {!confirmingDelete && (
  943. <motion.div
  944. initial={{ opacity: 0 }}
  945. animate={{ opacity: 1 }}
  946. exit={{ opacity: 0 }}
  947. transition={{ duration: 0.15 }}
  948. >
  949. <Button
  950. size="icon"
  951. variant="ghost"
  952. 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"
  953. onClick={(e) => {
  954. e.stopPropagation();
  955. onDelete(classroom.id, e);
  956. }}
  957. >
  958. <Trash2 className="size-3.5" />
  959. </Button>
  960. <Button
  961. size="icon"
  962. variant="ghost"
  963. 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"
  964. onClick={startRename}
  965. >
  966. <Pencil className="size-3.5" />
  967. </Button>
  968. </motion.div>
  969. )}
  970. </AnimatePresence>
  971. {/* Inline delete confirmation overlay */}
  972. <AnimatePresence>
  973. {confirmingDelete && (
  974. <motion.div
  975. initial={{ opacity: 0 }}
  976. animate={{ opacity: 1 }}
  977. exit={{ opacity: 0 }}
  978. transition={{ duration: 0.15 }}
  979. className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/50 backdrop-blur-[6px]"
  980. onClick={(e) => e.stopPropagation()}
  981. >
  982. <span className="text-[13px] font-medium text-white/90">
  983. {t('classroom.deleteConfirmTitle')}?
  984. </span>
  985. <div className="flex gap-2">
  986. <button
  987. 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"
  988. onClick={onCancelDelete}
  989. >
  990. {t('common.cancel')}
  991. </button>
  992. <button
  993. className="px-3.5 py-1 rounded-lg text-[12px] font-medium bg-red-500/90 text-white hover:bg-red-500 transition-colors"
  994. onClick={onConfirmDelete}
  995. >
  996. {t('classroom.delete')}
  997. </button>
  998. </div>
  999. </motion.div>
  1000. )}
  1001. </AnimatePresence>
  1002. </div>
  1003. {/* Info — outside the thumbnail */}
  1004. <div className="mt-2.5 px-1 flex items-center gap-2">
  1005. <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">
  1006. {classroom.sceneCount} {t('classroom.slides')} · {formatDate(classroom.updatedAt)}
  1007. </span>
  1008. {editing ? (
  1009. <div className="flex-1 min-w-0" onClick={(e) => e.stopPropagation()}>
  1010. <input
  1011. ref={nameInputRef}
  1012. value={nameDraft}
  1013. onChange={(e) => setNameDraft(e.target.value)}
  1014. onKeyDown={(e) => {
  1015. if (e.key === 'Enter') commitRename();
  1016. if (e.key === 'Escape') setEditing(false);
  1017. }}
  1018. onBlur={commitRename}
  1019. maxLength={100}
  1020. placeholder={t('classroom.renamePlaceholder')}
  1021. 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"
  1022. />
  1023. </div>
  1024. ) : (
  1025. <Tooltip>
  1026. <TooltipTrigger asChild>
  1027. <p
  1028. className="font-medium text-[15px] truncate text-foreground/90 min-w-0 cursor-text"
  1029. onDoubleClick={startRename}
  1030. >
  1031. {classroom.name}
  1032. </p>
  1033. </TooltipTrigger>
  1034. <TooltipContent
  1035. side="bottom"
  1036. sideOffset={4}
  1037. className="!max-w-[min(90vw,32rem)] break-words whitespace-normal"
  1038. >
  1039. <div className="flex items-center gap-1.5">
  1040. <span className="break-all">{classroom.name}</span>
  1041. <button
  1042. className="shrink-0 p-0.5 rounded hover:bg-foreground/10 transition-colors"
  1043. onClick={(e) => {
  1044. e.stopPropagation();
  1045. navigator.clipboard.writeText(classroom.name);
  1046. toast.success(t('classroom.nameCopied'));
  1047. }}
  1048. >
  1049. <Copy className="size-3 opacity-60" />
  1050. </button>
  1051. </div>
  1052. </TooltipContent>
  1053. </Tooltip>
  1054. )}
  1055. </div>
  1056. </div>
  1057. );
  1058. }
  1059. export default function Page() {
  1060. return <HomePage />;
  1061. }