"use client"; import Link from "next/link"; import { useState } from "react"; import { ApiError, generateDeck } from "@/lib/client-api"; import { activityTypes, materialiseActivities } from "@/lib/deck-ops"; import { createFolder as makeFolder, folderName, listFolders } from "@/lib/folders"; import { UNORGANIZED_ID, type Deck } from "@/lib/schemas"; import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage"; import { cn, formatDate, truncate } from "@/lib/utils"; import { DownloadIcon, FolderIcon, PlayIcon, RefreshIcon } 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, onMove }: { deck: Deck; onMove?: (folderId: string | undefined) => void }) { const folders = listFolders(); const [busy, setBusy] = useState(null); const [menuOpen, setMenuOpen] = useState(false); const [renaming, setRenaming] = useState(false); const [title, setTitle] = useState(deck.title); const [confirmDelete, setConfirmDelete] = useState(false); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [addCount, setAddCount] = useState(5); const [newFolder, setNewFolder] = useState(null); 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("This deck has no stored source notes, so it can't be extended."); 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(`Added ${generated.activities.length} activities`); setMenuOpen(false); window.setTimeout(() => setNotice(null), 3000); } catch (addError) { setError(addError instanceof ApiError ? addError.message : "Could not add more activities."); } finally { setBusy(null); } }; const regenerate = async () => { if (!hasSource) { setError("This deck has no stored source notes, so it can't be regenerated."); 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("Deck regenerated"); setMenuOpen(false); window.setTimeout(() => setNotice(null), 3000); } catch (regenerateError) { setError(regenerateError instanceof ApiError ? regenerateError.message : "Could not regenerate the deck."); } 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)]"; return (
  • {/* 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)}

    )}

    {formatDate(deck.updatedAt)} · {deck.activities.length} activities {deck.attempts > 0 ? ( <> · {deck.attempts} {deck.attempts === 1 ? "run" : "runs"} ) : null}

    {/* tags --------------------------------------------------------- */}
    {deck.subject ? {deck.subject} : null} {deck.difficulty} {deck.folderId ? ( {folderName(deck.folderId)} ) : null} {deck.bestScore !== undefined ? ( Best {deck.bestScore}% ) : null}
    {/* summary ------------------------------------------------------ */} {deck.summary ? (

    {truncate(deck.summary, 160)}

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

    {busy === "more" ? "Adding new activities…" : "Regenerating the whole deck…"}

    ) : null} {notice ? (

    {notice}

    ) : null} {error ? (

    {error}

    ) : null} {/* single action ------------------------------------------------ */}
    Study
    {/* actions panel ------------------------------------------------ */} {menuOpen ? (
    {confirmDelete ? (
    ) : ( )} {/* folder ---------------------------------------------------- */}

    Folder

    {[{ id: UNORGANIZED_ID, name: "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="New folder name" placeholder="Folder name" maxLength={60} className="w-24 bg-transparent text-[12px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)]" /> )}
    {/* add more -------------------------------------------------- */}

    Add more activities

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

    No stored source text for this deck (imported decks don't carry it).

    ) : 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 }: { children: React.ReactNode }) { return ( {children} ); }