Mobile only (<=639px / coarse pointers); desktop keeps its behaviour. Foundation - lib/motion.ts owns the spring presets (snappy/gentle/sheet), durations, easings and variants (screens, tabs, wizard steps, list stagger). - Native-feel CSS: press feedback scales every button, card, chip, tab, row and summary to 0.96 with a spring-like cubic-bezier on transform (scoped to phones/coarse pointers), tap-highlight off, touch-action manipulation, chrome text non-selectable, reduced motion drops it. - App shell fades in once on launch; the study feed fades in on phones. Applied - Tab bar: sliding active pill (shared layoutId) + icon bounce. - Study Decks/Homework, language toggle, difficulty and size pickers: the same gliding highlight, gated so desktop keeps the old styling. - Route content enters direction-aware (deeper = from the right, back = from the left, same-depth = crossfade) via RouteTransition on the library/create/homework content columns; library tab switches crossfade; wizard steps slide forward/backward (reduced motion safe). - Library: cards fade up in a capped stagger on first load, glide with layout animations and shrink away on delete (AnimatePresence). - Study feed: correct feedback pulses, wrong shakes, the progress bar settles with a spring curve, score and XP count up on the results. - Settings sheet uses the sheet spring; the drawer now follows the finger and closes on release velocity, like the sheet. - Generation progress: the stage message fades/slides between stages; the icon keeps its gentle wobble; the feed fades in on hand-off. Skipped, on purpose: LazyMotion (the sliding highlights need layout animations = domMax, same features the current import already has); route exit animations (the App Router unmounts the outgoing page — exits are handled by in-page AnimatePresence); replacing the study feed's native scroll-snap with finger-follow drag (would fight the platform scroller); finger-follow height animation for native <details> expanders. Verified: animation suite 8/8 (reduced motion + 4x-CPU-throttled trace: no long tasks, CLS 0.0000), regression 74/74, mobile 52/52, declutter 116/116, i18n 25/25. Lint + build clean. SW freezmo-v21.
527 lines
20 KiB
TypeScript
527 lines
20 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 { useI18n } from "@/lib/i18n";
|
|
import { springGentle } from "@/lib/motion";
|
|
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
|
|
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
|
|
import { useIsPhone } from "@/lib/use-phone";
|
|
import { cn, formatDate, truncate } from "@/lib/utils";
|
|
import { ChevronDownIcon, DownloadIcon, EllipsisIcon, 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,
|
|
index = 0,
|
|
}: {
|
|
deck: Deck;
|
|
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 = listFolders();
|
|
const [busy, setBusy] = useState<Busy>(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<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);
|
|
|
|
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>
|
|
) : (
|
|
<h3
|
|
className="line-clamp-2 text-[15px] font-semibold leading-snug tracking-[-0.01em] text-[var(--text)]"
|
|
title={deck.title}
|
|
>
|
|
{truncate(deck.title, 90)}
|
|
</h3>
|
|
)}
|
|
{/* 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">
|
|
<span aria-hidden> · </span>
|
|
{deck.attempts > 0 ? plural(deck.attempts, "deck.runs") : t("deck.notPlayed")}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
aria-label={t("deck.actions")}
|
|
aria-expanded={menuOpen}
|
|
onClick={() => 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-secondary 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={() => void regenerate()}
|
|
disabled={Boolean(busy) || !hasSource}
|
|
>
|
|
<RefreshIcon className="size-4 text-[var(--text-subtle)]" />
|
|
{t("deck.regenerate")}
|
|
</button>
|
|
|
|
{confirmDelete ? (
|
|
<div className="flex items-center gap-2 px-3 py-2">
|
|
<Button size="sm" variant="danger" onClick={() => deleteDeck(deck.id)}>
|
|
{t("deck.reallyDelete")}
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(false)}>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<button type="button" className={cn(menuRow, "text-rose-600 dark:text-rose-300")} onClick={() => setConfirmDelete(true)}>
|
|
<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>
|
|
);
|
|
}
|