freezmo/components/DeckCard.tsx
Martin Dvorak 6036238bf0 Teach it back: the AI becomes the curious student
A new mode where the user explains a topic in their own words and the AI
plays a curious classmate: short reactions, exactly one follow-up question
at a time, never "correct"/"wrong", never lecturing, and everything it knows
comes from the user's own decks and notes.

- Entry points: a sidebar row next to Exam mode and a "Teach it back" action
  in every deck's menu (which prefills the topic and scope).
- Start screen: deck or typed topic, friendly/skeptical student, the same
  context and model chips as Chat (/teach?deck=<id> pre-selects the deck).
- Session: the student prompt is a mode of the existing /api/chat route, so
  model resolution, allowlists, rate limits and the streaming client are the
  same code; the personality lives in lib/prompts.ts (TEACH_*). The deck's
  cards travel as a numbered checklist and the student is told to ask about
  the ideas that were skipped; it must say so rather than guess when the
  notes cover nothing. One constant (TEACH_MAX_EXCHANGES = 10) ends the
  session, or the Finish button does.
- Recap: a teach_summary call (JSON, one repair retry) yields well / gaps
  with card references / next steps; each gap links to the card it means.
  "Add missed cards to review" schedules those activities due now; "Turn
  gaps into cards" generates one flashcard per gap from the deck's notes and
  shows an editable review list before saving.
- Sessions are chat conversations with a mode:"teach" marker + session
  record: same IndexedDB history, backup and sync, filtered out of the Chat
  screens; the recent list reopens a recap or continues a session.
- Reused pieces: MessageRow, Composer (now optional attach/scope chips and a
  mic), Markdown/KaTeX, AiDisclaimer, ContextPicker/ModelPicker, plus a
  shared useChatModels hook and useKeyboardInset extracted from Chat.
- Dictation: Web Speech API recognition (lib/speech-input.ts); the mic only
  renders where the browser supports it, and dictated phrases land in the
  draft.
- All strings in en + cs. teach-test: 44 checks (API prompts and guards,
  both entry points, the session, recap actions, history, exchange limit,
  360/390px layouts, mic on/off). Version 0.8.1, SW v103.
2026-10-16 23:05:24 +00:00

589 lines
22 KiB
TypeScript

