mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-30 07:35:16 +08:00
* 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>
2049 lines
88 KiB
TypeScript
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 />;
|
|
}
|