freezmo/components/LibraryScreen.tsx
Martin Dvorak df34337b4a Accessibility pass, report control for homework exercises, clearer rail actions
- axe is clean across every screen in both themes, phone and desktop. Fixed:
  the hidden file inputs (aria-hidden + tabIndex -1 — they are opened by
  labelled buttons, so they should not be in the accessibility tree), the
  heading order (deck/homework/smart-review cards are h2 under the page h1;
  LearningSection takes a level so it stays h2 on the library and h3 inside
  the settings dialog) and landmarks around the study and review feeds.
- tests/contrast.mjs (new diagnostic): samples rendered pixels, because axe
  cannot judge text on blurred glass — it reports those nodes as incomplete.
  It skips disabled controls (1.4.3 exempts them) and content behind an open
  sheet. Result: no below-minimum text on any scanned screen.
- The report control exists in the homework guide too: under each "Try it
  yourself" exercise, labelled "Report exercise", without a skip (there is no
  queue to skip). lib/question-reports now takes deckId/deckTitle directly.
- The rail's two creation actions are buttons now (.nav-action) rather than
  quiet nav rows — once the library is full they were the only way to start
  something on desktop and read as just more navigation. "Make my homework"
  is "New homework" wherever it is an action (rail, empty state, share
  chooser, manifest shortcut); the page title and mode switch keep their
  wording.

Version 0.1.6 (three task bumps), SW -> freezmo-v52.
2026-10-12 21:15:56 +00:00

647 lines
27 KiB
TypeScript

