- 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.
647 lines
27 KiB
TypeScript
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>
|
|
);
|
|
}
|