'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; /** 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(null); const [submitting, setSubmitting] = useState(false); const [dropActive, setDropActive] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false); const dragDepth = useRef(0); const inputRef = useRef(null); const thumbRef = useRef(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 (
{ 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); }} >
{hasCovers ? ( ) : courseCount === 0 ? ( // Truly empty folder: folder icon.
) : ( // Non-empty but no cached covers: a neutral stacked-card placeholder // distinct from the empty-folder icon, so it does not read as empty.
)} {/* Course count badge โ€” always visible (bottom-right). */} {courseCount} {t('classroom.folderCourseCountUnit')} {dropActive && (
)} {/* Hover actions โ€” hidden while a delete confirmation is shown. */} {!confirmingDelete && ( {/* Delete โ€” empty folder: inline confirm; non-empty: dropdown menu. */} {courseCount === 0 ? ( ) : ( e.stopPropagation()} className="min-w-[200px]" > onDelete('ungroup')} className="gap-2 text-[13px]" > {t('classroom.deleteFolderOnly')} {t('classroom.deleteFolderUngroupDesc')} setConfirmingDelete(true)} className="gap-2 text-[13px] text-destructive focus:text-destructive" > {t('classroom.deleteFolderAndCourses', { count: courseCount })} )} )} {/* Inline delete confirmation overlay (empty folder + destructive path). */} {confirmingDelete && ( e.stopPropagation()} > {courseCount > 0 && (
{t('classroom.deleteFolderConfirmRemove')}
)} {courseCount === 0 ? t('classroom.deleteFolderEmptyDesc') : t('classroom.deleteFolderTitle', { name: folder.name })}
)}
{t('classroom.folderBadge')} {editing ? (
e.stopPropagation()}> { 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 &&

{error}

}
) : (

{folder.name}

)}
); } /** * 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(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 (
{/* 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 (
{thumbWidth > 0 && (
)}
); })}
); }