"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<Busy>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [renaming, setRenaming] = useState(false);
const [title, setTitle] = useState(deck.title);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [addCount, setAddCount] = useState(5);
const [newFolder, setNewFolder] = useState<string | null>(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 (
<motion.li
layout={isPhone}
variants={isPhone ? cardVariants : undefined}
initial={isPhone ? "hidden" : false}
animate={isPhone ? "show" : undefined}
exit={isPhone ? "exit" : undefined}
className="card card-hover group relative flex flex-col p-4 sm:p-5"
>
{/* Phones: the whole card is tappable too; interactive children sit above it. */}
<Link
href={`/deck/${deck.id}`}
aria-hidden="true"
tabIndex={-1}
className="absolute inset-0 hidden rounded-[1.5rem] max-sm:block lg:block"
/>
{/* header ------------------------------------------------------- */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{renaming ? (
<div className="relative z-10 flex items-center gap-2">
<label className="sr-only" htmlFor={`rename-${deck.id}`}>
{t("deck.titleLabel")}
</label>
<input
id={`rename-${deck.id}`}
value={title}
autoFocus
onChange={(event) => 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"
/>
<Button size="sm" onClick={saveTitle}>
{t("common.save")}
</Button>
</div>
) : (
<h2
className="line-clamp-2 text-[15px] font-semibold leading-snug tracking-[-0.01em] text-[var(--text)]"
title={deck.title}
>
{truncate(deck.title, 90)}
</h2>
)}
{/* Phones: one compact meta line. */}
<p className="mt-1 text-[12.5px] text-[var(--text-subtle)] sm:hidden">
{plural(deck.activities.length, "deck.activities")}
<span aria-hidden> · </span>
<span className={difficultyClass(deck.difficulty)}>
{deck.difficulty === "easy"
? t("upload.easy")
: deck.difficulty === "hard"
? t("upload.hard")
: t("upload.medium")}
</span>
{deck.bestScore !== undefined ? (
<>
<span aria-hidden> · </span>
<span className="text-emerald-700 dark:text-emerald-300">
{t("deck.bestLabel")} {deck.bestScore}%
</span>
</>
) : null}
</p>
<p className="mt-1 hidden text-[12.5px] text-[var(--text-subtle)] sm:block lg:mt-3">
{/* Phones keep the original date · activities · runs line. */}
<span className="lg:hidden">
{formatDate(deck.updatedAt)}
<span aria-hidden> · </span>
</span>
{plural(deck.activities.length, "deck.activities")}
<span className="lg:hidden">
{deck.attempts > 0 ? (
<>
<span aria-hidden> · </span>
{plural(deck.attempts, "deck.runs")}
</>
) : null}
</span>
{/* Desktop always shows the same two fields. */}
<span className="hidden lg:inline-flex items-center">
{deck.attempts > 0 ? (
<>
<span aria-hidden> · </span>
{plural(deck.attempts, "deck.runs")}
</>
) : (
<span className="tint-sky ml-1 rounded-full px-2 py-0.5 text-[10.5px] font-semibold">
{t("deck.new")}
</span>
)}
</span>
</p>
</div>
<button
type="button"
aria-label={t("deck.actions")}
aria-expanded={menuOpen}
onClick={() => {
haptic("tap");
setMenuOpen((value) => !value);
}}
className={cn(
"relative z-10 grid size-8 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:-mt-1.5",
menuOpen && "bg-[var(--hover)] text-[var(--text)]",
)}
>
<span aria-hidden className="lg:hidden">⋯</span>
<EllipsisIcon className="hidden size-4 lg:block" />
</button>
</div>
{/* tags --------------------------------------------------------- */}
<div className="mt-3 hidden flex-wrap items-center gap-1.5 sm:flex">
{deck.subject ? <Tag>{deck.subject}</Tag> : null}
<Tag>
<span className={difficultyClass(deck.difficulty)}>
{deck.difficulty === "easy"
? t("upload.easy")
: deck.difficulty === "hard"
? t("upload.hard")
: t("upload.medium")}
</span>
</Tag>
{deck.folderId ? (
<Tag>
<FolderIcon className="size-3.5 opacity-70" />
{folders.find((entry) => entry.id === deck.folderId)?.name ?? t("common.unorganized")}
</Tag>
) : null}
{deck.bestScore !== undefined ? (
<>
<Tag className="lg:hidden">
{t("deck.bestLabel")}{" "}
<span className="text-emerald-700 dark:text-emerald-300">{deck.bestScore}%</span>
</Tag>
<span className="hidden text-[12px] font-medium tabular-nums text-[var(--text-muted)] lg:ml-1 lg:inline">
{t("deck.bestLabel")} {deck.bestScore}%
</span>
</>
) : null}
</div>
{/* summary ------------------------------------------------------ */}
{deck.summary ? (
<>
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((value) => !value)}
className="relative z-10 mt-2 flex w-full items-start gap-1 text-left text-[13px] leading-relaxed text-[var(--text-muted)] sm:hidden"
>
<span className={cn("min-w-0 flex-1", !expanded && "line-clamp-1")}>{deck.summary}</span>
<ChevronDownIcon
className={cn("mt-0.5 size-4 shrink-0 text-[var(--text-subtle)] transition-transform", expanded && "rotate-180")}
/>
</button>
<p className="mt-3 hidden line-clamp-2 text-[13.5px] leading-relaxed text-[var(--text-muted)] sm:block">
{truncate(deck.summary, 160)}
</p>
</>
) : null}
{/* status ------------------------------------------------------- */}
{busy ? (
<div className="mt-4 space-y-2">
<ProgressBar
value={1}
max={2}
label={busy === "more" ? t("deck.addingActivities") : t("deck.regeneratingDeck")}
/>
<p className="text-[12.5px] font-medium text-[var(--text-muted)]">
{busy === "more" ? t("deck.addingNew") : t("deck.regeneratingWhole")}
</p>
</div>
) : null}
{notice ? (
<p className="mt-3 rounded-xl bg-emerald-500/10 px-3 py-2 text-[12.5px] font-medium text-emerald-700 dark:text-emerald-300">
{notice}
</p>
) : null}
{error ? (
<p className="mt-3 rounded-xl bg-rose-500/10 px-3 py-2 text-[12.5px] font-medium text-rose-700 dark:text-rose-300">
{error}
</p>
) : null}
{/* single action ------------------------------------------------ */}
<div className="mt-4 flex justify-end lg:mt-4">
<Link
href={`/deck/${deck.id}`}
className="btn-primary touch-target relative z-10 h-9 gap-1.5 px-3.5 text-[13px] sm:hidden"
>
<PlayIcon className="size-4" />
{t("deck.study")}
</Link>
<Link
href={`/deck/${deck.id}`}
className="btn-primary hidden h-10 w-full px-4 text-sm sm:flex lg:hidden"
>
<PlayIcon className="size-4" />
{t("deck.study")}
</Link>
<div className="relative z-10 hidden lg:flex lg:justify-end">
<Link href={`/deck/${deck.id}`} className="btn-primary h-9 px-4 text-[13px]">
<PlayIcon className="size-4" />
{t("deck.study")}
</Link>
</div>
</div>
{/* actions panel ------------------------------------------------ */}
{menuOpen ? (
<div
data-testid="deck-actions"
className="animate-rise relative z-10 mt-3 space-y-1 border-t border-[var(--hairline)] pt-3"
>
<button type="button" className={menuRow} onClick={() => setRenaming(true)} disabled={Boolean(busy)}>
<span aria-hidden className="w-4 text-center text-[var(--text-subtle)]">
✎
</span>
{t("common.rename")}
</button>
<button type="button" className={menuRow} onClick={() => downloadDeck(deck)} disabled={Boolean(busy)}>
<DownloadIcon className="size-4 text-[var(--text-subtle)]" />
{t("deck.export")}
</button>
<button
type="button"
className={menuRow}
onClick={() => {
setMenuOpen(false);
void shareDeck(deck);
}}
disabled={Boolean(busy)}
>
<ShareIcon className="size-4 text-[var(--text-subtle)]" />
{t("deck.share")}
</button>
<Link
href={`/chat?deck=${deck.id}`}
className={menuRow}
>
<ChatIcon className="size-4 text-[var(--text-subtle)]" />
{t("chat.askAboutDeck")}
</Link>
<Link
href={`/teach?deck=${deck.id}`}
className={menuRow}
>
<TeacherIcon className="size-4 text-[var(--text-subtle)]" />
{t("teach.nav")}
</Link>
<button
type="button"
className={menuRow}
onClick={() => void regenerate()}
disabled={Boolean(busy) || !hasSource}
>
<RefreshIcon className="size-4 text-[var(--text-subtle)]" />
{t("deck.regenerate")}
</button>
{hasSource ? (
<button
type="button"
className={menuRow}
onClick={() => {
setMenuOpen(false);
deleteNotesText(deck.id);
showToast({ message: t("chat.notesDeleted") });
}}
>
<XCircleIcon className="size-4 text-[var(--text-subtle)]" />
{t("chat.deleteNotes")}
</button>
) : null}
{/* Deleting is reversible: no confirm, just an Undo toast. */}
<button
type="button"
className={cn(menuRow, "text-rose-600 dark:text-rose-300")}
onClick={() => {
setMenuOpen(false);
deleteDeck(deck.id);
showToast({
message: t("toast.deckDeleted"),
actionLabel: t("toast.undo"),
onAction: () => upsertDeck(deck),
});
}}
>
<span aria-hidden className="w-4 text-center">
⌫
</span>
{t("deck.delete")}
</button>
{/* folder ---------------------------------------------------- */}
<div className="mt-2 rounded-xl bg-[var(--surface-sunken)] p-2.5">
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("common.folder")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{[{ id: UNORGANIZED_ID, name: t("common.unorganized") }, ...folders].map((folder) => {
const current = (deck.folderId ?? UNORGANIZED_ID) === folder.id;
return (
<button
key={folder.id}
type="button"
aria-pressed={current}
onClick={() => {
onMove?.(folder.id === UNORGANIZED_ID ? undefined : folder.id);
setMenuOpen(false);
}}
className={cn(
"rounded-full px-2.5 py-1 text-[12px] font-medium transition",
current
? "grad-ice text-[#06111f]"
: "text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
)}
>
{folder.name}
</button>
);
})}
{newFolder === null ? (
<button
type="button"
onClick={() => setNewFolder("")}
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
{t("deck.newFolder")}
</button>
) : (
<span className="inline-flex items-center gap-1 rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] py-0.5 pl-2.5 pr-1">
<input
autoFocus
value={newFolder}
onChange={(event) => 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)]"
/>
<button
type="button"
className="btn-primary h-6 px-2.5 text-[11px]"
onClick={() => {
const created = makeFolder(newFolder);
if ("folder" in created) {
onMove?.(created.folder.id);
setNewFolder(null);
setMenuOpen(false);
} else {
setError(created.error);
}
}}
>
{t("common.add")}
</button>
</span>
)}
</div>
</div>
{/* add more -------------------------------------------------- */}
<div className="mt-1 rounded-xl bg-[var(--surface-sunken)] p-2.5">
<p className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("deck.addMore")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{ADD_OPTIONS.map((option) => (
<button
key={option}
type="button"
onClick={() => setAddCount(option)}
aria-pressed={addCount === option}
className={cn(
"min-w-9 rounded-full px-2.5 py-1 text-[12px] font-medium transition",
addCount === option
? "grad-ice text-[#06111f]"
: "text-[var(--text-muted)] hover:bg-[var(--hover)] hover:text-[var(--text)]",
)}
>
+{option}
</button>
))}
<Button size="sm" onClick={() => void addMore()} disabled={Boolean(busy) || !hasSource} loading={busy === "more"}>
{t("deck.generate")}
</Button>
</div>
{!hasSource ? (
<p className="px-1 pt-2 text-[11px] text-amber-700 dark:text-amber-300">
{t("deck.noSource")}
</p>
) : null}
</div>
</div>
) : null}
</motion.li>
);
}
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 (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full bg-[var(--surface-sunken)] px-2.5 py-1 text-[12px] font-medium leading-none text-[var(--text-muted)]",
className,
)}
>
{children}
</span>
);
}