"use client";
import { AnimatePresence, motion } from "framer-motion";
import Link from "next/link";
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { DeckCard } from "@/components/DeckCard";
import { FolderBar } from "@/components/FolderBar";
import { HomeworkCard } from "@/components/HomeworkCard";
import { ChatIcon, EllipsisIcon, FolderIcon, UploadIcon } from "@/components/icons";
import { LinkLoadingScreen } from "@/components/LinkLoadingScreen";
import { SegmentedPill } from "@/components/SegmentedPill";
import { Button, EmptyState, ProgressBar } from "@/components/ui";
import { createFolder, folderStore, renameFolder } from "@/lib/folders";
import { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework";
import { learningStore } from "@/lib/history";
import { useI18n } from "@/lib/i18n";
import { computeStats, dueItems, mistakeItems } from "@/lib/learning";
import { settingsStore } from "@/lib/settings";
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
import { springGentle, springSheet } from "@/lib/motion";
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
import { deckStore, deleteFolder, importDeck, sortDecks, upsertDeck } from "@/lib/storage";
import { backupNudgeDue, downloadBackup, snoozeBackupNudge } from "@/lib/backup";
import { useBackClose } from "@/lib/use-back-close";
import { useIsPhone } from "@/lib/use-phone";
import { cn } from "@/lib/utils";
/**
* Dashboard with two separate pools: study decks (folders + card grid) and
* saved homework (solutions and step-by-step guides). The tab bar picks one,
* so the two never mix in a single list.
*/
export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | "homework" }) {
const tab = initialTab;
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
const homeworkRecords = useSyncExternalStore(
homeworkStore.subscribe,
homeworkStore.getSnapshot,
homeworkStore.getServerSnapshot,
);
const homework = useMemo(() => sortHomework(homeworkRecords), [homeworkRecords]);
const folders = useSyncExternalStore(
folderStore.subscribe,
folderStore.getSnapshot,
folderStore.getServerSnapshot,
);
const selected = useSelectedFolder();
const setSelected = folderSelection.set;
const { t, plural } = useI18n();
const isPhone = useIsPhone();
const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null);
const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [nudgeDue, setNudgeDue] = useState(false);
/** Phone "⋯" sheet: Import JSON + New folder. */
const [sheetOpen, setSheetOpen] = useState(false);
const [folderCreating, setFolderCreating] = useState(false);
const [folderDraft, setFolderDraft] = useState("");
const [folderError, setFolderError] = useState<string | null>(null);
useBackClose(sheetOpen, () => setSheetOpen(false));
useEffect(() => {
if (!sheetOpen) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") setSheetOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [sheetOpen]);
const closeSheet = () => {
setSheetOpen(false);
setFolderCreating(false);
setFolderDraft("");
setFolderError(null);
};
const submitFolderFromSheet = () => {
const result = createFolder(folderDraft);
if ("error" in result) {
setFolderError(result.error);
return;
}
setSelected(result.folder.id);
setMessage({ tone: "ok", text: t("library.folderCreated") });
closeSheet();
};
useEffect(() => {
setNudgeDue(backupNudgeDue(decks.length));
}, [decks.length]);
/* Learning strip: SRS due count, open mistakes, today's goal. */
const answers = useSyncExternalStore(
learningStore.subscribe,
learningStore.getAnswersSnapshot,
learningStore.getServerAnswers,
);
const srs = useSyncExternalStore(
learningStore.subscribe,
learningStore.getSrsSnapshot,
learningStore.getServerSrs,
);
const dailyGoal = useSyncExternalStore(
settingsStore.subscribe,
() => settingsStore.getSnapshot().dailyGoal,
() => 20,
);
/* Smart-review counts resolve against the live deck list: deleting a deck
must drop its due schedules and its mistakes right away. `dueCount` is not
used directly because it counts raw schedules, including decks that no
longer exist. */
const deckIds = useMemo(() => new Set(decks.map((deck) => deck.id)), [decks]);
const due = useMemo(() => dueItems(srs).filter((item) => deckIds.has(item.deck.id)).length, [srs, deckIds]);
const mistakes = useMemo(
() => mistakeItems(answers).filter((item) => deckIds.has(item.deck.id)).length,
[answers, deckIds],
);
const todayCount = useMemo(() => computeStats(answers).todayCount, [answers]);
const importRef = useRef<HTMLInputElement>(null);
const sorted = useMemo(() => sortDecks(decks), [decks]);
const folderList = useMemo(
() => [...folders].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })),
[folders],
);
const counts = useMemo(() => {
const map: Record<string, number> = {};
for (const folder of folderList) map[folder.id] = 0;
for (const deck of decks) {
const key = deck.folderId && map[deck.folderId] !== undefined ? deck.folderId : UNORGANIZED_ID;
map[key] = (map[key] ?? 0) + 1;
}
return map;
}, [decks, folderList]);
const groups = useMemo(() => {
const unorganized: { id: string; name: string; decks: Deck[]; virtual: boolean } = {
id: UNORGANIZED_ID,
name: t("common.unorganized"),
decks: sorted.filter((deck) => !deck.folderId),
virtual: true,
};
if (selected === UNORGANIZED_ID) return [unorganized];
if (selected !== ALL_FOLDERS) {
const folder = folderList.find((entry) => entry.id === selected);
if (!folder) return [unorganized];
return [
{ id: folder.id, name: folder.name, decks: sorted.filter((deck) => deck.folderId === folder.id), virtual: false },
];
}
const named = folderList.map((folder) => ({
id: folder.id,
name: folder.name,
decks: sorted.filter((deck) => deck.folderId === folder.id),
virtual: false,
}));
return [...named, unorganized];
}, [folderList, selected, sorted, t]);
const handleImport = async (file: File | undefined) => {
if (!file) return;
try {
const text = await file.text();
const result = importDeck(text);
if ("error" in result) setMessage({ tone: "error", text: result.error });
else setMessage({ tone: "ok", text: t("library.imported", { title: result.deck.title }) });
} catch {
setMessage({ tone: "error", text: t("library.couldNotRead") });
}
};
const removeFolderEverywhere = (folderId: string) => {
deleteFolder(folderId);
setConfirmDelete(null);
setSelected(ALL_FOLDERS);
setMessage({ tone: "ok", text: t("library.folderDeleted") });
};
const isEmpty = sorted.length === 0 && folderList.length === 0;
// Group headings only earn their keep once real folders exist; with none,
// desktop shows a single quiet “All decks” label instead of “Unorganized”.
const hasFolders = folderList.length > 0;
return (
<div className="relative">
{/* page header ---------------------------------------------------- */}
<section className="mb-6 space-y-5">
<div>
<h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] sm:text-[26px]">
{t("library.title")}
</h1>
<p className="mt-1.5 text-[13.5px] text-[var(--text-subtle)]">
{t("library.subtitle")}
</p>
</div>
{/* Desktop keeps Import above the tabs; phones get a small icon
button in the tab row instead. */}
<div className="hidden flex-wrap items-center gap-2 sm:flex">
<Link href="/chat" className="touch-target inline-flex items-center gap-2 rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] px-4 py-2.5 text-sm font-semibold text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]">
<ChatIcon className="size-4" />
{t("chat.ask")}
</Link>
<Button
variant="secondary"
size="md"
className="touch-target"
onClick={() => importRef.current?.click()}
>
<UploadIcon className="size-4" />
{t("library.import")}
</Button>
</div>
<input
ref={importRef}
type="file"
aria-hidden="true"
tabIndex={-1}
accept="application/json,.json"
className="sr-only"
onChange={(event) => {
void handleImport(event.target.files?.[0]);
event.target.value = "";
}}
/>
{/* Two pools, never mixed: study decks and saved homework. */}
<div className="flex items-center gap-2">
<div className="segmented min-w-0 flex-1 sm:max-w-sm">
<Link
href="/"
aria-current={tab === "decks" ? "page" : undefined}
className={cn("relative", isPhone && "seg-slide")}
>
{isPhone && tab === "decks" ? <SegmentedPill layoutId="library-tabs" /> : null}
<span className="seg-label relative z-10">{t("tabDecks")}</span>
</Link>
<Link
href="/?tab=homework"
aria-current={tab === "homework" ? "page" : undefined}
className={cn("relative", isPhone && "seg-slide")}
>
{isPhone && tab === "homework" ? <SegmentedPill layoutId="library-tabs" /> : null}
<span className="seg-label relative z-10">{t("tabHomework")}</span>
</Link>
</div>
<Link
href="/chat"
aria-label={t("chat.ask")}
title={t("chat.ask")}
className="touch-target grid size-11 shrink-0 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] sm:hidden"
>
<ChatIcon className="size-5" />
</Link>
<button
type="button"
onClick={() => setSheetOpen(true)}
aria-label={t("library.moreActions")}
title={t("library.moreActions")}
className="touch-target grid size-11 shrink-0 place-items-center rounded-2xl border border-[var(--glass-border)] bg-[var(--surface-strong)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] sm:hidden"
>
<EllipsisIcon className="size-5" />
</button>
</div>
{/* Mobile folder switcher; the desktop rail covers desktop. Shown only
once at least one folder exists — creating one lives in the ⋯ sheet
and in the New deck options step. */}
{tab === "decks" && hasFolders ? (
<FolderBar
className="lg:hidden"
folders={folderList}
counts={counts}
selected={selected}
onSelect={(id) => {
setSelected(id);
setConfirmDelete(null);
}}
onCreated={(id) => {
setSelected(id);
setMessage({
tone: "ok",
text: t("library.folderCreated"),
});
}}
/>
) : null}
{tab === "decks" && message ? (
<p
role="status"
className={cn(
"flex items-center gap-2 rounded-xl border border-[var(--hairline)] bg-[var(--surface)] px-3.5 py-2.5 text-[13px] font-medium",
message.tone === "ok" ? "text-emerald-700 dark:text-emerald-300" : "text-rose-700 dark:text-rose-300",
)}
>
{message.text}
</p>
) : null}
</section>
{/* Phones: switching pools crossfades the content. */}
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={isPhone ? tab : "static"}
initial={isPhone ? { opacity: 0, y: 8 } : false}
animate={isPhone ? { opacity: 1, y: 0 } : undefined}
exit={isPhone ? { opacity: 0, y: -6, transition: { duration: 0.16 } } : undefined}
transition={springGentle}
>
{/* learning: review due, mistakes, exam, daily goal ---------------- */}
<section className="card mb-5 space-y-3 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("review.cardTitle")}
</p>
<p className="text-[11.5px] font-medium text-[var(--text-muted)]">
{t("library.learningToday", { n: todayCount, goal: dailyGoal })}
</p>
</div>
<ProgressBar value={Math.min(todayCount, dailyGoal)} max={dailyGoal} label={t("stats.today")} />
<div className="flex flex-wrap gap-2">
<Link
href="/review"
prefetch
aria-disabled={due === 0}
className={cn(
"touch-target inline-flex h-10 items-center rounded-xl px-3.5 text-[13px] font-semibold transition",
due > 0 ? "btn-primary" : "btn-secondary pointer-events-none opacity-60",
)}
>
{t("review.title")} · {plural(due, "review.due")}
<LinkLoadingScreen messageKey="review.preparing" />
</Link>
<Link
href="/review?mistakes=1"
prefetch
aria-disabled={mistakes === 0}
className={cn(
"touch-target inline-flex h-10 items-center rounded-xl px-3.5 text-[13px] font-semibold transition",
mistakes > 0 ? "btn-secondary" : "btn-secondary pointer-events-none opacity-60",
)}
>
{t("review.mistakesTitle")} · {plural(mistakes, "review.mistakes")}
<LinkLoadingScreen messageKey="review.preparing" />
</Link>
</div>
</section>
{/* gentle backup nudge — dismissible, never blocks ---------------- */}
{nudgeDue ? (
<div className="mb-5 flex flex-wrap items-center gap-3 rounded-2xl border border-[var(--hairline)] bg-[var(--surface-sunken)] px-3.5 py-3">
<div className="min-w-0 flex-1">
<p className="text-[13px] font-semibold text-[var(--text)]">{t("settings.backupNudgeTitle")}</p>
<p className="text-[12px] leading-snug text-[var(--text-subtle)]">{t("settings.backupNudgeBody")}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
onClick={() => {
downloadBackup();
setNudgeDue(false);
}}
>
{t("settings.backupNow")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
snoozeBackupNudge();
setNudgeDue(false);
}}
>
{t("settings.backupLater")}
</Button>
</div>
</div>
) : null}
{/* decks: folders + card grid, or a decks-only empty state --------- */}
{tab === "decks" && isEmpty ? (
<EmptyState
title={t("library.noDecks")}
action={
<Link href="/create?mode=deck" className="btn-primary touch-target h-11 px-5 text-sm">
{t("emptyStateCreateDeck")}
</Link>
}
>
{t("library.emptyBody")}
</EmptyState>
) : null}
{/* deck grid (the decks tab, once there is something to show) ------- */}
{tab === "decks" && !isEmpty ? (
<div className="space-y-8">
{groups.map((group) => {
const isEditing = editingFolder?.id === group.id;
return (
<section key={group.id} aria-label={group.name}>
{/* group header -------------------------------------- */}
<div className={cn("mb-4 flex items-center gap-2", !hasFolders && "hidden")}>
<FolderIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
{isEditing && editingFolder ? (
<span className="flex flex-1 items-center gap-2">
<input
autoFocus
value={editingFolder.name}
onChange={(event) => setEditingFolder({ ...editingFolder, name: event.target.value })}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") {
const result = renameFolder(editingFolder.id, editingFolder.name);
if ("error" in result) setMessage({ tone: "error", text: result.error });
else setEditingFolder(null);
}
if (event.key === "Escape") setEditingFolder(null);
}}
aria-label={t("library.renameFolderAria")}
maxLength={60}
className="min-w-32 max-w-xs 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={() => {
const result = renameFolder(editingFolder.id, editingFolder.name);
if ("error" in result) setMessage({ tone: "error", text: result.error });
else setEditingFolder(null);
}}
>
{t("common.save")}
</Button>
</span>
) : (
<button
type="button"
onClick={() => setSelected(group.virtual ? UNORGANIZED_ID : group.id)}
className="truncate text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]"
>
{group.name}
<span className="ml-2 text-[13px] font-normal tabular-nums text-[var(--text-subtle)]">
{group.decks.length}
</span>
</button>
)}
{!group.virtual && !isEditing ? (
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={() => setEditingFolder({ id: group.id, name: group.name })}
className="rounded-lg px-2 py-1 text-[12.5px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
{t("common.rename")}
</button>
{confirmDelete === group.id ? (
<>
<Button size="sm" variant="danger" onClick={() => removeFolderEverywhere(group.id)}>
{t("common.delete")}
</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(null)}>
{t("common.cancel")}
</Button>
</>
) : (
<button
type="button"
onClick={() => setConfirmDelete(group.id)}
className="rounded-lg px-2 py-1 text-[12.5px] font-medium text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-600 dark:hover:text-rose-300"
>
{t("common.delete")}
</button>
)}
</div>
) : null}
</div>
{/* One quiet label replaces the redundant single-group heading. */}
{!hasFolders ? (
<p className="mb-4 hidden text-[12px] font-medium text-[var(--text-subtle)] lg:block">
{t("common.allDecks")}
</p>
) : null}
{confirmDelete === group.id ? (
<p className="mb-3 text-[12.5px] text-[var(--text-muted)]">
{plural(group.decks.length, "library.deleteWarning")}
</p>
) : null}
{group.decks.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--hairline)] px-5 py-8 text-center text-[13px] text-[var(--text-subtle)]">
{group.virtual ? t("library.nothingUnfiled") : t("library.emptyFolder")}
</div>
) : (
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 lg:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]">
<AnimatePresence initial={false}>
{group.decks.map((deck, index) => (
<DeckCard
key={deck.id}
deck={deck}
index={index}
onMove={(folderId) => {
upsertDeck({ ...deck, folderId });
setMessage({
tone: "ok",
text: folderId ? t("library.deckMoved") : t("library.deckMovedUnorganized"),
});
}}
/>
))}
</AnimatePresence>
</ul>
)}
</section>
);
})}
</div>
) : null}
{/* homework: saved solutions and guides, or a homework-only empty state */}
{tab === "homework" && homework.length === 0 ? (
<EmptyState
title={t("homework.emptyTitle")}
action={
<Link href="/homework?mode=homework" className="btn-primary touch-target h-11 px-5 text-sm">
{t("emptyStateHomework")}
</Link>
}
>
{t("homework.emptyBody")}
</EmptyState>
) : null}
{tab === "homework" && homework.length > 0 ? (
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 lg:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]">
<AnimatePresence initial={false}>
{homework.map((record, index) => (
<HomeworkCard key={record.id} record={record} onDelete={deleteHomework} index={index} />
))}
</AnimatePresence>
</ul>
) : null}
</motion.div>
</AnimatePresence>
{/* phone "⋯" sheet: Import JSON + New folder ---------------------- */}
<AnimatePresence>
{sheetOpen ? (
<motion.div
className="fixed inset-0 z-50 flex flex-col justify-end sm:hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<button
type="button"
aria-label={t("common.cancel")}
onClick={closeSheet}
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
/>
<motion.div
role="dialog"
aria-modal="true"
aria-label={t("library.moreTitle")}
initial={{ y: 300 }}
animate={{ y: 0 }}
exit={{ y: 300 }}
transition={springSheet}
className="glass-panel relative z-10 rounded-t-3xl px-4 pb-safe pt-3"
>
<div className="sheet-grabber" aria-hidden />
<h2 className="mb-3 text-[15px] font-semibold text-[var(--text)]">{t("library.moreTitle")}</h2>
{folderCreating ? (
<div className="space-y-2 pb-4">
<input
autoFocus
value={folderDraft}
onChange={(event) => setFolderDraft(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") submitFolderFromSheet();
}}
placeholder={t("common.folderName")}
aria-label={t("common.newFolderName")}
maxLength={60}
className="w-full rounded-xl border border-[var(--hairline)] bg-[var(--surface)] px-3 py-2.5 text-[15px] text-[var(--text)] outline-none focus:border-accent-400"
/>
{folderError ? (
<p className="text-[12px] font-medium text-rose-600 dark:text-rose-300">{folderError}</p>
) : null}
<div className="flex gap-2">
<Button className="touch-target flex-1" onClick={submitFolderFromSheet}>
{t("common.add")}
</Button>
<Button variant="ghost" className="touch-target" onClick={() => setFolderCreating(false)}>
{t("common.cancel")}
</Button>
</div>
</div>
) : (
<div className="space-y-1 pb-4">
<button
type="button"
onClick={() => {
closeSheet();
importRef.current?.click();
}}
className="nav-row touch-target"
>
<UploadIcon className="size-4 shrink-0 opacity-70" />
<span className="flex-1 text-left">{t("library.import")}</span>
</button>
<button
type="button"
onClick={() => {
setFolderDraft("");
setFolderError(null);
setFolderCreating(true);
}}
className="nav-row touch-target"
>
<FolderIcon className="size-4 shrink-0 opacity-70" />
<span className="flex-1 text-left">{t("common.newFolder")}</span>
</button>
</div>
)}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}