Files
OpenMAIC/app/page.tsx
T
wyucandClaude Fable 5 4f00cf7ad6 feat(materials): material library manifest and id-addressed generation images (#1153 part 2) (#1168)
* feat(materials): material library manifest and id-addressed generation images (#1153 part 2)

* fix(materials): library-owned references and failure-path parity (#1153 part 2)

* fix(generation): resolve-then-slice vision images and close allocation leaks (#1153 part 2)

* fix(generation): bound vision resolution and make library writes leak-safe (#1153 part 2)

Bound the resolve-with-refill phase with an aggregate 15s budget raced
against every probe plus a 3-consecutive-failure fuse — either stop strips
unresolved and unprobed ids from the mapping and proceeds (text-only at
worst), never failing the request. Restore the original interleaved text
ordering for non-vision runs with a mapping present. Guard the library's
failed-entry-write release with an ambiguous-commit re-read: release only
when the entry provably does not point at the new id, keep it when the
write actually landed, and prefer a recoverable leak over a dangling entry
when the re-read fails. Drop the unused package export.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(generation): bump @openmaic/generation to 0.3.1 (#1153 part 2)

The package's inputs changed (partitionImagesForVision, resolvedVisionImages
option, non-vision ordering fix); the merge-time gate requires a version bump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 18:08:40 +08:00

2049 lines
88 KiB
TypeScript

'use client';
import { useState, useEffect, useMemo, useRef, useDeferredValue } from 'react';
import { useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'motion/react';
import {
ArrowUp,
Check,
ChevronDown,
ChevronRight,
Clock,
Copy,
Folder,
FolderPlus,
ImagePlus,
Pencil,
Trash2,
Search,
Settings,
Sun,
Moon,
Monitor,
ChevronUp,
Upload,
Sparkles,
Atom,
X,
Presentation,
Loader2,
} 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 { putAsset, removeAsset } from '@/lib/media/asset-pool';
import { deleteDocumentBlob, storeDocumentBlob } from '@/lib/utils/image-storage';
import { normalizeDocumentMimeType } from '@/lib/document/mime';
import {
courseMaterialFingerprint,
dedupeCourseMaterialFiles,
} from '@/lib/document/course-materials';
import {
awaitPendingIngests,
DEFAULT_INGEST_AWAIT_TIMEOUT_MS,
resolvedAssetIdForIngest,
} from '@/lib/document/extract-source';
import { computeContentDigest } from '@/lib/document/extraction-cache';
import { upsertMaterialLibraryEntry } from '@/lib/materials/library';
import type {
SelectedCourseMaterial,
SessionDocumentSource,
UserRequirements,
} from '@/lib/types/generation';
import { useSettingsStore } from '@/lib/store/settings';
import { hasUsableLLMProvider } from '@/lib/store/settings-validation';
import { useUserProfileStore, AVATAR_OPTIONS } from '@/lib/store/user-profile';
import {
StageListItem,
listStages,
deleteStageData,
renameStage,
getFirstSlideByStages,
revokeThumbnailSlideMediaUrls,
listFolders,
createFolder,
renameFolder,
deleteFolder,
setStageFolder,
FolderNameError,
type DeleteFolderMode,
} from '@/lib/utils/stage-storage';
import type { FolderRecord } from '@/lib/utils/database';
import { displayNameWidth, FOLDER_NAME_MAX_WIDTH } from '@/lib/utils/folder-name-validation';
import { FolderCard } from '@/components/discovery/folder-card';
import { NewFolderDialog } from '@/components/discovery/folder-dialogs';
import { MoveToFolderMenu } from '@/components/discovery/move-to-folder-menu';
import { SlideThumbnail } from '@/components/slide-renderer/SlideThumbnail';
import type { Slide } from '@openmaic/dsl';
import { useMediaGenerationStore } from '@/lib/store/media-generation';
import { toast } from 'sonner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useDraftCache } from '@/lib/hooks/use-draft-cache';
import { SpeechButton } from '@/components/audio/speech-button';
import { useImportClassroom } from '@/lib/import/use-import-classroom';
import { isPptxImportEnabled, shouldShowVocationalTestUi } from '@/lib/config/feature-flags';
import { useImportPptx } from '@/lib/import/use-import-pptx';
import { InteractiveModeButton } from '@/components/generation/interactive-mode-button';
const log = createLogger('Home');
const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled';
const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen';
const INTERACTIVE_MODE_STORAGE_KEY = 'interactiveModeEnabled';
// PPTX import is still scaffolding: `useImportPptx` has no `onImported` consumer
// yet, so the flow only logs the parsed slides. Hide the entry point behind a
// flag until it's wired end-to-end, so the UI doesn't expose a no-op button.
const PPTX_IMPORT_ENABLED = isPptxImportEnabled();
interface FormState {
courseMaterials: SelectedCourseMaterial[];
requirement: string;
webSearch: boolean;
interactiveMode: boolean;
vocationalTestMode: boolean;
}
const initialFormState: FormState = {
courseMaterials: [],
requirement: '',
webSearch: false,
interactiveMode: false,
vocationalTestMode: false,
};
function HomePage() {
const { t } = useI18n();
const { theme, setTheme } = useTheme();
const router = useRouter();
const showVocationalTestUi = shouldShowVocationalTestUi();
const [form, setForm] = useState<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' });
// A usable LLM provider exists ⇒ a concrete model is always selected (#580
// invariant). Gate generation on this single condition (state A vs B)
// instead of inspecting modelId directly.
const providersConfig = useSettingsStore((s) => s.providersConfig);
const hasUsableProvider = hasUsableLLMProvider(providersConfig);
const [recentOpen, setRecentOpen] = useState(true);
const persistRecentOpen = (next: boolean) => {
setRecentOpen(next);
try {
localStorage.setItem(RECENT_OPEN_STORAGE_KEY, String(next));
} catch {
/* ignore */
}
};
// Hydrate client-only state after mount (avoids SSR mismatch)
useEffect(() => {
try {
const saved = localStorage.getItem(RECENT_OPEN_STORAGE_KEY);
if (saved !== null) setRecentOpen(saved !== 'false');
} catch {
/* localStorage unavailable */
}
try {
const savedWebSearch = localStorage.getItem(WEB_SEARCH_STORAGE_KEY);
const savedInteractiveMode = localStorage.getItem(INTERACTIVE_MODE_STORAGE_KEY);
const updates: Partial<FormState> = {};
if (savedWebSearch === 'true') updates.webSearch = true;
if (savedInteractiveMode === 'true') updates.interactiveMode = true;
if (Object.keys(updates).length > 0) {
setForm((prev) => ({ ...prev, ...updates }));
}
} catch {
/* localStorage unavailable */
}
}, []);
// Restore requirement draft from localStorage on mount. The previous derived-state
// pattern initialised `prev` from the cached value itself, so on the first client
// render the comparison was always equal and the restore never fired. Use an effect
// so the cache is hydrated into the form once we know the live requirement is empty.
const draftRestoredRef = useRef(false);
useEffect(() => {
if (draftRestoredRef.current) return;
if (!cachedRequirement) return;
draftRestoredRef.current = true;
setForm((prev) => (prev.requirement ? prev : { ...prev, requirement: cachedRequirement }));
}, [cachedRequirement]);
const [themeOpen, setThemeOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
// True while the Generate click drains upload-time ingests and builds the
// generation session. Doubles as the guard flag that freezes the course
// material set for the duration of prep and as the switch that disables the
// toolbar's add/remove affordances, so the session is always built from a
// set that cannot change under it.
const [preparingGenerate, setPreparingGenerate] = useState(false);
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('');
// Course folders — device-local grouping. `currentFolderId === undefined`
// is the root view (folders + unfiled courses); a folder id navigates into
// that folder's course list. Searching flattens every course regardless of
// folder and annotates each with its folder name.
const [folders, setFolders] = useState<FolderRecord[]>([]);
// True once the initial classroom + folder loads resolve. Guards layout
// selection so the hero does not flip between full-screen and compact as the
// two async reads land (avoids a visible layout shift on first paint).
const [hydrated, setHydrated] = useState(false);
const [currentFolderId, setCurrentFolderId] = useState<string | undefined>(undefined);
const [newFolderOpen, setNewFolderOpen] = useState(false);
// When set, the new-folder dialog is creating a folder AND moving this course
// into it (entered via the move-menu's "new folder" entry).
const [createAndMoveTarget, setCreateAndMoveTarget] = useState<string | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchButtonRef = useRef<HTMLButtonElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const thumbnailsRef = useRef<Record<string, Slide>>({});
// In-flight asset-pool ingests, keyed by course material id, so removing a
// file whose pool entry is still being allocated can still release it.
const pendingMaterialIngestsRef = useRef(new Map<string, Promise<string>>());
// In-flight content-digest computations, keyed the same way. The digest is
// the stable half of the extraction-cache key, so the session build reads it
// from the settled promise just like the asset id (see
// `resolvedAssetIdForIngest` for the commit-timing rationale).
const pendingMaterialDigestsRef = useRef(new Map<string, Promise<string | undefined>>());
// Course-material ids whose ingest was ABANDONED: removed from the
// selection (the source file handle is gone) or released at prep-timeout
// because no durable holder would ever exist for the id. The material-library
// upsert skips these — minting would record bytes the user deliberately
// discarded (RFC #1153 part 2, N1). Written synchronously by the removal
// path and the prep-timeout drain, read by the upsert settlement below.
const abandonedMaterialIngestIdsRef = useRef(new Set<string>());
const replaceThumbnails = (slides: Record<string, Slide>) => {
const previous = thumbnailsRef.current;
thumbnailsRef.current = slides;
setThumbnails(slides);
window.setTimeout(() => revokeThumbnailSlideMediaUrls(previous), 0);
};
// Close dropdowns when clicking outside
useEffect(() => {
if (!themeOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) {
setThemeOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [themeOpen]);
const loadClassrooms = async () => {
try {
const list = await listStages();
setClassrooms(list);
// Load first slide thumbnails
if (list.length > 0) {
const slides = await getFirstSlideByStages(list.map((c) => c.id));
replaceThumbnails(slides);
} else {
replaceThumbnails({});
}
} catch (err) {
log.error('Failed to load classrooms:', err);
toast.error('Persistence is unavailable. Saved classrooms could not be loaded.');
}
};
const loadFolders = async () => {
try {
setFolders(await listFolders());
} catch (err) {
log.error('Failed to load folders:', err);
}
};
// Capture the active folder when an import starts so the imported course
// lands in that folder, not whichever folder is active when the async import
// resolves (the user may have navigated away in the meantime).
const importFolderRef = useRef<string | undefined>(undefined);
const handleImportSuccess = async (importedStageId: string) => {
const folderId = importFolderRef.current;
importFolderRef.current = undefined;
// File the imported course into the folder that was active when the
// import began, before refreshing the list so the card appears in place.
if (folderId) {
try {
await setStageFolder(importedStageId, folderId);
} catch (err) {
log.error('Failed to assign imported course to folder:', err);
toast.error(t('classroom.moveFailed'));
}
}
await loadClassrooms();
};
const { importing, fileInputRef, triggerFileSelect, handleFileChange } =
useImportClassroom(handleImportSuccess);
const triggerImport = () => {
importFolderRef.current = currentFolderId;
triggerFileSelect();
};
const {
importing: pptxImporting,
fileInputRef: pptxFileInputRef,
triggerFileSelect: triggerPptxFileSelect,
handleFileChange: handlePptxFileChange,
} = useImportPptx();
useEffect(() => {
// Clear stale media store to prevent cross-course thumbnail contamination.
// The store may hold tasks from a previously visited classroom whose elementIds
// (gen_img_1, etc.) collide with other courses' placeholders.
useMediaGenerationStore.getState().revokeObjectUrls();
useMediaGenerationStore.setState({ tasks: {} });
// Read sessionStorage on the client only (avoids SSR hydration mismatch).
// Both reads resolve before flipping `hydrated`, so the hero layout does
// not thrash as each lands independently.
void Promise.all([loadClassrooms(), loadFolders()]).finally(() => setHydrated(true));
return () => {
revokeThumbnailSlideMediaUrls(thumbnailsRef.current);
thumbnailsRef.current = {};
};
}, []);
const handleDelete = (id: string, e: React.MouseEvent) => {
e.stopPropagation();
setPendingDeleteId(id);
};
const confirmDelete = async (id: string) => {
setPendingDeleteId(null);
try {
await deleteStageData(id);
await loadClassrooms();
} catch (err) {
log.error('Failed to delete classroom:', err);
toast.error('Failed to delete classroom');
}
};
const handleRename = async (id: string, newName: string) => {
try {
await renameStage(id, newName);
setClassrooms((prev) => prev.map((c) => (c.id === id ? { ...c, name: newName } : c)));
} catch (err) {
log.error('Failed to rename classroom:', err);
toast.error(t('classroom.renameFailed'));
}
};
// ─── Folder handlers ────────────────────────────────────────────────
const handleCreateFolder = async (name: string) => {
const folder = await createFolder(name);
setFolders((prev) => [...prev, folder]);
// If this create came from the move-menu's "new folder" entry, move the
// requesting course into the freshly created folder.
if (createAndMoveTarget) {
await handleMoveCourse(createAndMoveTarget, folder.id);
setCreateAndMoveTarget(null);
}
};
const handleRenameFolder =
(folder: FolderRecord) =>
async (newName: string): Promise<string | null> => {
// Empty or unchanged input just exits editing without an error.
const trimmed = newName.trim();
if (!trimmed || trimmed === folder.name) return null;
try {
await renameFolder(folder.id, newName);
setFolders((prev) => prev.map((f) => (f.id === folder.id ? { ...f, name: trimmed } : f)));
return null;
} catch (err) {
if (err instanceof FolderNameError) {
if (err.kind === 'duplicate') return t('classroom.folderNameExists');
if (err.kind === 'tooLong')
return t('classroom.folderWidth', {
width: displayNameWidth(trimmed),
max: FOLDER_NAME_MAX_WIDTH,
});
return t('classroom.folderNameHint');
}
log.error('Failed to rename folder:', err);
return t('classroom.folderRenameFailed');
}
};
const confirmDeleteFolder = async (folder: FolderRecord, mode: DeleteFolderMode) => {
try {
await deleteFolder(folder.id, mode);
if (currentFolderId === folder.id) setCurrentFolderId(undefined);
} catch (err) {
log.error('Failed to delete folder:', err);
toast.error(t('classroom.folderDeleteFailed'));
} finally {
// Always refresh authoritative state: in 'remove' mode a partial failure
// may have durably deleted some courses before throwing, and the UI must
// reflect that rather than leaving stale cards/counts behind.
await Promise.all([loadFolders(), loadClassrooms()]);
}
};
const handleMoveCourse = async (stageId: string, folderId: string | undefined) => {
// Optimistic update for snappy UI; the persistence call follows.
setClassrooms((prev) => prev.map((c) => (c.id === stageId ? { ...c, folderId } : c)));
try {
await setStageFolder(stageId, folderId);
} catch (err) {
log.error('Failed to move course:', err);
toast.error(t('classroom.moveFailed'));
// Revert on failure.
await loadClassrooms();
}
};
// From the move-menu's "new folder" entry: remember the course, then open the
// folder dialog. The actual create+move happens in handleCreateFolder once the
// name is confirmed. (A Radix DropdownMenu is modal, so the name input cannot
// live inside it; the dialog is the focus surface.)
const handleCreateAndMove = (stageId: string) => () => {
setCreateAndMoveTarget(stageId);
setNewFolderOpen(true);
};
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]);
// Folder-aware view model. Searching collapses the hierarchy: every matching
// course is shown flat, annotated with its folder name. Otherwise the root
// view shows folder tiles + unfiled courses, and a folder view shows only
// that folder's members.
const folderNameById = useMemo(() => new Map(folders.map((f) => [f.id, f.name])), [folders]);
const isSearching = deferredSearchQuery.trim().length > 0;
// The course tiles rendered in the active view: search flattens everything;
// a folder shows only its members; the root shows unfiled courses (folder
// tiles are rendered separately above them).
const visibleClassrooms = useMemo(() => {
if (isSearching) return filteredClassrooms;
if (currentFolderId) return filteredClassrooms.filter((c) => c.folderId === currentFolderId);
return filteredClassrooms.filter(
(c) => c.folderId === undefined || !folderNameById.has(c.folderId),
);
}, [filteredClassrooms, isSearching, currentFolderId, folderNameById]);
const currentFolderClassrooms = useMemo(
() => (currentFolderId ? classrooms.filter((c) => c.folderId === currentFolderId) : []),
[classrooms, currentFolderId],
);
const courseCountByFolder = useMemo(() => {
const counts = new Map<string, number>();
for (const c of classrooms) {
if (c.folderId) counts.set(c.folderId, (counts.get(c.folderId) ?? 0) + 1);
}
return counts;
}, [classrooms]);
// Up to 3 member course covers (first-slide thumbnails) per folder, for the
// folder tile's cover stack. Members are ordered by updatedAt desc so the
// frontmost cover is the most recently touched course.
const coverSlidesByFolder = useMemo(() => {
const byFolder = new Map<string, Slide[]>();
for (const c of [...classrooms].sort((a, b) => b.updatedAt - a.updatedAt)) {
if (!c.folderId) continue;
const slide = thumbnails[c.id];
if (!slide) continue;
const list = byFolder.get(c.folderId) ?? [];
if (list.length < 3) list.push(slide);
byFolder.set(c.folderId, list);
}
return byFolder;
}, [classrooms, thumbnails]);
const currentFolder = folders.find((f) => f.id === currentFolderId);
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 {
/* ignore */
}
};
// Ingest a selected file into the asset pool as soon as it is picked, so the
// material gets its allocated asset id at upload time (RFC #1153 part 0).
// The file still appears in the form immediately; the asset id is patched in
// when the pool entry lands. A failure only loses the pool entry — the
// source keeps working through the legacy blob-stash byte upload — so it is
// logged, not surfaced.
//
// A tab closing mid-ingest can still orphan a pool entry: the browser gives
// no reliable release hook once the page tears down. Accepted — the
// material-library milestone of RFC #1153 brings visibility and management
// for such entries.
const ingestCourseMaterialIntoPool = (addition: SelectedCourseMaterial) => {
const ingest = putAsset(addition.file).then((assetId) => {
setForm((prev) =>
prev.courseMaterials.some((item) => item.id === addition.id)
? {
...prev,
courseMaterials: prev.courseMaterials.map((item) =>
item.id === addition.id ? { ...item, assetId } : item,
),
}
: prev,
);
return assetId;
});
// The pending entry is the durable release handle for this pool entry and
// is intentionally NOT deleted when the ingest settles: deleting it inside
// the ingest's own settlement would race the state patch above (React
// commits it after this promise resolves), orphaning the entry if the user
// removes the file in that window. Only the removal path deletes it (see
// removeCourseMaterial), so an entry is always retrievable until the id
// has a durable holder. Entries for never-removed sources stay until the
// page unmounts — bounded by the files picked in one session.
pendingMaterialIngestsRef.current.set(addition.id, ingest);
void ingest.catch((error) => {
log.error(`Failed to ingest course material "${addition.name}" into the asset pool:`, error);
});
// Content identity: the SHA-256 of the file bytes, computed in parallel
// with the pool ingest (the file is already in memory for putAsset). It is
// the stable half of the extraction-cache key — two uploads of the same
// bytes get different allocated asset ids but the same digest. A digest
// failure only means the source skips the extraction cache (a conservative
// miss); it never blocks the upload or the extraction.
const contentDigest = computeContentDigest(addition.file).catch((error) => {
log.error(`Failed to compute the content digest for "${addition.name}":`, error);
return undefined;
});
pendingMaterialDigestsRef.current.set(addition.id, contentDigest);
void contentDigest.then((digest) => {
if (digest === undefined) return;
setForm((prev) =>
prev.courseMaterials.some((item) => item.id === addition.id)
? {
...prev,
courseMaterials: prev.courseMaterials.map((item) =>
item.id === addition.id ? { ...item, contentDigest: digest } : item,
),
}
: prev,
);
});
// Material library manifest (RFC #1153 part 2): once BOTH the ingest's
// allocated asset id and the content digest have settled, upsert the
// library entry keyed by digest — same bytes re-imported refresh the same
// entry (`addedAt` bumped, `assetId` advanced). The library allocates its
// OWN pool entry from the file bytes and stores that id, so the entry
// stays resolvable after this selection's pool entry is released (N1,
// RFC §5 root model). The upsert must NOT fire for a material whose
// ingest was already abandoned — removed from the selection (the source
// file handle is gone) or released at prep-timeout — so it is gated on
// the same settlement as before AND on the abandoned set. Best-effort by
// contract: a KV failure or a failed library allocation never fails the
// upload.
void Promise.all([ingest, contentDigest]).then(([, digest]) => {
if (!digest) return;
if (abandonedMaterialIngestIdsRef.current.has(addition.id)) return;
void upsertMaterialLibraryEntry({
file: addition.file,
contentDigest: digest,
name: addition.name,
mimeType:
normalizeDocumentMimeType({
mimeType: addition.file.type,
fileName: addition.file.name,
}) || undefined,
size: addition.size,
}).catch((error) => {
log.error(`Failed to record the material library entry for "${addition.name}":`, error);
});
});
};
const addCourseMaterials = (files: File[]) => {
// The set is frozen for the duration of generate-prep: adding is inert
// while `preparingGenerate` is set (the toolbar affordance is disabled
// via the same state), so nothing can slip into the set mid-prep.
if (preparingGenerate) return;
// Compute the additions before touching state: the ingest loop must not
// run inside the setForm updater, which React may invoke more than once —
// each replay would putAsset again and orphan an allocated id.
const dedupedFiles = dedupeCourseMaterialFiles(form.courseMaterials, files);
const startOrder = form.courseMaterials.length + 1;
const additions = dedupedFiles.map((file, index) => ({
id: nanoid(8),
file,
name: file.name,
size: file.size,
lastModified: file.lastModified,
type: file.type,
order: startOrder + index,
}));
for (const addition of additions) {
ingestCourseMaterialIntoPool(addition);
}
if (additions.length === 0) return;
setForm((prev) => {
// Pure updater: drop any addition the latest state already carries — by
// id (a replayed or superseded update) or by content fingerprint (two
// addCourseMaterials calls in one render batch both dedupe against the
// same stale closure list, so the same file could otherwise enter twice
// under two ids and ingest/extract twice) — then append the rest.
const missing = additions.filter((addition) => {
if (prev.courseMaterials.some((item) => item.id === addition.id)) return false;
return !prev.courseMaterials.some(
(item) => courseMaterialFingerprint(item) === courseMaterialFingerprint(addition),
);
});
if (missing.length === 0) return prev;
return { ...prev, courseMaterials: [...prev.courseMaterials, ...missing] };
});
};
const removeCourseMaterial = (id: string) => {
// The set is frozen for the duration of generate-prep: removing is inert
// while `preparingGenerate` is set (the toolbar affordance is disabled
// via the same state), so nothing can slip out of the set mid-prep.
if (preparingGenerate) return;
const removed = form.courseMaterials.find((item) => item.id === id);
// The source file handle is gone: the material-library upsert must not
// fire for this id once its ingest settles (N1). Mark it synchronously so
// the settlement closure — which runs after this handler returns — skips
// minting an entry for bytes the user deliberately discarded.
abandonedMaterialIngestIdsRef.current.add(id);
// Release the pool entry allocated for this file, mirroring the blob-stash
// cleanup: if the ingest is still in flight, release once it lands.
const release = removed?.assetId
? Promise.resolve(removed.assetId)
: (pendingMaterialIngestsRef.current.get(id) ?? Promise.resolve(undefined));
void release
.then((assetId) => {
if (assetId) return removeAsset(assetId);
})
.catch((error) => {
log.error('Failed to release course material asset pool entry:', error);
})
.finally(() => {
// This removal path is the sole consumer of the pending entry: the
// pool entry has been released (or the ingest itself failed, leaving
// nothing to release), so the entry can go. Deleting it here — rather
// than in the ingest's own settlement — keeps it retrievable until
// the id has a durable holder.
pendingMaterialIngestsRef.current.delete(id);
// The content digest has no release to perform; drop its pending entry
// so a removed material cannot leak it into a later session build.
pendingMaterialDigestsRef.current.delete(id);
});
setForm((prev) => ({
...prev,
courseMaterials: prev.courseMaterials
.filter((item) => item.id !== id)
.map((item, index) => ({ ...item, order: index + 1 })),
}));
};
const handleGenerate = async () => {
// No model/provider guard here: generation is gated by `canGenerate`
// (requires a usable provider), and under the #580 invariant a usable
// provider always has a concrete model. State A (no usable provider)
// surfaces through the toolbar's single Configure-Provider affordance.
if (preparingGenerate) return;
if (!form.requirement.trim()) {
setError(t('upload.requirementRequired'));
return;
}
setError(null);
// The material list and the extractor provider config are frozen for the
// duration of prep: `preparingGenerate` makes add/remove inert and
// disables the toolbar affordances (including the extractor Select and the
// web-search toggle), so neither can change under the session build below.
// Capture both at click time and build the session from this snapshot,
// never from live form state or live store state.
const frozenMaterials = [...form.courseMaterials].sort((a, b) => a.order - b.order);
const settingsSnapshot = useSettingsStore.getState();
const frozenPdfProviderId = settingsSnapshot.pdfProviderId;
const frozenPdfProviderConfig = settingsSnapshot.pdfProvidersConfig?.[
settingsSnapshot.pdfProviderId
]
? {
apiKey: settingsSnapshot.pdfProvidersConfig[settingsSnapshot.pdfProviderId].apiKey,
baseUrl: settingsSnapshot.pdfProvidersConfig[settingsSnapshot.pdfProviderId].baseUrl,
accessKeyId:
settingsSnapshot.pdfProvidersConfig[settingsSnapshot.pdfProviderId].accessKeyId,
accessKeySecret:
settingsSnapshot.pdfProvidersConfig[settingsSnapshot.pdfProviderId].accessKeySecret,
}
: undefined;
// Flip the generating UI state BEFORE the drain so the click visibly does
// something even when an ingest stalls.
setPreparingGenerate(true);
const unsettledIngestIds = new Set<string>();
try {
// Drain any in-flight ingests before building the session, so a resolved
// asset id lands in the session instead of being dropped when this page
// unmounts. The await is bounded (~15 s): on timeout, the unsettled
// sources proceed with their storageKey and the legacy byte path, and
// each late-resolving id is released since no durable holder will ever
// exist for it. A rejected ingest is fine: that source proceeds with its
// storageKey and the byte path. The drain loops until the pending map is
// stable — despite the freeze guard, an add that slipped in before the
// flag took effect is still drained (belt-and-braces).
const awaitedIngestIds = new Set<string>();
for (;;) {
const unawaited = [...pendingMaterialIngestsRef.current.entries()].filter(
([id]) => !awaitedIngestIds.has(id),
);
if (unawaited.length === 0) break;
for (const [id] of unawaited) awaitedIngestIds.add(id);
const batchUnsettled = await awaitPendingIngests(new Map(unawaited), {
timeoutMs: DEFAULT_INGEST_AWAIT_TIMEOUT_MS,
onUnsettled: (ingestId, ingest) => {
unsettledIngestIds.add(ingestId);
// The ingest was abandoned: no durable holder will ever exist for
// its id, so release it when it lands — and mark it abandoned so
// the material-library upsert skips minting an entry for bytes
// the session deliberately discarded (N1).
abandonedMaterialIngestIdsRef.current.add(ingestId);
void ingest
.then((assetId) => (assetId ? removeAsset(assetId) : undefined))
.catch((error) => {
log.error('Failed to release timed-out course material asset pool entry:', error);
});
},
});
for (const id of batchUnsettled) unsettledIngestIds.add(id);
}
const userProfile = useUserProfileStore.getState();
const requirements: UserRequirements = {
requirement: form.requirement,
userNickname: userProfile.nickname || undefined,
userBio: userProfile.bio || undefined,
webSearch: form.webSearch || undefined,
interactiveMode: form.vocationalTestMode ? true : form.interactiveMode,
...(form.vocationalTestMode ? { taskEngineMode: true } : {}),
};
let documentSources: SessionDocumentSource[] | undefined;
let pdfProviderId: string | undefined;
let pdfProviderConfig:
| { apiKey?: string; baseUrl?: string; accessKeyId?: string; accessKeySecret?: string }
| undefined;
if (frozenMaterials.length > 0) {
// The session is built from the click-time snapshot (frozen above),
// never from live store state.
pdfProviderId = frozenPdfProviderId;
pdfProviderConfig = frozenPdfProviderConfig;
const storedDocumentKeys: string[] = [];
try {
documentSources = [];
for (const [index, item] of frozenMaterials.entries()) {
const storageKey = await storeDocumentBlob(item.file);
storedDocumentKeys.push(storageKey);
// The awaited ingests patched their asset ids into form state via
// setForm, which this closure may not reflect yet; read the settled
// value from the pending map so a just-resolved id still lands in
// the session regardless of React commit timing. A timed-out ingest
// is never awaited again — its source goes byte-path (no assetId).
const settledAssetId = unsettledIngestIds.has(item.id)
? undefined
: await resolvedAssetIdForIngest(pendingMaterialIngestsRef.current, item.id);
// Same for the content digest: read the settled value (undefined
// when the computation failed or never started) so the extraction
// cache key is available in the session even before the form patch
// commits. Digest is local and fast, so no time budget is needed.
const settledContentDigest = await pendingMaterialDigestsRef.current.get(item.id);
documentSources.push({
id: item.id,
name: item.name,
size: item.size,
lastModified: item.lastModified,
mimeType: normalizeDocumentMimeType({
mimeType: item.file.type,
fileName: item.file.name,
}),
order: index + 1,
storageKey,
// The asset id was allocated at upload; new sessions carry it so
// a server-backed pool can extract by id instead of re-uploading.
assetId: item.assetId ?? settledAssetId,
// Content identity of the bytes: the stable half of the
// extraction-cache key (RFC #1153 part 1).
contentDigest: item.contentDigest ?? settledContentDigest,
providerId: pdfProviderId,
});
}
} catch (error) {
await Promise.allSettled(storedDocumentKeys.map((key) => deleteDocumentBlob(key)));
throw error;
}
}
const sessionState = {
sessionId: nanoid(),
requirements,
pdfText: '',
pdfImages: [],
imageStorageIds: [],
documentSources,
// Backward-compatible single-document fields for previously saved sessions.
pdfStorageKey: documentSources?.[0]?.storageKey,
pdfFileName: documentSources?.[0]?.name,
documentMimeType: documentSources?.[0]?.mimeType,
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'));
} finally {
// Unfreeze the set once prep settles (navigation unmounts this page, so
// this is normally a no-op on the way out).
setPreparingGenerate(false);
}
};
const formatDate = (timestamp: number) => {
const date = new Date(timestamp);
const now = new Date();
const diffTime = Math.abs(now.getTime() - date.getTime());
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) return t('classroom.today');
if (diffDays === 1) return t('classroom.yesterday');
if (diffDays < 7) return `${diffDays} ${t('classroom.daysAgo')}`;
return date.toLocaleDateString();
};
const canGenerate = !!form.requirement.trim() && hasUsableProvider;
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
if (canGenerate && !preparingGenerate) 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"
/>
{PPTX_IMPORT_ENABLED && (
<input
ref={pptxFileInputRef}
type="file"
accept=".pptx,application/vnd.openxmlformats-officedocument.presentationml.presentation"
onChange={handlePptxFileChange}
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 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
webSearch={form.webSearch}
onWebSearchChange={(v) => updateForm('webSearch', v)}
onSettingsOpen={(section) => {
setSettingsSection(section);
setSettingsOpen(true);
}}
courseMaterials={form.courseMaterials}
onCourseMaterialsAdd={addCourseMaterials}
onCourseMaterialRemove={removeCourseMaterial}
onPdfError={setError}
materialsLocked={preparingGenerate}
/>
</div>
{/* Interactive mode toggle */}
<Tooltip>
<TooltipTrigger asChild>
<InteractiveModeButton
pressed={form.interactiveMode}
label={t('toolbar.interactiveModeLabel')}
onPressedChange={(pressed) => updateForm('interactiveMode', pressed)}
/>
</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 || preparingGenerate}
className={cn(
'shrink-0 h-8 rounded-lg flex items-center justify-center gap-1.5 transition-all px-3',
canGenerate && !preparingGenerate
? '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">
{preparingGenerate ? t('stage.generating') : t('toolbar.enterClassroom')}
</span>
{preparingGenerate ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<ArrowUp className="size-3.5" />
)}
</button>
</div>
</div>
</motion.div>
{showVocationalTestUi && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mt-2 flex w-full justify-start px-1"
>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
role="switch"
aria-checked={form.vocationalTestMode}
onClick={() => updateForm('vocationalTestMode', !form.vocationalTestMode)}
className={cn(
'inline-flex h-7 items-center gap-2 rounded-full border px-2.5 text-[11px] font-medium transition-colors',
form.vocationalTestMode
? 'border-cyan-400/70 bg-cyan-50 text-cyan-700 shadow-[0_0_10px_rgba(6,182,212,0.16)] dark:bg-cyan-950/40 dark:text-cyan-300'
: 'border-border/70 bg-background/70 text-muted-foreground hover:border-cyan-300/60 hover:text-cyan-700 dark:hover:text-cyan-300',
)}
>
<span className="rounded-full bg-cyan-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-normal text-cyan-700 dark:bg-cyan-900/45 dark:text-cyan-300">
测试功能
</span>
<Sparkles className="size-3.5" />
<span>职教任务</span>
<span
className={cn(
'relative h-3.5 w-6 rounded-full transition-colors',
form.vocationalTestMode ? 'bg-cyan-500' : 'bg-muted-foreground/25',
)}
>
<span
className={cn(
'absolute left-0.5 top-0.5 size-2.5 rounded-full bg-white transition-transform',
form.vocationalTestMode ? 'translate-x-2.5' : 'translate-x-0',
)}
/>
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
从当前输入框提交职教实操训练测试
</TooltipContent>
</Tooltip>
</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 ═══ */}
{/* The library action bar is always present after hydration: it carries
the New-folder / import / search actions, so a brand-new user with
zero courses and zero folders can still create the first folder or
import. One stable action surface across root, folder, and empty. */}
{hydrated && (
<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. Fixed height keeps the
bar geometrically stable when the New-folder action or the folder
path appears/disappears (entering vs leaving a folder). */}
<div className="group w-full flex items-center gap-4 h-9">
<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={() => {
if (currentFolderId) setCurrentFolderId(undefined);
else persistRecentOpen(!recentOpen);
}}
className="flex items-center gap-2 hover:text-foreground/70 transition-colors cursor-pointer"
>
<Clock className="size-3.5" />
{t('classroom.recentClassrooms')}
{currentFolder && (
<>
<ChevronRight className="size-3 opacity-40" />
<span className="text-foreground/80 truncate max-w-[160px]">
{currentFolder.name}
</span>
</>
)}
<span className="text-[11px] tabular-nums opacity-60">
{currentFolder ? currentFolderClassrooms.length : 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={triggerImport}
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>
{PPTX_IMPORT_ENABLED && (
<button
onClick={triggerPptxFileSelect}
disabled={pptxImporting}
className="group/import-pptx 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"
>
<Presentation className="size-3" />
<span className="overflow-hidden opacity-0 group-hover/import-pptx:opacity-100 transition-opacity duration-200 whitespace-nowrap">
{t('import.pptx')}
</span>
</button>
)}
{/* New folder — round icon button, matches the import/upload affordances. */}
{!currentFolderId && !isSearching && (
<button
type="button"
onClick={() => {
if (!recentOpen) persistRecentOpen(true);
setNewFolderOpen(true);
}}
aria-label={t('classroom.newFolderTitle')}
title={t('classroom.newFolderTitle')}
className="inline-flex items-center justify-center size-7 rounded-full bg-muted/40 text-muted-foreground ring-1 ring-border/50 hover:bg-muted hover:text-foreground hover:ring-border transition-[background-color,color,box-shadow] cursor-pointer"
>
<FolderPlus className="size-3.5" />
</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"
>
{folders.length === 0 && classrooms.length === 0 ? (
<div className="pt-8 pb-2 text-center text-[13px] text-muted-foreground/60">
{t('classroom.emptyLibraryHint')}
</div>
) : !isSearching && currentFolderId && currentFolderClassrooms.length === 0 ? (
// Empty folder: hint directly below the centered path bar.
<div className="pt-8 text-center">
<p className="text-[14px] text-muted-foreground">
{t('classroom.emptyFolderHint')}
</p>
</div>
) : isSearching && 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">
{/* Breadcrumb — shown only while searching (the folder path
already lives in the centered header above). */}
{isSearching && (
<div className="mb-4 flex items-center gap-1.5 text-[13px] text-muted-foreground">
<button
type="button"
onClick={() => {
setCurrentFolderId(undefined);
setSearchQuery('');
setSearchOpen(false);
}}
className="hover:text-foreground transition-colors"
>
{t('classroom.recentClassrooms')}
</button>
<ChevronRight className="size-3.5" />
<span className="text-foreground font-medium">
{t('classroom.searchResults')}
</span>
<span className="ml-1.5 text-[12px] text-muted-foreground tabular-nums">
({filteredClassrooms.length})
</span>
</div>
)}
<AnimatePresence mode="wait">
<motion.div
key={
isSearching
? 'search'
: currentFolderId
? `folder-${currentFolderId}`
: 'root'
}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2 }}
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-5 gap-y-8"
>
{/* Root + non-search: render folder tiles first. */}
{!isSearching &&
currentFolderId === undefined &&
folders.map((folder, i) => (
<motion.div
key={folder.id}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04, duration: 0.35, ease: 'easeOut' }}
>
<FolderCard
folder={folder}
courseCount={courseCountByFolder.get(folder.id) ?? 0}
coverSlides={coverSlidesByFolder.get(folder.id) ?? []}
onOpen={() => setCurrentFolderId(folder.id)}
onRename={handleRenameFolder(folder)}
onDelete={(mode) => confirmDeleteFolder(folder, mode)}
onDropCourse={(stageId) => handleMoveCourse(stageId, folder.id)}
/>
</motion.div>
))}
{/* Course tiles for the active view. */}
{visibleClassrooms.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}`)}
overlay={
<>
<MoveToFolderMenu
folders={folders}
currentFolderId={classroom.folderId}
onMove={(folderId) => handleMoveCourse(classroom.id, folderId)}
onCreateAndMove={handleCreateAndMove(classroom.id)}
/>
{/* Search view: show the owning folder as a badge. */}
{isSearching && classroom.folderId && (
<span className="absolute bottom-2 left-2 z-10 inline-flex items-center gap-1 rounded-md bg-violet-500/80 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm pointer-events-none">
<Folder className="size-2.5" />
{folderNameById.get(classroom.folderId) ?? ''}
</span>
)}
</>
}
/>
</motion.div>
))}
</motion.div>
</AnimatePresence>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
{/* Folder dialogs — mounted at the top level so they are reachable even
while the Recent section is collapsed or the course list is empty. */}
<NewFolderDialog
open={newFolderOpen}
onOpenChange={(open) => {
setNewFolderOpen(open);
if (!open) setCreateAndMoveTarget(null);
}}
folders={folders}
onCreate={handleCreateFolder}
/>
{/* 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,
overlay,
onDelete,
onRename,
confirmingDelete,
onConfirmDelete,
onCancelDelete,
onClick,
}: {
classroom: StageListItem;
slide?: Slide;
formatDate: (ts: number) => string;
/** Extra absolutely-positioned layers over the thumbnail (move menu, badges). */
overlay?: React.ReactNode;
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 isTaskEngineMode = classroom.taskEngineMode === true;
const showModeBadge = classroom.interactiveMode || isTaskEngineMode;
const ModeBadgeIcon = isTaskEngineMode ? Sparkles : Atom;
const modeBadgeLabel = isTaskEngineMode ? 'Vocational Mode' : t('toolbar.interactiveModeLabel');
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}
draggable={!confirmingDelete && !editing}
onDragStart={(e) => {
e.dataTransfer.setData('text/stage-id', classroom.id);
e.dataTransfer.effectAllowed = 'move';
}}
onDragEnd={() => {
// Notify folder cards to clear any lingering drop highlight (Escape-
// cancelled drags may not fire dragleave on every target).
window.dispatchEvent(new CustomEvent('course-drag-end'));
}}
>
{/* 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 ? (
<SlideThumbnail
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}
{showModeBadge && (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label={modeBadgeLabel}
onClick={(e) => e.stopPropagation()}
className={cn(
'absolute bottom-2 left-2 inline-flex items-center justify-center size-5 rounded-full bg-white/70 dark:bg-slate-900/60 backdrop-blur-sm shadow-sm z-10',
isTaskEngineMode
? 'text-amber-600 dark:text-amber-300 ring-1 ring-amber-500/35'
: 'text-cyan-600 dark:text-cyan-300 ring-1 ring-cyan-500/30',
)}
>
<ModeBadgeIcon 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"
>
{modeBadgeLabel}
</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>
{overlay}
</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 />;
}