"use client"; import Link from "next/link"; import { motion } from "framer-motion"; import { useState } from "react"; import { ApiError, generateDeck } from "@/lib/client-api"; import { activityTypes, materialiseActivities } from "@/lib/deck-ops"; import { createFolder as makeFolder, listFolders } from "@/lib/folders"; import { haptic } from "@/lib/haptics"; import { useI18n } from "@/lib/i18n"; import { springGentle } from "@/lib/motion"; import { UNORGANIZED_ID, type Deck, type Folder } from "@/lib/schemas"; import { shareDeck } from "@/lib/share"; import { appendActivities, deleteDeck, deleteNotesText, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage"; import { showToast } from "@/lib/toast"; import { useBackClose } from "@/lib/use-back-close"; import { useIsPhone } from "@/lib/use-phone"; import { cn, formatDate, truncate } from "@/lib/utils"; import { ChatIcon, ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon, ShareIcon, TeacherIcon, XCircleIcon } from "@/components/icons"; import { Button, ProgressBar } from "@/components/ui"; type Busy = null | "more" | "regenerate"; const ADD_OPTIONS = [3, 5, 8]; /** * One deck in the dashboard grid: title, muted metadata, tags, summary and a * prominent Study CTA with quiet secondary actions. */ export function DeckCard({ deck, folders: foldersProp, onMove, index = 0, }: { deck: Deck; folders?: Folder[]; onMove?: (folderId: string | undefined) => void; /** Position in the list — used for the capped first-load stagger. */ index?: number; }) { const { t, plural } = useI18n(); const isPhone = useIsPhone(); const folders = foldersProp ?? listFolders(); const [busy, setBusy] = useState(null); const [menuOpen, setMenuOpen] = useState(false); const [renaming, setRenaming] = useState(false); const [title, setTitle] = useState(deck.title); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [addCount, setAddCount] = useState(5); const [newFolder, setNewFolder] = useState(null); const [expanded, setExpanded] = useState(false); /* System Back closes the actions panel first. */ useBackClose(isPhone && menuOpen, () => setMenuOpen(false)); const hasSource = deck.sourceText.trim().length > 20; const saveTitle = () => { const trimmed = title.trim(); if (trimmed && trimmed !== deck.title) renameDeck(deck.id, trimmed); else setTitle(deck.title); setRenaming(false); setMenuOpen(false); }; const addMore = async () => { if (!hasSource) { setError(t("deck.noSourceExtend")); return; } setBusy("more"); setError(null); try { const { deck: generated } = await generateDeck({ text: deck.sourceText, difficulty: deck.difficulty, language: deck.language, count: addCount, mode: "more", existing: { title: deck.title, types: activityTypes(deck.activities), count: deck.activities.length }, }); appendActivities(deck.id, materialiseActivities(generated.activities, deck.difficulty)); setNotice(plural(generated.activities.length, "deck.added")); setMenuOpen(false); window.setTimeout(() => setNotice(null), 3000); } catch (addError) { setError(addError instanceof ApiError ? addError.message : t("deck.couldNotAdd")); } finally { setBusy(null); } }; const regenerate = async () => { if (!hasSource) { setError(t("deck.noSourceRegenerate")); return; } setBusy("regenerate"); setError(null); try { const { deck: generated } = await generateDeck({ text: deck.sourceText, difficulty: deck.difficulty, language: deck.language, count: Math.max(8, deck.activities.length), }); upsertDeck({ ...deck, title: generated.title || deck.title, subject: generated.subject ?? deck.subject, summary: generated.summary ?? deck.summary, language: generated.language ?? deck.language, activities: materialiseActivities(generated.activities, deck.difficulty), attempts: 0, bestScore: undefined, updatedAt: Date.now(), }); setNotice(t("deck.regenerated")); setMenuOpen(false); window.setTimeout(() => setNotice(null), 3000); } catch (regenerateError) { setError(regenerateError instanceof ApiError ? regenerateError.message : t("deck.couldNotRegenerate")); } finally { setBusy(null); } }; const menuRow = "flex w-full items-center gap-2.5 rounded-xl px-3 py-2 text-left text-[13px] font-medium text-[var(--text)] transition hover:bg-[var(--hover)]"; /* Phones: cards fade up in a short capped stagger, glide when the grid reflows (folder moves, filtering) and shrink away on delete. */ const cardVariants = { hidden: { opacity: 0, y: 12 }, show: { opacity: 1, y: 0, transition: { ...springGentle, delay: Math.min(index, 8) * 0.04 } }, exit: { opacity: 0, scale: 0.97, transition: { duration: 0.16 } }, }; return ( {/* Phones: the whole card is tappable too; interactive children sit above it. */} {/* header ------------------------------------------------------- */}
{renaming ? (
setTitle(event.target.value)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") saveTitle(); if (event.key === "Escape") { setTitle(deck.title); setRenaming(false); } }} className="min-w-0 flex-1 rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-1.5 text-[15px] font-semibold text-[var(--text)] outline-none focus:border-accent-400" />
) : (

{truncate(deck.title, 90)}

)} {/* Phones: one compact meta line. */}

{plural(deck.activities.length, "deck.activities")} · {deck.difficulty === "easy" ? t("upload.easy") : deck.difficulty === "hard" ? t("upload.hard") : t("upload.medium")} {deck.bestScore !== undefined ? ( <> · {t("deck.bestLabel")} {deck.bestScore}% ) : null}

{/* Phones keep the original date · activities · runs line. */} {formatDate(deck.updatedAt)} · {plural(deck.activities.length, "deck.activities")} {deck.attempts > 0 ? ( <> · {plural(deck.attempts, "deck.runs")} ) : null} {/* Desktop always shows the same two fields. */} {deck.attempts > 0 ? ( <> · {plural(deck.attempts, "deck.runs")} ) : ( {t("deck.new")} )}

{/* tags --------------------------------------------------------- */}
{deck.subject ? {deck.subject} : null} {deck.difficulty === "easy" ? t("upload.easy") : deck.difficulty === "hard" ? t("upload.hard") : t("upload.medium")} {deck.folderId ? ( {folders.find((entry) => entry.id === deck.folderId)?.name ?? t("common.unorganized")} ) : null} {deck.bestScore !== undefined ? ( <> {t("deck.bestLabel")}{" "} {deck.bestScore}% {t("deck.bestLabel")} {deck.bestScore}% ) : null}
{/* summary ------------------------------------------------------ */} {deck.summary ? ( <>

{truncate(deck.summary, 160)}

) : null} {/* status ------------------------------------------------------- */} {busy ? (

{busy === "more" ? t("deck.addingNew") : t("deck.regeneratingWhole")}

) : null} {notice ? (

{notice}

) : null} {error ? (

{error}

) : null} {/* single action ------------------------------------------------ */}
{t("deck.study")} {t("deck.study")}
{t("deck.study")}
{/* actions panel ------------------------------------------------ */} {menuOpen ? (
{t("chat.askAboutDeck")} {t("teach.nav")} {hasSource ? ( ) : null} {/* Deleting is reversible: no confirm, just an Undo toast. */} {/* folder ---------------------------------------------------- */}

{t("common.folder")}

{[{ id: UNORGANIZED_ID, name: t("common.unorganized") }, ...folders].map((folder) => { const current = (deck.folderId ?? UNORGANIZED_ID) === folder.id; return ( ); })} {newFolder === null ? ( ) : ( setNewFolder(event.target.value)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Escape") setNewFolder(null); if (event.key === "Enter" && newFolder.trim()) { const created = makeFolder(newFolder); if ("folder" in created) { onMove?.(created.folder.id); setNewFolder(null); setMenuOpen(false); } else { setError(created.error); } } }} aria-label={t("common.newFolderName")} placeholder={t("common.folderName")} maxLength={60} className="w-24 bg-transparent text-[12px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)]" /> )}
{/* add more -------------------------------------------------- */}

{t("deck.addMore")}

{ADD_OPTIONS.map((option) => ( ))}
{!hasSource ? (

{t("deck.noSource")}

) : null}
) : null}
); } function difficultyClass(difficulty: Deck["difficulty"]): string { if (difficulty === "easy") return "diff-easy"; if (difficulty === "hard") return "diff-hard"; return "diff-medium"; } /** Neutral pill; colour is only ever used on the text inside it. */ function Tag({ children, className }: { children: React.ReactNode; className?: string }) { return ( {children} ); }