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

@ -54,7 +54,7 @@ no database, no data leaving your machine except the requests you send to Ollama
the safe area (the tab bar hides inside the wizard), and it is portalled to `<body>` so the
screen entrance animation can never displace it. The desktop page is unchanged.
- **Native-feel touches (phones)**: the Android Back gesture closes the topmost layer first
(sheet, drawer, actions panel, then one wizard step); deleting a deck shows an *Undo*
(sheet, actions sheet, then one wizard step); deleting a deck shows an *Undo*
toast instead of a confirm; the deck menu can share a deck as JSON through the system share
sheet (clipboard fallback); content shared from other apps opens a chooser and lands
pre-filled in either wizard; haptics (Settings → Haptics, on by default) tick on tab
@ -67,8 +67,8 @@ no database, no data leaving your machine except the requests you send to Ollama
1 day, then 3, then interval × ease; wrong ones after ten minutes. The Library shows a **Smart
review** card with the due count, a **Mistakes** count (activities whose latest answer was wrong —
they clear themselves once answered correctly) and today's **daily goal** progress. A **Progress**
screen (drawer) has the day streak, a five-week calendar, accuracy per subject and per deck and
the weakest topics. **Exam mode** (drawer) takes one or more decks, a question count and an
tab has the day streak, a five-week calendar, accuracy per subject and per deck and
the weakest topics. **Exam mode** (its own tab) takes one or more decks, a question count and an
optional timer, hides hints and shows a per-deck/per-topic breakdown at the end. **Daily
reminders** appear in Settings only when the server has VAPID keys configured; subscriptions are
stored as endpoint + keys only, and `node scripts/send-reminders.mjs` (cron/systemd timer) does
@ -172,9 +172,9 @@ no database, no data leaving your machine except the requests you send to Ollama
- **Two pools, never mixed**: a **Study Decks** tab (the folder + card library) and a **Homework**
tab with saved solutions and step-by-step guides — each with its own empty state and call to
action, and the active tab lives in the URL (`/?tab=homework`)
- **Phone library, decluttered**: no stats line, the folder row and “Unorganized” heading only
appear once folders exist (the drawer has *New folder*), and Import is a small icon button in
the tab row. Deck cards shrink to title + one meta line (activities · difficulty · best
- **Phone library, decluttered**: no stats line, the folder chips and “Unorganized” heading only
appear once folders exist, and a **⋯** button next to the pool switcher opens a small sheet with
**Import JSON** and **New folder**. Deck cards shrink to title + one meta line (activities · difficulty · best
score) + a one-line description that expands on tap, with the whole card tappable and a small
**Study** pill; homework cards match.
- Decks saved locally with title, subject, folder, activity count, date, play count and best score
@ -188,10 +188,14 @@ no database, no data leaving your machine except the requests you send to Ollama
**Design**
- **ChatGPT-style shell**: a persistent left **sidebar** (brand, a quiet "New deck" row, a *Folders*
list with per-folder counts, Settings/Dark mode pinned to the bottom). It **collapses** to a 68px
icon rail (remembered in `localStorage`) and becomes a slide-in **drawer** behind a hamburger on
phones; mobile also keeps the frosted header + iOS tab bar
- **ChatGPT-style shell on desktop, five tabs on phones**: desktop keeps the persistent left
**sidebar** (brand, a quiet "New deck" row, a *Folders* list with per-folder counts,
Settings/Dark mode pinned to the bottom), which **collapses** to a 68px icon rail (remembered in
`localStorage`). Phones have no drawer and no hamburger: a frosted header (logo + theme chip) sits
above an iOS-style bottom tab bar — **Library · Progress · New deck · Exam · Settings** — with the
New deck button as the prominent centre action and Settings opening the sheet. The wizards keep
their own bottom action bar instead of the tab bar, and the in-page segmented switch moves between
the deck and homework wizards.
- **Frosted ice glass, both themes**: light page `#eef5fd` (pale ice) with white glass panels;
dark page `#0a1120` with `rgba(255,255,255,.06)` glass. Every large surface shares one recipe —
translucent tint, 24px `backdrop-blur` (16px on phones), a bright 1px rim, an inner top highlight
@ -209,11 +213,11 @@ no database, no data leaving your machine except the requests you send to Ollama
- Micro-interactions: 160–200ms colour/transform transitions, `active:scale-[0.985]`, skeleton
shimmer, hairline hover washes on every row and card
- **Motion (phones)**: one shared language (`lib/motion.ts`) — every button, card, chip, tab and
drawer row scales to 0.96 on press with a spring release; the tab bar, segmented controls and
nav row scales to 0.96 on press with a spring release; the tab bar, segmented controls and
difficulty/size pickers glide their highlight with a shared `layoutId`; wizard steps slide
forward and backward and page content enters direction-aware; the Settings sheet and drawer
follow the finger and settle on release velocity; library cards fade up in a capped stagger and
animate out on delete; score and XP count up; correct answers pulse, wrong ones shake;
forward and backward and page content enters direction-aware; the Settings sheet and the library
actions sheet follow the finger and settle on release velocity; library cards fade up in a capped
stagger and animate out on delete; score and XP count up; correct answers pulse, wrong ones shake;
`prefers-reduced-motion` collapses everything to short fades. Only transform and opacity
animate — never blur.
- **iOS-flavoured PWA**: standalone mode, safe-area insets, bottom tab bar, sheet-style dialogs with
@ -537,8 +541,8 @@ on Android Chrome a captured `beforeinstallprompt` turns into a real **Install**
can be reopened any time from **Settings → Install app** (mobile only).
It launches full-screen in standalone mode (no browser chrome), respects the notch/home-indicator
safe areas, and has an iOS-style bottom tab bar (Library · New deck · Settings) with sheet-style
dialogs. Android Chrome also offers "Install app" from the ⋮ menu; Freezmo works offline for the
safe areas, and has an iOS-style bottom tab bar (Library · Progress · New deck · Exam · Settings)
with sheet-style dialogs. Android Chrome also offers "Install app" from the ⋮ menu; Freezmo works offline for the
parts that don't need a model.
What's included:
@ -1018,6 +1022,22 @@ Things I chose while building, with the reasoning:
phone and desktop check flows, the shorter follow-up with restore, the guide's explain action,
and the deck hand-off).
43. **Phones navigate with five tabs, not a drawer.** The drawer, its hamburger, its
drag-to-close gesture and its back-gesture layer are gone (`MobileSidebar`, `sidebarOpen`,
`MenuIcon` and the three drawer strings deleted); the desktop rail is untouched and is now the
only consumer of the shared nav content. The bottom bar is **Library · Progress · New deck (the
prominent centre +) · Exam · Settings**, keeping the sliding pill, press feedback, re-tap-to-top
and safe-area padding. Tab labels use dedicated short `tab.*` keys because the old names
(“Zkouškový režim”) can't fit five-up at 360px; the matrix in the mobile suite checks every
label against clipping at 360/390, EN/CS, dark/light and with reduced motion on and off. The
wizards keep their own bottom action bar, so the tab bar steps aside there, exactly as before.
Drawer-only destinations were re-homed: Progress and Exam became tabs, the New deck screen got
a **Study deck ⇄ Make my homework** segmented switch (also on `/homework`), and the library's
phone row gained a **⋯** sheet with Import JSON + New folder (folder chips still appear only
once a folder exists). Verified by the mobile suite (134 checks: no hamburger/drawer, tab order,
the label matrix, per-tab routing, deep links, Back from every tab and the ⋯ sheet) plus the
other ten standing suites.
36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck
flow and a 2-step homework flow that share the exact state and handlers with the desktop
cards (one `UploadScreen` / one `HomeworkInput`, two layouts switched by `useIsPhone()`);
@ -1046,7 +1066,7 @@ Things I chose while building, with the reasoning:
transform on an ancestor becomes the containing block for `position: fixed`, so the bar
used to paint inside the content and jump to the viewport bottom a second later. It now
only fades in, pinned to the bottom from its first frame — verified by sampling its
position across the entrance (5 frames, 60–450ms) from the tab bar, the drawer and the
position across the entrance (5 frames, 60–450ms) from the tab bar, the mode switch and the
empty state, at 360/390, both themes, both languages and with reduced motion.
39. **Native feel is layered onto the platform, never around it.** The Back gesture closes the
@ -1059,7 +1079,7 @@ Things I chose while building, with the reasoning:
Wizard drafts and the last URL are per-browser; the share target hands POSTed files to the
client through a ten-minute in-memory store so nothing touches disk. Verified by the native
suite (35 checks: manifest/head/viewport, offline banner + disabled generate, share target GET
and POST, back-gesture closing for drawer/sheet/wizard, undo toast, share fallback, tab
and POST, back-gesture closing for sheet/wizard, undo toast, share fallback, tab
re-tap, haptics persistence, draft restore) plus the six standing suites.
**Needs a real device to verify** (emulation can't do these honestly): the iOS hidden-switch

View file

@ -3,8 +3,9 @@ import { redirect } from "next/navigation";
import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader";
import { RouteTransition } from "@/components/RouteTransition";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
import { Sidebar, SidebarOffset } from "@/components/Sidebar";
import { UploadScreen } from "@/components/UploadScreen";
import { WizardModeSwitch } from "@/components/WizardModeSwitch";
export const metadata = {
title: "New deck",
@ -25,10 +26,10 @@ export default async function CreatePage({
<div className="flex min-h-dvh">
<Sidebar />
<SidebarOffset />
<MobileSidebar />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader />
<div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16">
<WizardModeSwitch mode="deck" />
<RouteTransition>
<UploadScreen />
</RouteTransition>

View file

@ -1,7 +1,7 @@
import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader";
import { ExamScreen } from "@/components/exam/ExamScreen";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
import { Sidebar, SidebarOffset } from "@/components/Sidebar";
export const metadata = {
title: "Exam mode",
@ -13,7 +13,6 @@ export default function ExamPage() {
<div className="flex min-h-dvh">
<Sidebar />
<SidebarOffset />
<MobileSidebar />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader />
<div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">

View file

@ -4,7 +4,8 @@ import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader";
import { HomeworkScreen } from "@/components/homework/HomeworkScreen";
import { RouteTransition } from "@/components/RouteTransition";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
import { Sidebar, SidebarOffset } from "@/components/Sidebar";
import { WizardModeSwitch } from "@/components/WizardModeSwitch";
export const metadata = {
title: "Make my homework",
@ -25,10 +26,10 @@ export default async function HomeworkPage({
<div className="flex min-h-dvh">
<Sidebar />
<SidebarOffset />
<MobileSidebar />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader />
<div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">
<WizardModeSwitch mode="homework" />
<RouteTransition>
<HomeworkScreen initialRecordId={typeof open === "string" ? open : undefined} />
</RouteTransition>

View file

@ -2,8 +2,7 @@ import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader";
import { LibraryScreen } from "@/components/LibraryScreen";
import { RouteTransition } from "@/components/RouteTransition";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
import { TabBar } from "@/components/TabBar";
import { Sidebar, SidebarOffset } from "@/components/Sidebar";
export default async function HomePage({
searchParams,
@ -16,7 +15,6 @@ export default async function HomePage({
<div className="flex min-h-dvh">
<Sidebar />
<SidebarOffset />
<MobileSidebar />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader />
<div className="mx-auto w-full max-w-6xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">
@ -24,7 +22,6 @@ export default async function HomePage({
<LibraryScreen initialTab={tab === "homework" ? "homework" : "decks"} />
</RouteTransition>
</div>
<TabBar />
</main>
</div>
</ApiKeyGate>

View file

@ -1,7 +1,7 @@
import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader";
import { ShareChooser } from "@/components/ShareChooser";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
import { Sidebar, SidebarOffset } from "@/components/Sidebar";
export const metadata = {
title: "Add to Freezmo",
@ -18,7 +18,6 @@ export default async function SharePage({
<div className="flex min-h-dvh">
<Sidebar />
<SidebarOffset />
<MobileSidebar />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader />
<div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">

View file

@ -1,6 +1,6 @@
import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
import { Sidebar, SidebarOffset } from "@/components/Sidebar";
import { StatsScreen } from "@/components/StatsScreen";
export const metadata = {
@ -13,7 +13,6 @@ export default function StatsPage() {
<div className="flex min-h-dvh">
<Sidebar />
<SidebarOffset />
<MobileSidebar />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader />
<div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">

View file

@ -3,18 +3,17 @@
import Link from "next/link";
import { useEffect, useState } from "react";
import { MenuIcon } from "@/components/icons";
import { SettingsDialog } from "@/components/SettingsDialog";
import { TabBar } from "@/components/TabBar";
import { ThemeToggle } from "@/components/ThemeToggle";
import { useI18n } from "@/lib/i18n";
import { sidebarOpen } from "@/lib/nav";
import { OPEN_SETTINGS_EVENT } from "@/lib/ui-events";
import { cn } from "@/lib/utils";
/**
* Minimal, sticky frosted top bar: brand on the left, quiet actions on the
* right. The phone layout keeps the brand compact and relies on the tab bar
* for navigation.
* Minimal, sticky frosted top bar: brand on the left, theme chip on the
* right. Phones navigate with the bottom tab bar (also mounted here, so every
* screen that has a header gets it); the desktop rail carries navigation.
*/
export function AppHeader({ className, compact = false }: { className?: string; compact?: boolean }) {
const { t } = useI18n();
@ -48,41 +47,34 @@ export function AppHeader({ className, compact = false }: { className?: string;
)}
>
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-3">
<button
type="button"
onClick={() => sidebarOpen.toggle()}
aria-label={t("header.openMenu")}
className="mr-1 grid size-9 shrink-0 touch-target place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
<Link
href="/"
className="group flex min-w-0 items-center gap-2.5 rounded-xl px-1 py-1"
aria-label={t("header.home")}
>
<MenuIcon className="size-5" />
</button>
<Link href="/" className="group flex items-center gap-2.5 rounded-xl px-1 py-1" aria-label={t("header.home")}>
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
<img
src="/icons/icon-192.png"
alt=""
aria-hidden
className="size-8 rounded-[0.6rem] transition-transform group-hover:scale-[1.04]"
/>
<span className="flex items-baseline gap-2">
<span className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">
Freezmo
</span>
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
<img
src="/icons/icon-192.png"
alt=""
aria-hidden
className="size-8 shrink-0 rounded-[0.6rem] transition-transform group-hover:scale-[1.04]"
/>
<span className="flex min-w-0 items-baseline gap-2">
<span className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">Freezmo</span>
{!compact ? (
<span className="hidden text-[13px] text-[var(--text-subtle)] sm:inline">
<span className="hidden truncate text-[13px] text-[var(--text-subtle)] sm:inline">
{t("header.tagline")}
</span>
) : null}
</span>
</Link>
<div className="flex items-center gap-1">
{/* Settings lives in the tab bar and the drawer; the old header
gear was the same control a third time. */}
<div className="flex shrink-0 items-center gap-1">
<ThemeToggle />
</div>
</div>
</header>
<TabBar />
<SettingsDialog
open={settingsOpen}
onClose={() => setSettingsOpen(false)}

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>
);
}

View file

@ -2,7 +2,6 @@
import Link from "next/link";
import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
ChevronLeftIcon,
@ -21,26 +20,19 @@ import { useTheme } from "@/components/ThemeToggle";
import { createFolder, folderStore } from "@/lib/folders";
import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n";
import { springSheet } from "@/lib/motion";
import { ALL_FOLDERS, folderSelection, sidebarOpen, useSelectedFolder, useSidebarOpen } from "@/lib/nav";
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
import { UNORGANIZED_ID } from "@/lib/schemas";
import { deckStore } from "@/lib/storage";
import { openSettings } from "@/lib/ui-events";
import { useBackClose } from "@/lib/use-back-close";
import { cn } from "@/lib/utils";
const COLLAPSE_KEY = "freezmo.sidebarCollapsed";
/** Shared nav content for the desktop rail and the mobile drawer. */
function NavContent({
collapsed = false,
onNavigate,
desktop = false,
}: {
collapsed?: boolean;
onNavigate?: () => void;
desktop?: boolean;
}) {
/**
* Desktop navigation rail (lg and up). Phones use the bottom tab bar instead —
* there is no drawer any more, so this is the only consumer of the rail.
*/
function NavContent({ collapsed = false }: { collapsed?: boolean }) {
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
const folders = useSyncExternalStore(
folderStore.subscribe,
@ -60,16 +52,6 @@ function NavContent({
[folders],
);
const counts = useMemo(() => {
const map: Record<string, number> = { [UNORGANIZED_ID]: 0 };
for (const folder of sortedFolders) 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, sortedFolders]);
const submit = () => {
const result = createFolder(draft);
if ("error" in result) {
@ -82,20 +64,15 @@ function NavContent({
setCreating(false);
};
const select = (id: string) => {
folderSelection.set(id);
onNavigate?.();
};
/** Counts of 0 are noise — only show a count when there is something in it. */
const count = (value: number) =>
!collapsed && value > 0 ? <span className="text-[12px] tabular-nums opacity-60">{value}</span> : null;
/** One shared view row (All decks / Unorganized) for both nav layouts. */
/** One shared view row (All decks / Unorganized). */
const viewRow = (id: string, label: string, icon: ReactNode, trailing: ReactNode) => (
<button
type="button"
onClick={() => select(id)}
onClick={() => folderSelection.set(id)}
aria-pressed={selected === id}
title={label}
className={cn("nav-row", collapsed && "justify-center px-2")}
@ -112,7 +89,6 @@ function NavContent({
<div className={cn("mb-1 flex items-center", collapsed ? "justify-center" : "justify-between")}>
<Link
href="/"
onClick={onNavigate}
className={cn("nav-row gap-2.5", collapsed ? "w-auto px-2" : "flex-1")}
aria-label={t("header.home")}
title="Freezmo"
@ -144,7 +120,6 @@ function NavContent({
{/* new deck: a quiet row, not a competing primary button ---------- */}
<Link
href="/create"
onClick={onNavigate}
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
title={t("common.newDeck")}
>
@ -155,7 +130,6 @@ function NavContent({
{/* make my homework: same quiet row --------------------------------- */}
<Link
href="/homework"
onClick={onNavigate}
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
title={t("homework.nav")}
>
@ -163,29 +137,7 @@ function NavContent({
{!collapsed ? <span className="flex-1 truncate text-left">{t("homework.nav")}</span> : null}
</Link>
{/* folders --------------------------------------------------------- */}
{/* Mobile keeps the original layout: one Folders label above the list. */}
{!desktop && !collapsed ? (
<div className="flex items-center justify-between px-2 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
{t("common.folders")}
</span>
<button
type="button"
onClick={() => setCreating(true)}
aria-label={t("common.newFolder")}
title={t("common.newFolder")}
className="grid size-6 place-items-center rounded-md text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
>
<PlusIcon className="size-3.5" />
</button>
</div>
) : null}
<nav
aria-label={desktop ? t("sidebar.library") : t("common.folders")}
className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto"
>
<nav aria-label={t("sidebar.library")} className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto">
{collapsed ? (
<button
type="button"
@ -198,42 +150,23 @@ function NavContent({
</button>
) : null}
{/* Desktop: All decks + Unorganized are views, so they sit above the
Folders label; mobile keeps them inside the folder list. */}
{desktop ? (
<div className={cn("space-y-0.5", !collapsed && "mb-1")}>
{viewRow(
ALL_FOLDERS,
t("common.allDecks"),
<LayersIcon className="size-4 shrink-0 opacity-70" />,
count(decks.length),
)}
{viewRow(
UNORGANIZED_ID,
t("common.unorganized"),
<FolderIcon className="size-4 shrink-0 opacity-70" />,
null,
)}
</div>
) : (
<>
{viewRow(
ALL_FOLDERS,
t("common.allDecks"),
<LayersIcon className="size-4 shrink-0 opacity-70" />,
count(decks.length),
)}
{viewRow(
UNORGANIZED_ID,
t("common.unorganized"),
<FolderIcon className="size-4 shrink-0 opacity-70" />,
count(counts[UNORGANIZED_ID] ?? 0),
)}
</>
)}
{/* All decks + Unorganized are views, so they sit above Folders. */}
<div className={cn("space-y-0.5", !collapsed && "mb-1")}>
{viewRow(
ALL_FOLDERS,
t("common.allDecks"),
<LayersIcon className="size-4 shrink-0 opacity-70" />,
count(decks.length),
)}
{viewRow(
UNORGANIZED_ID,
t("common.unorganized"),
<FolderIcon className="size-4 shrink-0 opacity-70" />,
null,
)}
</div>
{/* Desktop: the Folders label sits between the views and the folders. */}
{desktop && !collapsed ? (
{!collapsed ? (
<div className="flex items-center justify-between px-2 pb-1 pt-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
{t("common.folders")}
@ -254,14 +187,13 @@ function NavContent({
<button
key={folder.id}
type="button"
onClick={() => select(folder.id)}
onClick={() => folderSelection.set(folder.id)}
aria-pressed={selected === folder.id}
title={folder.name}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
<FolderIcon className="size-4 shrink-0 opacity-70" />
{!collapsed ? <span className="flex-1 truncate text-left">{folder.name}</span> : null}
{desktop ? null : count(counts[folder.id] ?? 0)}
</button>
))}
@ -298,7 +230,6 @@ function NavContent({
<div className="mt-2 space-y-0.5 border-t border-[var(--hairline)] pt-2">
<Link
href="/stats"
onClick={onNavigate}
title={t("nav.stats")}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
@ -307,7 +238,6 @@ function NavContent({
</Link>
<Link
href="/exam"
onClick={onNavigate}
title={t("nav.exam")}
className={cn("nav-row", collapsed && "justify-center px-2")}
>
@ -350,7 +280,7 @@ function NavContent({
}
/* ------------------------------------------------------------------ */
/* Collapsed state (shared between rail and drawer) */
/* Collapsed state (desktop rail) */
/* ------------------------------------------------------------------ */
let collapsed = false;
@ -395,7 +325,7 @@ function useCollapsed(): boolean {
}
/* ------------------------------------------------------------------ */
/* Desktop rail + mobile drawer */
/* Desktop rail */
/* ------------------------------------------------------------------ */
export function Sidebar() {
@ -412,61 +342,12 @@ export function Sidebar() {
>
{/* One tall inset glass panel, so the ice scene shows around it. */}
<div className="sidebar-surface flex min-h-0 flex-1 flex-col px-2.5 py-3">
<NavContent collapsed={isCollapsed} desktop />
<NavContent collapsed={isCollapsed} />
</div>
</aside>
);
}
export function MobileSidebar() {
const open = useSidebarOpen();
const { t } = useI18n();
/* Back closes the drawer before it navigates away. */
const { skipRewind } = useBackClose(open, () => sidebarOpen.set(false));
return (
<AnimatePresence>
{open ? (
<motion.div
className="fixed inset-0 z-50 flex lg:hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<button
type="button"
aria-label={t("sidebar.closeMenu")}
onClick={() => sidebarOpen.set(false)}
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
/>
<motion.aside
aria-label={t("common.menu")}
initial={{ x: -280 }}
animate={{ x: 0 }}
exit={{ x: -280 }}
transition={springSheet}
drag="x"
dragConstraints={{ left: -280, right: 0 }}
dragElastic={{ left: 0.45, right: 0 }}
dragMomentum={false}
onDragEnd={(_, gesture) => {
if (gesture.offset.x < -80 || gesture.velocity.x < -500) sidebarOpen.set(false);
}}
className="sidebar-surface relative z-10 flex h-full w-[272px] flex-col rounded-l-none rounded-r-[1.5rem] px-2.5 py-3 pb-safe"
>
<NavContent
onNavigate={() => {
skipRewind();
sidebarOpen.set(false);
}}
/>
</motion.aside>
</motion.div>
) : null}
</AnimatePresence>
);
}
/** Spacer so page content clears the fixed rail (desktop only). */
export function SidebarOffset() {
const isCollapsed = useCollapsed();

View file

@ -4,7 +4,7 @@ import { motion, useReducedMotion } from "framer-motion";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { GearIcon, HomeIcon, PlusIcon } from "@/components/icons";
import { CompassIcon, FlameIcon, GearIcon, HomeIcon, PlusIcon } from "@/components/icons";
import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n";
import { springSnappy } from "@/lib/motion";
@ -12,20 +12,25 @@ import { openSettings } from "@/lib/ui-events";
import { cn } from "@/lib/utils";
/**
* iOS-style bottom tab bar. Rendered on the library and create screens on
* phones; hidden on desktop (the header carries the same actions there) and on
* the full-screen study feed.
* iOS-style bottom tab bar, phones only: Library · Progress · New deck (the
* prominent centre button) · Exam mode · Settings. The active item sits on a
* sliding pill (shared `layoutId`) and its icon gives a small bounce when you
* land on it; re-tapping the active tab scrolls back to the top.
*
* The active item sits on a sliding pill (shared `layoutId`), and its icon
* gives a small bounce when you land on it.
* Labels use the short `tab.*` keys so they fit on one line at 360px in both
* languages — no ellipsis, no wrapping.
*/
export function TabBar() {
const pathname = usePathname();
const { t } = useI18n();
const reduceMotion = useReducedMotion();
/* The wizards keep their own bottom action bar (Cancel / Next / Freeze) —
the flow is deliberately focused, so the tab bar steps aside there. */
const inWizard = pathname === "/create" || pathname === "/homework";
const item =
"relative flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium transition-colors";
"relative flex min-h-[52px] min-w-0 flex-1 flex-col items-center justify-center gap-1 py-2 text-[10.5px] font-medium leading-none transition-colors";
const active = "text-[var(--text)]";
const idle = "text-[var(--text-subtle)]";
@ -53,58 +58,67 @@ export function TabBar() {
);
const label = (text: string, isActive: boolean) => (
<span className={cn("relative z-10", isActive ? active : idle)}>{text}</span>
<span className={cn("relative z-10 whitespace-nowrap", isActive ? active : idle)}>{text}</span>
);
const libraryActive = pathname === "/";
const createActive = pathname === "/create";
const isActive = (key: "library" | "progress" | "create" | "exam") => {
if (key === "library") return pathname === "/";
if (key === "progress") return pathname === "/stats";
// The homework wizard is the other half of the New deck screen.
if (key === "create") return pathname === "/create" || pathname === "/homework";
return pathname === "/exam";
};
const link = (
key: "library" | "progress" | "create" | "exam",
href: string,
labelText: string,
iconNode: React.ReactNode,
) => {
const current = isActive(key);
return (
<Link
key={key}
href={href}
aria-current={current ? "page" : undefined}
className={cn(item, current ? active : idle)}
onClick={(event) => {
haptic("tap");
// Re-tapping the active tab scrolls back to the top.
if (current) {
event.preventDefault();
window.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });
}
}}
>
{pill(current)}
{icon(iconNode, current, key)}
{label(labelText, current)}
</Link>
);
};
if (inWizard) return null;
return (
<nav
aria-label={t("tabbar.main")}
className="tabbar fixed inset-x-0 bottom-0 z-40 flex items-stretch pb-safe sm:hidden"
>
<Link
href="/"
aria-current={libraryActive ? "page" : undefined}
className={cn(item, libraryActive ? active : idle)}
onClick={(event) => {
haptic("tap");
// Re-tapping the active tab scrolls back to the top.
if (libraryActive) {
event.preventDefault();
window.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });
}
}}
>
{pill(libraryActive)}
{icon(<HomeIcon className="size-6" />, libraryActive, "library")}
{label(t("common.library"), libraryActive)}
</Link>
<Link
href="/create"
aria-current={createActive ? "page" : undefined}
className={cn(item, createActive ? active : idle)}
onClick={(event) => {
haptic("tap");
if (createActive) {
event.preventDefault();
window.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });
}
}}
>
{pill(createActive)}
{icon(
<span className="grad-ice grid size-6 place-items-center rounded-full text-[#06111f]">
<PlusIcon className="size-4" />
</span>,
createActive,
"create",
)}
{label(t("common.newDeck"), createActive)}
</Link>
{link("library", "/", t("tab.library"), <HomeIcon className="size-6" />)}
{link("progress", "/stats", t("tab.progress"), <FlameIcon className="size-6" />)}
{link(
"create",
"/create",
t("tab.new"),
<span className="grad-ice grid size-7 place-items-center rounded-full text-[#06111f]">
<PlusIcon className="size-4" />
</span>,
)}
{link("exam", "/exam", t("tab.exam"), <CompassIcon className="size-6" />)}
<button
type="button"
aria-haspopup="dialog"
onClick={() => {
haptic("tap");
openSettings();
@ -112,7 +126,7 @@ export function TabBar() {
className={cn(item, idle)}
>
{icon(<GearIcon className="size-6" />, false, "settings")}
{label(t("common.settings"), false)}
{label(t("tab.settings"), false)}
</button>
</nav>
);

View file

@ -1054,8 +1054,8 @@ export function UploadScreen() {
</section>
{/* Dedicated flow: this page is the Study Deck uploader. Homework has
its own screen (/homework), reached from the library tab, the
drawer or the bottom bar — no mode picker here. */}
its own screen (/homework), reached from the mode switch above, the
centre tab or the library's homework tab. */}
{errorCard}

View file

@ -0,0 +1,39 @@
"use client";
import Link from "next/link";
import { SegmentedPill } from "@/components/SegmentedPill";
import { useI18n } from "@/lib/i18n";
import { useIsPhone } from "@/lib/use-phone";
import { cn } from "@/lib/utils";
/**
* In-page switch between the two wizards (New deck ⇄ Make my homework).
* Phones reach both through this control and the centre tab; desktop keeps
* the rail rows as well.
*/
export function WizardModeSwitch({ mode }: { mode: "deck" | "homework" }) {
const { t } = useI18n();
const isPhone = useIsPhone();
return (
<div className="segmented mb-5" role="group" aria-label={t("wizard.mode.aria")}>
<Link
href="/create"
aria-current={mode === "deck" ? "page" : undefined}
className={cn("relative", isPhone && "seg-slide")}
>
{isPhone && mode === "deck" ? <SegmentedPill layoutId="wizard-mode" /> : null}
<span className="seg-label relative z-10">{t("wizard.mode.deck")}</span>
</Link>
<Link
href="/homework"
aria-current={mode === "homework" ? "page" : undefined}
className={cn("relative", isPhone && "seg-slide")}
>
{isPhone && mode === "homework" ? <SegmentedPill layoutId="wizard-mode" /> : null}
<span className="seg-label relative z-10">{t("wizard.mode.homework")}</span>
</Link>
</div>
);
}

View file

@ -214,14 +214,6 @@ export function ChevronLeftIcon({ className }: IconProps) {
);
}
export function MenuIcon({ className }: IconProps) {
return (
<Svg className={className}>
<path d="M4 7h16M4 12h16M4 17h16" />
</Svg>
);
}
export function ChevronRightIcon({ className }: IconProps) {
return (
<Svg className={className}>

View file

@ -84,7 +84,6 @@ const en: Record<string, string> = {
"common.darkMode": "Dark mode",
"common.switchToLight": "Switch to light mode",
"common.switchToDark": "Switch to dark mode",
"common.menu": "Menu",
"common.back": "Back",
"common.next": "Next",
"common.more": "More",
@ -93,20 +92,24 @@ const en: Record<string, string> = {
"common.stepOf": "{n} / {total}",
/* header ---------------------------------------------------------- */
"header.openMenu": "Open menu",
"header.home": "Freezmo home",
"header.tagline": "study feed",
"header.openSettings": "Open settings and Ollama status",
/* tab bar --------------------------------------------------------- */
"tabbar.main": "Main",
/* Short tab labels: they must stay on one line at 360px in both languages. */
"tab.library": "Library",
"tab.progress": "Progress",
"tab.new": "New deck",
"tab.exam": "Exam",
"tab.settings": "Settings",
/* sidebar --------------------------------------------------------- */
"sidebar.collapse": "Collapse sidebar",
"sidebar.expand": "Expand sidebar",
"sidebar.library": "Library",
"sidebar.label": "Sidebar",
"sidebar.closeMenu": "Close menu",
/* language -------------------------------------------------------- */
"language.label": "Language",
@ -267,6 +270,9 @@ const en: Record<string, string> = {
/* make my homework ------------------------------------------------- */
"homework.nav": "Make my homework",
"wizard.mode.deck": "Study deck",
"wizard.mode.homework": "Make my homework",
"wizard.mode.aria": "Study deck or homework",
"homeworkHeroTitle": "Make my homework",
"homework.subtitle": "Paste it, photograph it or add a file — then pick how I should help.",
"homework.phone.assignment": "The assignment",
@ -343,7 +349,8 @@ const en: Record<string, string> = {
"library.title": "Your icebox",
"library.subtitle": "Decks frozen from your notes, saved in this browser.",
"library.import": "Import JSON",
"library.importAria": "Import deck JSON",
"library.moreActions": "More actions",
"library.moreTitle": "Library actions",
"library.folderCreated": "Folder created — decks you freeze while it's selected land here.",
"library.imported": "Imported “{title}”.",
"library.couldNotRead": "Could not read that file.",
@ -913,7 +920,6 @@ const cs: Record<string, string> = {
"common.darkMode": "Tmavý režim",
"common.switchToLight": "Přepnout na světlý režim",
"common.switchToDark": "Přepnout na tmavý režim",
"common.menu": "Menu",
"common.back": "Zpět",
"common.next": "Další",
"common.more": "Více",
@ -922,20 +928,24 @@ const cs: Record<string, string> = {
"common.stepOf": "{n} / {total}",
/* header ---------------------------------------------------------- */
"header.openMenu": "Otevřít menu",
"header.home": "Domů — Freezmo",
"header.tagline": "studijní feed",
"header.openSettings": "Otevřít nastavení a stav Ollamy",
/* tab bar --------------------------------------------------------- */
"tabbar.main": "Hlavní",
/* Krátké popisky záložek: musí zůstat na jednom řádku při 360px v obou jazycích. */
"tab.library": "Knihovna",
"tab.progress": "Pokrok",
"tab.new": "Nová sada",
"tab.exam": "Zkouška",
"tab.settings": "Nastavení",
/* sidebar --------------------------------------------------------- */
"sidebar.collapse": "Sbalit panel",
"sidebar.expand": "Rozbalit panel",
"sidebar.library": "Knihovna",
"sidebar.label": "Postranní panel",
"sidebar.closeMenu": "Zavřít menu",
/* language -------------------------------------------------------- */
"language.label": "Jazyk",
@ -1096,6 +1106,9 @@ const cs: Record<string, string> = {
/* make my homework ------------------------------------------------- */
"homework.nav": "Udělej mi úkol",
"wizard.mode.deck": "Studijní sada",
"wizard.mode.homework": "Udělej mi úkol",
"wizard.mode.aria": "Studijní sada nebo úkol",
"homeworkHeroTitle": "Udělej mi úkol",
"homework.subtitle": "Vlož ho, vyfoť nebo přidej soubor — a vyber, jak ti mám pomoct.",
"homework.phone.assignment": "Zadání",
@ -1172,7 +1185,8 @@ const cs: Record<string, string> = {
"library.title": "Tvoje mraznička",
"library.subtitle": "Sady zmrazené z tvých poznámek, uložené v tomto prohlížeči.",
"library.import": "Importovat JSON",
"library.importAria": "Importovat JSON sady",
"library.moreActions": "Další akce",
"library.moreTitle": "Akce knihovny",
"library.folderCreated": "Složka vytvořena — sady, které zmrazíš, když je vybraná, přistanou tady.",
"library.imported": "Importováno „{title}“.",
"library.couldNotRead": "Tento soubor se nepodařilo přečíst.",

View file

@ -8,7 +8,7 @@
*
* - `springSnappy` — press releases and small UI changes (180–220ms feel).
* - `springGentle` — screen content, lists, feedback panels (280–350ms).
* - `springSheet` — bottom sheets and the drawer, tuned for dragging.
* - `springSheet` — bottom sheets, tuned for dragging.
* - `EASE_OUT` — quick fades that don't need a spring.
*
* Only transform and opacity are ever animated (plus the explicit height
@ -27,7 +27,7 @@ export const springSnappy: Transition = { type: "spring", stiffness: 520, dampin
/** Content entering/exiting a screen or list. ~300ms, a whisper of overshoot. */
export const springGentle: Transition = { type: "spring", stiffness: 300, damping: 30, mass: 0.9 };
/** Sheets and the drawer: responsive to release velocity, settles fast. */
/** Bottom sheets: responsive to release velocity, settles fast. */
export const springSheet: Transition = { type: "spring", stiffness: 380, damping: 36, mass: 1 };
/** Crisp fade for elements that should not move. */

View file

@ -30,39 +30,6 @@ export const folderSelection = {
},
};
/* Mobile drawer ---------------------------------------------------------- */
let drawerOpen = false;
const drawerListeners = new Set<() => void>();
export const sidebarOpen = {
subscribe(listener: () => void): () => void {
drawerListeners.add(listener);
return () => drawerListeners.delete(listener);
},
getSnapshot(): boolean {
return drawerOpen;
},
getServerSnapshot(): boolean {
return false;
},
set(value: boolean): void {
drawerOpen = value;
drawerListeners.forEach((listener) => listener());
},
toggle(): void {
sidebarOpen.set(!drawerOpen);
},
};
export function useSidebarOpen(): boolean {
return useSyncExternalStore(
sidebarOpen.subscribe,
sidebarOpen.getSnapshot,
sidebarOpen.getServerSnapshot,
);
}
export function useSelectedFolder(): string {
return useSyncExternalStore(
folderSelection.subscribe,

View file

@ -10,8 +10,8 @@ import { useCallback, useEffect, useRef } from "react";
* standalone) closes the layer instead of leaving the page. Layers form a
* stack, so only the topmost one reacts. Closing a layer through its own UI
* pops the pushed entry again — unless the layer closed because of a
* navigation (a drawer link), in which case the entry is left for the
* permanent listener to skip on the next Back press.
* navigation (a link inside the layer), in which case the entry is left for
* the permanent listener to skip on the next Back press.
*/
type State = { freezmoLayer?: boolean };

View file

@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v27";
const CACHE_VERSION = "freezmo-v28";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;