mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-30 07:35:16 +08:00
* feat(courses): add folder grouping behind a feature flag Group courses into user-created folders on the home page. Folders are device-local organization metadata kept in the existing IndexedDB database (new folders + course-to-folder mapping tables, Dexie v16); the course document aggregate owned by the DocumentStore is untouched. - Create / rename (inline) / delete folders (keep or remove members) - Move a course between folders via a hover menu - Navigate into a folder with a breadcrumb back to all courses - Search flattens the list, annotating each course with its folder - Deleting a course cleans up its folder membership - All UI strings internationalized across 9 locales - Gated behind NEXT_PUBLIC_ENABLE_COURSE_FOLDERS (default OFF) * test(courses): update db mocks and version assertion for folder tables The folder feature adds the `stageFolders` table to the deletion cascade and bumps the Dexie schema to v16. Update the affected test db mocks to include the new table and bump the version assertion accordingly. * fix(courses): stop course click-through when selecting from the move menu The move-to-folder menu is rendered inside the course card's clickable container. Selecting a folder item could let the click event reach the card's onClick (opening the course) because only the trigger button stopped propagation. Stop propagation on pointerdown for the trigger and on click for every menu item so selecting a destination never opens the course. * fix(courses): open a dialog for the move-menu new-folder entry The move-to-folder menu's inline "new folder" input never worked: a Radix DropdownMenu is modal, so a raw <input> inside it cannot keep focus — Radix closes the menu the instant the input is focused, dropping the field before anything can be typed. Move the new-folder entry out of the menu: it now asks the caller to open the existing NewFolderDialog and, on confirm, moves the requesting course into the freshly created folder. * feat(courses): drag-to-file folders and stacked cover thumbnails Per review feedback, make folders feel complete: - Drag a course card onto a folder tile to file it there. The tile turns into a clear drop target (ring + overlay) while a drag is over it. The hover 📂 menu remains as the accessible fallback for keyboard/touch. - Replace the placeholder folder icon with a stable stack of up to three member course covers (most recently updated frontmost). Empty folders keep the folder icon; the name and course count are always visible. * feat(courses): refine folder cover stack to a tidy fanned layout Tune the folder-tile cover stack per review: front cover centered and upright, rear covers peek out from alternating sides with a slight tilt and reduced opacity, soft shadow and hairline ring. Reads as a neat pile of course covers rather than an exaggerated fan. * fix(courses): address review — dialog mount, empty-list, rename validation, partial-delete refresh, breadcrumb dedup Per review (wyuc, CHANGES_REQUESTED): 1. Mount NewFolderDialog/DeleteFolderDialog outside the collapsible Recent subtree so they are reachable while it is collapsed; the New-folder button now expands the section before opening the dialog. 2. Keep the Recent/folder surface alive when the course list is empty so folders remain reachable and a first folder can be created. 3. Enforce folder-name validation (width + uniqueness) on rename as well as create, at the storage boundary (FolderNameError) and in the UI. 4. Refresh authoritative state on folder-delete both success and failure, so a partial "remove" failure does not leave stale cards/counts. 5. Drop the duplicated folder breadcrumb from the top section header; keep a single navigation breadcrumb in the content area. Add focused storage tests covering rename validation, membership writes, both deletion modes, and partial-failure propagation. * fix(courses): address 2nd review — folder-view layout, drop highlight, validation parity, a11y, i18n Per review (wyuc, CHANGES_REQUESTED on9f77686f): P1 — folder view layout: opening a folder compacts the hero and surfaces the library content; the centered header shows a single path "Recent > Folder name"; the empty-folder state sits directly below it; clicking Recent returns to the root grid. The duplicate content breadcrumb is dropped (kept only for search). P2 — correctness & a11y: - Clear the folder drop highlight on every drag exit (dragenter counter) and gate it on the text/stage-id payload. - Duplicate-name check in the dialog is now case-insensitive, matching the storage boundary; FolderNameError is mapped to specific messages. - Renames report the actual submitted width, not a hardcoded 0. - createFolder/renameFolder run read-check-write in one read-write transaction so the uniqueness invariant cannot race across tabs. - The move-menu trigger is focus-visible and pointer-coarse reachable (visible on touch / keyboard), not hover-only. Extra: - Empty library (no courses, no folders) shows a dedicated hint instead of the search-empty string. - Translate all newly added folder strings across ja/ko/pt/ru/ar, fix Simplified-Chinese text in zh-TW, and add the emptyLibraryHint key. * fix(courses): address 3rd review — ungate folders, fix empty-library hero, stabilize the bar Per review (wyuc, CHANGES_REQUESTED on19729038): P1 — ship folders unconditionally: remove NEXT_PUBLIC_ENABLE_COURSE_FOLDERS, the isCourseFoldersEnabled helper, the .env.example entry, and every flag-on/flag-off UI branch. Folder metadata is always loaded; create, move, drag, and folder navigation are always available. The IndexedDB v16 schema stays intact. P1 — full-screen landing hero only when the library is truly empty: the hero uses min-h-[calc(100dvh-8rem)] only when there are zero courses AND zero folders. A `hydrated` flag waits for both async loads before selecting the layout, so folders arriving from storage do not flip the hero from full-screen to compact. P2 — geometrically stable centered bar: the Recent bar gets a fixed height (h-9) so entering/leaving a folder (which toggles the New-folder action and the folder path) does not shift the search/import controls. * fix(courses): single stable library action bar across root, folder, and empty states Per review (wyuc, CHANGES_REQUESTED on860a1170): P2 — remove the duplicate floating import controls. The hero section rendered a second Import Classroom / PPTX cluster whenever the course list was empty, duplicating the Recent bar's actions and floating above it as the hero switched layout modes. Import now lives only in the Recent action bar. P1 — keep folder creation reachable for a truly empty library. The Recent section is now always rendered after hydration (not gated on having courses or folders), so a new user with zero of each can still create the first folder or import. The empty-library hint renders below the single stable action bar. Invariant: one library action bar across root, folder, and empty states; state changes alter the path and enabled actions only. * chore: trigger CI after ready-for-review * fix(courses): inherit folder context when importing from inside a folder Per review (wyuc, CHANGES_REQUESTED on37be4716): [P1] Courses imported from inside a folder were silently placed at the root. The import contract now carries the new stageId to the success callback; the page captures the active folder when the file picker opens (not when the async import resolves) and files the imported course into that folder before the list refresh, so the card appears immediately and the folder count increments. Root imports remain ungrouped. A failed folder assignment surfaces an explicit error toast instead of silently falling back. * fix(courses): stable hero on folder delete, lightweight delete menu, breadcrumb count, empty-name validation Per review (wyuc, CHANGES_REQUESTED on5e12984f) + QA findings: P1 — deleting the last folder must not expand the hero. The full-screen landing hero is a first-visit treatment only: a session-scoped "librarySeen" flag latches true once the library bar renders, so the hero stays compact across all subsequent create/delete transitions. P2 — replace the heavy two-card delete dialog with progressive disclosure. Empty folder: an inline confirmation overlay on the card tile (matching the course-delete pattern), with the empty-folder copy. Non-empty folder: a compact dropdown beside the delete icon — "Delete folder only" (courses move to unfiled) executes directly; "Delete folder and N courses" opens a lightweight destructive confirmation. The full modal is gone. QA fixes: - Breadcrumb count is now contextual (total at root, in-folder count inside a folder) instead of always showing the global total. - Renaming a folder to empty/whitespace shows a "name cannot be empty" error and shakes, instead of silently exiting edit mode. * fix(courses): cross-review cleanup — dead i18n keys, missing translations, delete-overlay reset Post-commit cross-review (leak audit PASS, wyuc 19/19 PASS) found: - Remove 6 dead i18n keys left over from the removed two-card delete dialog (deleteFolderDesc, deleteFolderUngroupTitle, etc.) across all 9 locales. - Translate all remaining English folder strings in ja/ko/pt/ru/ar (newFolderTitle, folderNameLabel, folderCreate, deleteFolderTitle, etc.). - Fix zh-TW: convert ~17 simplified-Chinese folder strings to Traditional (新增資料夾/建立/刪除/etc.). - Delete the orphaned "feature flag" comment (flag was removed earlier). - Close the inline delete-confirm overlay before the async delete, so a failure leaves the card interactive instead of stuck behind the backdrop. * fix(courses): clear drop highlight on drag end, map limit error in dialog Two minor findings from cross-review: - Escape-cancelled drags may not fire dragleave on every folder target, leaving a highlight ring. The course card now dispatches a 'course-drag-end' window event on dragEnd (fires for both normal drop and Escape cancel); folder cards listen and clear their drop state. - FolderNameError kind 'limit' (thrown at the storage boundary when FOLDER_COUNT_LIMIT is reached in a cross-tab race) now maps to the specific folderCountLimit message in the dialog instead of falling through to a generic hint. Also fixes an SSR hydration mismatch: librarySeen is now initialized to false and read from sessionStorage in useEffect (not in the useState initializer). * fix(courses): atomic folder removal, always-compact hero, no-cover placeholder Per review (wyuc, CHANGES_REQUESTED on470f5fea): P2 — close the orphan-membership race in 'remove' mode. deleteFolder now captures members, deletes the folder row, and clears all memberships in ONE transaction BEFORE the course-deletion cascade. The folder is gone from the moment the cascade starts, so a concurrent setStageFolder (which checks existence in its own transaction) rejects the assignment. P2 — remove the first-visit full-screen hero. The librarySeen flag caused a visible layout jump on refresh (SSR renders full-screen, then the effect reads sessionStorage and switches to compact). The hero is now always compact (mt-[10vh]); no sessionStorage, no hydration mismatch, no geometry regression. P3 — distinct no-cover fallback for non-empty folders. A folder with courses but no cached thumbnails now shows a neutral stacked-card placeholder instead of the empty-folder icon. Merged with latest main; no conflicts. --------- Co-authored-by: Percy <percy@PercydeMacBook-Pro.local>
1782 lines
74 KiB
TypeScript
1782 lines
74 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,
|
|
} 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 { deleteDocumentBlob, storeDocumentBlob } from '@/lib/utils/image-storage';
|
|
import { normalizeDocumentMimeType } from '@/lib/document/mime';
|
|
import { dedupeCourseMaterialFiles } from '@/lib/document/course-materials';
|
|
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);
|
|
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>>({});
|
|
|
|
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 */
|
|
}
|
|
};
|
|
|
|
const addCourseMaterials = (files: File[]) => {
|
|
setForm((prev) => {
|
|
const dedupedFiles = dedupeCourseMaterialFiles(prev.courseMaterials, files);
|
|
const startOrder = prev.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,
|
|
}));
|
|
|
|
return additions.length > 0
|
|
? { ...prev, courseMaterials: [...prev.courseMaterials, ...additions] }
|
|
: prev;
|
|
});
|
|
};
|
|
|
|
const removeCourseMaterial = (id: string) => {
|
|
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 (!form.requirement.trim()) {
|
|
setError(t('upload.requirementRequired'));
|
|
return;
|
|
}
|
|
|
|
setError(null);
|
|
|
|
try {
|
|
const userProfile = useUserProfileStore.getState();
|
|
const requirements: UserRequirements = {
|
|
requirement: form.requirement,
|
|
userNickname: userProfile.nickname || undefined,
|
|
userBio: userProfile.bio || undefined,
|
|
webSearch: form.webSearch || undefined,
|
|
interactiveMode: form.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 (form.courseMaterials.length > 0) {
|
|
const settings = useSettingsStore.getState();
|
|
pdfProviderId = settings.pdfProviderId;
|
|
const providerCfg = settings.pdfProvidersConfig?.[settings.pdfProviderId];
|
|
if (providerCfg) {
|
|
pdfProviderConfig = {
|
|
apiKey: providerCfg.apiKey,
|
|
baseUrl: providerCfg.baseUrl,
|
|
accessKeyId: providerCfg.accessKeyId,
|
|
accessKeySecret: providerCfg.accessKeySecret,
|
|
};
|
|
}
|
|
|
|
const storedDocumentKeys: string[] = [];
|
|
try {
|
|
documentSources = [];
|
|
const orderedMaterials = [...form.courseMaterials].sort((a, b) => a.order - b.order);
|
|
for (const [index, item] of orderedMaterials.entries()) {
|
|
const storageKey = await storeDocumentBlob(item.file);
|
|
storedDocumentKeys.push(storageKey);
|
|
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,
|
|
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'));
|
|
}
|
|
};
|
|
|
|
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) 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}
|
|
/>
|
|
</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}
|
|
className={cn(
|
|
'shrink-0 h-8 rounded-lg flex items-center justify-center gap-1.5 transition-all px-3',
|
|
canGenerate
|
|
? 'bg-primary text-primary-foreground hover:opacity-90 shadow-sm cursor-pointer'
|
|
: 'bg-muted text-muted-foreground/40 cursor-not-allowed',
|
|
)}
|
|
>
|
|
<span className="text-xs font-medium">{t('toolbar.enterClassroom')}</span>
|
|
<ArrowUp className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{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 />;
|
|
}
|