Navigation restructure: five-tab bottom bar, no phone drawer

Drawer removed (phones only; desktop rail untouched)
- MobileSidebar, sidebarOpen/useSidebarOpen, MenuIcon and the drawer's
  back-gesture layer are gone; the shared nav content now serves only
  the desktop rail (dead mobile branches deleted). No drawer-specific
  CSS existed; the three drawer strings (header.openMenu,
  sidebar.closeMenu, common.menu) and library.importAria are removed.
- The header drops the hamburger: logo + name on the left, the single
  theme chip on the right.

Five-tab bottom bar
- Library / Progress / New deck (prominent centre +) / Exam / Settings,
  in that order; Progress and Exam reuse the existing /stats and /exam
  screens, Settings keeps opening the dialog. Sliding pill, icon bounce,
  press feedback, re-tap-to-top and safe-area padding kept; every item
  is >=44px (72px wide at 360px).
- New short tab.* keys (EN + CS) so all five labels stay on one line at
  360px — 'Zkouškový režim' became 'Zkouška'. No ellipsis anywhere.
- The bar is mounted once in AppHeader so library, progress, exam and
  share get it; the wizards keep their own bottom action bar and the
  tab bar steps aside there, exactly as before.

Re-homed drawer destinations
- Smart Review card loses the Exam link (it is a tab now); Review due
  and Mistakes stay.
- New deck screen (and /homework) get a Study deck / Make my homework
  segmented switch; the library's homework empty-state button stays.
- The phone import icon becomes a ... button opening a sheet with
  Import JSON and New folder (back-gesture and Escape close it); folder
  chips still appear only once at least one folder exists, and folder
  creation stays in the New deck options step.

Verified: mobile suite 134 checks (no hamburger/drawer, tab order and
44px targets, label matrix at 360/390 x EN/CS x dark/light x reduced
motion, per-tab routing, deep links, Back from every tab, the ... sheet
with folder creation) plus regression 74/74, wizard 83/83, declutter
116/116, animation 8/8, native 35/35, i18n 25/25, phase1 38/38, phase2
43/43, phase3 20/20, freeze-loading 6/6. Key parity 740/740. Lint +
build clean. SW freezmo-v28.
This commit is contained in:
Martin Dvořák 2026-10-10 16:53:55 +00:00
commit 8557b04621
19 changed files with 357 additions and 325 deletions

View file

@ -7,20 +7,21 @@ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "reac
import { DeckCard } from "@/components/DeckCard";
import { FolderBar } from "@/components/FolderBar";
import { HomeworkCard } from "@/components/HomeworkCard";
import { FolderIcon, UploadIcon } from "@/components/icons";
import { EllipsisIcon, FolderIcon, UploadIcon } from "@/components/icons";
import { SegmentedPill } from "@/components/SegmentedPill";
import { Button, EmptyState, ProgressBar } from "@/components/ui";
import { folderStore, renameFolder } from "@/lib/folders";
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, dueCount, mistakeCount } from "@/lib/learning";
import { settingsStore } from "@/lib/settings";
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
import { springGentle } from "@/lib/motion";
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";
@ -51,6 +52,40 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
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));
@ -202,18 +237,19 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
</div>
<button
type="button"
onClick={() => importRef.current?.click()}
aria-label={t("library.importAria")}
title={t("library.import")}
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"
>
<UploadIcon className="size-4" />
<EllipsisIcon className="size-5" />
</button>
</div>
{/* Mobile folder switcher; the sidebar covers desktop. Hidden while
there are no folders — the drawer's “New folder” is the way in. */}
{tab === "decks" && !isEmpty && hasFolders ? (
{/* 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}
@ -288,12 +324,6 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
>
{t("review.mistakesTitle")} · {plural(mistakes, "review.mistakes")}
</Link>
<Link
href="/exam"
className="btn-ghost touch-target inline-flex h-10 items-center rounded-xl px-3.5 text-[13px] font-semibold transition"
>
{t("exam.title")}
</Link>
</div>
</section>
@ -498,6 +528,93 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
</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>
);
}