Files
openmaic/OpenMAIC/components/discovery/folder-card.tsx
2026-08-16 14:58:47 +08:00

435 lines
17 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { AlertTriangle, Folder, Pencil, Trash2 } from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { cn } from '@/lib/utils';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { SlideThumbnail } from '@/components/slide-renderer/SlideThumbnail';
import type { Slide } from '@openmaic/dsl';
import type { FolderRecord } from '@/lib/utils/database';
import type { DeleteFolderMode } from '@/lib/utils/stage-storage';
/** Maximum number of course covers stacked on a folder tile. */
const MAX_COVERS = 3;
/**
* Folder card. Same visual footprint as a ClassroomCard (16:9 tile + title
* row). The tile shows up to {@link MAX_COVERS} member course covers stacked
* with a slight offset; an empty folder falls back to a centered folder icon.
*
* Hover shows rename / delete buttons matching the course-card ✏️/🗑️. The tile
* is also a drop target: dragging a course card onto it files the course into
* this folder (the hover 📂 menu remains as the accessible fallback).
*/
export function FolderCard({
folder,
courseCount,
coverSlides,
onOpen,
onRename,
onDelete,
onDropCourse,
}: {
folder: FolderRecord;
courseCount: number;
/** Up to {@link MAX_COVERS} first-slide thumbnails of the member courses. */
coverSlides: Slide[];
onOpen: () => void;
onRename: (newName: string) => Promise<string | null>;
/** Delete the folder with the chosen mode. */
onDelete: (mode: DeleteFolderMode) => void;
/** Files the dragged course (its stage id is in the payload) into this folder. */
onDropCourse: (stageId: string) => void;
}) {
const { t } = useI18n();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(folder.name);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [dropActive, setDropActive] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const dragDepth = useRef(0);
const inputRef = useRef<HTMLInputElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);
const [thumbWidth, setThumbWidth] = useState(0);
// Clear lingering drop highlight when a course drag ends (covers Escape-
// cancelled drags that may not fire dragleave on every target).
useEffect(() => {
const handler = () => {
dragDepth.current = 0;
setDropActive(false);
};
window.addEventListener('course-drag-end', handler);
return () => window.removeEventListener('course-drag-end', handler);
}, []);
const startEditing = () => {
setDraft(folder.name);
setError(null);
setEditing(true);
requestAnimationFrame(() => inputRef.current?.focus());
};
const shake = () => {
inputRef.current?.animate(
[
{ transform: 'translateX(0)' },
{ transform: 'translateX(-4px)' },
{ transform: 'translateX(4px)' },
{ transform: 'translateX(0)' },
],
{ duration: 200 },
);
};
const commit = async () => {
if (!editing || submitting) return;
const trimmed = draft.trim();
if (trimmed === folder.name) {
setEditing(false);
return;
}
// Empty name: show an error and keep editing (do not silently exit).
if (!trimmed) {
setError(t('classroom.folderNameEmpty'));
shake();
return;
}
setSubmitting(true);
const err = await onRename(trimmed);
setSubmitting(false);
if (err) {
setError(err);
shake();
return;
}
setEditing(false);
};
const covers = coverSlides.slice(0, MAX_COVERS);
// Emptiness is derived from courseCount (the authoritative source), not from
// covers.length — a non-empty folder whose courses have no cached thumbnails
// must not show the empty-folder icon. `hasCovers` gates the cover stack.
const hasCovers = covers.length > 0;
return (
<div
className="group cursor-pointer"
onClick={editing ? undefined : onOpen}
onDragEnter={(e) => {
if (editing) return;
// Only react to course-card drags (carrying a stage-id payload).
if (!Array.from(e.dataTransfer.types).includes('text/stage-id')) return;
dragDepth.current += 1;
setDropActive(true);
}}
onDragOver={(e) => {
if (editing) return;
if (!Array.from(e.dataTransfer.types).includes('text/stage-id')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}}
onDragLeave={() => {
// dragenter/dragleave fire per child element; balance them with a
// counter so the highlight clears only when the pointer fully exits.
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDropActive(false);
}}
onDrop={(e) => {
e.preventDefault();
dragDepth.current = 0;
setDropActive(false);
const stageId = e.dataTransfer.getData('text/stage-id');
if (stageId) onDropCourse(stageId);
}}
>
<div
ref={thumbRef}
className={cn(
'relative w-full aspect-[16/9] rounded-2xl bg-gradient-to-br from-violet-50 to-blue-50 dark:from-violet-900/20 dark:to-blue-900/20 overflow-hidden transition-transform duration-200 group-hover:scale-[1.02] ring-1',
dropActive
? 'ring-2 ring-violet-500 ring-offset-2 ring-offset-background scale-[1.03]'
: 'ring-violet-200/50 dark:ring-violet-800/40',
)}
>
{hasCovers ? (
<CoverStack covers={covers} thumbWidth={thumbWidth} setThumbWidth={setThumbWidth} />
) : courseCount === 0 ? (
// Truly empty folder: folder icon.
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<div className="size-14 rounded-2xl bg-violet-100 dark:bg-violet-900/40 flex items-center justify-center">
<Folder className="size-7 text-violet-500 dark:text-violet-300" />
</div>
</div>
) : (
// Non-empty but no cached covers: a neutral stacked-card placeholder
// distinct from the empty-folder icon, so it does not read as empty.
<div className="absolute inset-0 flex items-center justify-center">
<div
className="w-[60%] aspect-[16/9] rounded-xl bg-violet-200/60 dark:bg-violet-800/30 shadow-md ring-1 ring-violet-300/30 dark:ring-violet-700/30"
style={{ transform: 'translate(-3%, 2%)' }}
/>
<div
className="absolute w-[60%] aspect-[16/9] rounded-xl bg-violet-300/50 dark:bg-violet-700/20 shadow-md ring-1 ring-violet-300/30 dark:ring-violet-700/30"
style={{ transform: 'translate(3%, -1%)' }}
/>
</div>
)}
{/* Course count badge — always visible (bottom-right). */}
<span className="absolute bottom-2 right-2 z-10 inline-flex items-center rounded-full bg-black/40 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
{courseCount} {t('classroom.folderCourseCountUnit')}
</span>
{dropActive && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-violet-500/20 backdrop-blur-[2px]">
<Folder className="size-8 text-white drop-shadow" />
</div>
)}
{/* Hover actions — hidden while a delete confirmation is shown. */}
{!confirmingDelete && (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{/* Delete — empty folder: inline confirm; non-empty: dropdown menu. */}
{courseCount === 0 ? (
<button
type="button"
aria-label={t('classroom.delete')}
onClick={(e) => {
e.stopPropagation();
setConfirmingDelete(true);
}}
className="absolute top-2 right-2 size-7 inline-flex items-center justify-center rounded-full bg-black/30 hover:bg-destructive/80 text-white hover:text-white backdrop-blur-sm opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
<Trash2 className="size-3.5" />
</button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('classroom.delete')}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
className="absolute top-2 right-2 size-7 inline-flex items-center justify-center rounded-full bg-black/30 hover:bg-destructive/80 text-white hover:text-white backdrop-blur-sm opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
<Trash2 className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onClick={(e) => e.stopPropagation()}
className="min-w-[200px]"
>
<DropdownMenuItem
onClick={() => onDelete('ungroup')}
className="gap-2 text-[13px]"
>
<Folder className="size-3.5 shrink-0 mt-0.5" />
<span className="flex flex-col">
<span>{t('classroom.deleteFolderOnly')}</span>
<span className="text-[11px] text-muted-foreground">
{t('classroom.deleteFolderUngroupDesc')}
</span>
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setConfirmingDelete(true)}
className="gap-2 text-[13px] text-destructive focus:text-destructive"
>
<Trash2 className="size-3.5" />
{t('classroom.deleteFolderAndCourses', { count: courseCount })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<button
type="button"
aria-label={t('classroom.rename')}
onClick={(e) => {
e.stopPropagation();
startEditing();
}}
className="absolute top-2 right-11 size-7 inline-flex items-center justify-center rounded-full bg-black/30 hover:bg-black/50 text-white hover:text-white backdrop-blur-sm opacity-0 group-hover:opacity-100 transition-opacity z-10"
>
<Pencil className="size-3.5" />
</button>
</motion.div>
</AnimatePresence>
)}
{/* Inline delete confirmation overlay (empty folder + destructive path). */}
<AnimatePresence>
{confirmingDelete && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-black/50 backdrop-blur-[6px]"
onClick={(e) => e.stopPropagation()}
>
{courseCount > 0 && (
<div className="flex items-center gap-1.5 text-amber-300">
<AlertTriangle className="size-3.5" />
<span className="text-[11px] font-medium">
{t('classroom.deleteFolderConfirmRemove')}
</span>
</div>
)}
<span className="px-4 text-center text-[13px] font-medium text-white/90">
{courseCount === 0
? t('classroom.deleteFolderEmptyDesc')
: t('classroom.deleteFolderTitle', { name: folder.name })}
</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={() => setConfirmingDelete(false)}
>
{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={() => {
// Close the overlay before the async delete: on success the
// card unmounts; on failure the user sees the error toast
// and the card is interactive again (not stuck behind the
// backdrop).
setConfirmingDelete(false);
onDelete(courseCount === 0 ? 'ungroup' : 'remove');
}}
>
{t('classroom.delete')}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<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">
{t('classroom.folderBadge')}
</span>
{editing ? (
<div className="flex-1 min-w-0" onClick={(e) => e.stopPropagation()}>
<input
ref={inputRef}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') setEditing(false);
}}
onBlur={commit}
disabled={submitting}
maxLength={80}
className="w-full bg-transparent border-b border-violet-400/60 text-[15px] font-medium text-foreground/90 outline-none disabled:opacity-50"
/>
{error && <p className="mt-1 text-[11px] text-destructive">{error}</p>}
</div>
) : (
<p className="font-medium text-[15px] truncate text-foreground/90 min-w-0">
{folder.name}
</p>
)}
</div>
</div>
);
}
/**
* Render member course covers as a tidy, slightly-fanned stack: each rear cover
* peeks out from behind the one in front, offset up and to alternating sides,
* with a gentle rotation. Inspired by the iOS Photos / macOS folder cover —
* restrained, geometric, readable. The frontmost cover is the most recently
* updated member.
*/
function CoverStack({
covers,
thumbWidth,
setThumbWidth,
}: {
covers: Slide[];
thumbWidth: number;
setThumbWidth: (w: number) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
setThumbWidth(Math.round(entry.contentRect.width));
});
ro.observe(el);
return () => ro.disconnect();
}, [setThumbWidth]);
const count = covers.length;
// `covers` is ordered most-recent-first; that cover is the front (drawn last,
// on top). Rear covers offset up + alternate sides + small rotation so a sliver
// of each is visible behind the front. Offsets are % of the tile for consistency.
const rear: Array<{ dx: number; dy: number; rot: number }> = [
{ dx: -8, dy: 10, rot: -5 }, // rearmost (left, up, tilted left)
{ dx: 8, dy: 5, rot: 5 }, // mid (right, up a bit, tilted right)
];
return (
<div ref={containerRef} className="absolute inset-0 flex items-center justify-center">
{/* Render back→front: iterate covers in reverse so the front is last (on top). */}
{[...covers].reverse().map((cover, i) => {
const depthFromFront = count - 1 - i; // 0 = frontmost
const isFront = depthFromFront === 0;
const cfg = rear[depthFromFront - 1] ?? rear[rear.length - 1];
const widthPct = isFront ? 66 : 60;
return (
<div
key={i}
className="absolute"
style={{
width: `${widthPct}%`,
transform: isFront
? 'translate(0, 0)'
: `translate(${cfg.dx}%, -${cfg.dy}%) rotate(${cfg.rot}deg)`,
zIndex: i + 1,
opacity: isFront ? 1 : 0.85,
}}
>
{thumbWidth > 0 && (
<div className="aspect-[16/9] w-full rounded-xl overflow-hidden shadow-md ring-1 ring-black/5 bg-slate-200 dark:bg-slate-700">
<SlideThumbnail
slide={cover}
size={Math.round((thumbWidth * widthPct) / 100)}
viewportSize={cover.viewportSize ?? 1000}
viewportRatio={cover.viewportRatio ?? 0.5625}
/>
</div>
)}
</div>
);
})}
</div>
);
}