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 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. 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 - **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 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 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 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 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 — 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** 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 tab 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 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 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 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 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** - **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 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`) action, and the active tab lives in the URL (`/?tab=homework`)
- **Phone library, decluttered**: no stats line, the folder row and “Unorganized” heading only - **Phone library, decluttered**: no stats line, the folder chips and “Unorganized” heading only
appear once folders exist (the drawer has *New folder*), and Import is a small icon button in appear once folders exist, and a **⋯** button next to the pool switcher opens a small sheet with
the tab row. Deck cards shrink to title + one meta line (activities · difficulty · best **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 score) + a one-line description that expands on tap, with the whole card tappable and a small
**Study** pill; homework cards match. **Study** pill; homework cards match.
- Decks saved locally with title, subject, folder, activity count, date, play count and best score - 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** **Design**
- **ChatGPT-style shell**: a persistent left **sidebar** (brand, a quiet "New deck" row, a *Folders* - **ChatGPT-style shell on desktop, five tabs on phones**: desktop keeps the persistent left
list with per-folder counts, Settings/Dark mode pinned to the bottom). It **collapses** to a 68px **sidebar** (brand, a quiet "New deck" row, a *Folders* list with per-folder counts,
icon rail (remembered in `localStorage`) and becomes a slide-in **drawer** behind a hamburger on Settings/Dark mode pinned to the bottom), which **collapses** to a 68px icon rail (remembered in
phones; mobile also keeps the frosted header + iOS tab bar `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; - **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 — 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 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 - Micro-interactions: 160–200ms colour/transform transitions, `active:scale-[0.985]`, skeleton
shimmer, hairline hover washes on every row and card shimmer, hairline hover washes on every row and card
- **Motion (phones)**: one shared language (`lib/motion.ts`) — every button, card, chip, tab and - **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 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 forward and backward and page content enters direction-aware; the Settings sheet and the library
follow the finger and settle on release velocity; library cards fade up in a capped stagger and actions sheet follow the finger and settle on release velocity; library cards fade up in a capped
animate out on delete; score and XP count up; correct answers pulse, wrong ones shake; 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 `prefers-reduced-motion` collapses everything to short fades. Only transform and opacity
animate — never blur. animate — never blur.
- **iOS-flavoured PWA**: standalone mode, safe-area insets, bottom tab bar, sheet-style dialogs with - **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). 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 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 safe areas, and has an iOS-style bottom tab bar (Library · Progress · New deck · Exam · Settings)
dialogs. Android Chrome also offers "Install app" from the ⋮ menu; Freezmo works offline for the 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. parts that don't need a model.
What's included: 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, phone and desktop check flows, the shorter follow-up with restore, the guide's explain action,
and the deck hand-off). 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 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 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()`); 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 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 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 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. 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 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 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 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 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. 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 **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 { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader"; import { AppHeader } from "@/components/AppHeader";
import { RouteTransition } from "@/components/RouteTransition"; import { RouteTransition } from "@/components/RouteTransition";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar"; import { Sidebar, SidebarOffset } from "@/components/Sidebar";
import { UploadScreen } from "@/components/UploadScreen"; import { UploadScreen } from "@/components/UploadScreen";
import { WizardModeSwitch } from "@/components/WizardModeSwitch";
export const metadata = { export const metadata = {
title: "New deck", title: "New deck",
@ -25,10 +26,10 @@ export default async function CreatePage({
<div className="flex min-h-dvh"> <div className="flex min-h-dvh">
<Sidebar /> <Sidebar />
<SidebarOffset /> <SidebarOffset />
<MobileSidebar />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col"> <main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader /> <AppHeader />
<div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16"> <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> <RouteTransition>
<UploadScreen /> <UploadScreen />
</RouteTransition> </RouteTransition>

View file

@ -1,7 +1,7 @@
import { ApiKeyGate } from "@/components/ApiKeyGate"; import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader"; import { AppHeader } from "@/components/AppHeader";
import { ExamScreen } from "@/components/exam/ExamScreen"; import { ExamScreen } from "@/components/exam/ExamScreen";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar"; import { Sidebar, SidebarOffset } from "@/components/Sidebar";
export const metadata = { export const metadata = {
title: "Exam mode", title: "Exam mode",
@ -13,7 +13,6 @@ export default function ExamPage() {
<div className="flex min-h-dvh"> <div className="flex min-h-dvh">
<Sidebar /> <Sidebar />
<SidebarOffset /> <SidebarOffset />
<MobileSidebar />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col"> <main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader /> <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"> <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 { AppHeader } from "@/components/AppHeader";
import { HomeworkScreen } from "@/components/homework/HomeworkScreen"; import { HomeworkScreen } from "@/components/homework/HomeworkScreen";
import { RouteTransition } from "@/components/RouteTransition"; 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 = { export const metadata = {
title: "Make my homework", title: "Make my homework",
@ -25,10 +26,10 @@ export default async function HomeworkPage({
<div className="flex min-h-dvh"> <div className="flex min-h-dvh">
<Sidebar /> <Sidebar />
<SidebarOffset /> <SidebarOffset />
<MobileSidebar />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col"> <main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader /> <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"> <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> <RouteTransition>
<HomeworkScreen initialRecordId={typeof open === "string" ? open : undefined} /> <HomeworkScreen initialRecordId={typeof open === "string" ? open : undefined} />
</RouteTransition> </RouteTransition>

View file

@ -2,8 +2,7 @@ import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader"; import { AppHeader } from "@/components/AppHeader";
import { LibraryScreen } from "@/components/LibraryScreen"; import { LibraryScreen } from "@/components/LibraryScreen";
import { RouteTransition } from "@/components/RouteTransition"; import { RouteTransition } from "@/components/RouteTransition";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar"; import { Sidebar, SidebarOffset } from "@/components/Sidebar";
import { TabBar } from "@/components/TabBar";
export default async function HomePage({ export default async function HomePage({
searchParams, searchParams,
@ -16,7 +15,6 @@ export default async function HomePage({
<div className="flex min-h-dvh"> <div className="flex min-h-dvh">
<Sidebar /> <Sidebar />
<SidebarOffset /> <SidebarOffset />
<MobileSidebar />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col"> <main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader /> <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"> <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"} /> <LibraryScreen initialTab={tab === "homework" ? "homework" : "decks"} />
</RouteTransition> </RouteTransition>
</div> </div>
<TabBar />
</main> </main>
</div> </div>
</ApiKeyGate> </ApiKeyGate>

View file

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

View file

@ -7,20 +7,21 @@ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "reac
import { DeckCard } from "@/components/DeckCard"; import { DeckCard } from "@/components/DeckCard";
import { FolderBar } from "@/components/FolderBar"; import { FolderBar } from "@/components/FolderBar";
import { HomeworkCard } from "@/components/HomeworkCard"; import { HomeworkCard } from "@/components/HomeworkCard";
import { FolderIcon, UploadIcon } from "@/components/icons"; import { EllipsisIcon, FolderIcon, UploadIcon } from "@/components/icons";
import { SegmentedPill } from "@/components/SegmentedPill"; import { SegmentedPill } from "@/components/SegmentedPill";
import { Button, EmptyState, ProgressBar } from "@/components/ui"; 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 { deleteHomework, homeworkStore, sortHomework } from "@/lib/homework";
import { learningStore } from "@/lib/history"; import { learningStore } from "@/lib/history";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { computeStats, dueCount, mistakeCount } from "@/lib/learning"; import { computeStats, dueCount, mistakeCount } from "@/lib/learning";
import { settingsStore } from "@/lib/settings"; import { settingsStore } from "@/lib/settings";
import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav"; 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 { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
import { deckStore, deleteFolder, importDeck, sortDecks, upsertDeck } from "@/lib/storage"; import { deckStore, deleteFolder, importDeck, sortDecks, upsertDeck } from "@/lib/storage";
import { backupNudgeDue, downloadBackup, snoozeBackupNudge } from "@/lib/backup"; import { backupNudgeDue, downloadBackup, snoozeBackupNudge } from "@/lib/backup";
import { useBackClose } from "@/lib/use-back-close";
import { useIsPhone } from "@/lib/use-phone"; import { useIsPhone } from "@/lib/use-phone";
import { cn } from "@/lib/utils"; 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 [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null); const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [nudgeDue, setNudgeDue] = useState(false); 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(() => { useEffect(() => {
setNudgeDue(backupNudgeDue(decks.length)); setNudgeDue(backupNudgeDue(decks.length));
@ -202,18 +237,19 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
</div> </div>
<button <button
type="button" type="button"
onClick={() => importRef.current?.click()} onClick={() => setSheetOpen(true)}
aria-label={t("library.importAria")} aria-label={t("library.moreActions")}
title={t("library.import")} 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" 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> </button>
</div> </div>
{/* Mobile folder switcher; the sidebar covers desktop. Hidden while {/* Mobile folder switcher; the desktop rail covers desktop. Shown only
there are no folders — the drawer's “New folder” is the way in. */} once at least one folder exists — creating one lives in the ⋯ sheet
{tab === "decks" && !isEmpty && hasFolders ? ( and in the New deck options step. */}
{tab === "decks" && hasFolders ? (
<FolderBar <FolderBar
className="lg:hidden" className="lg:hidden"
folders={folderList} folders={folderList}
@ -288,12 +324,6 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
> >
{t("review.mistakesTitle")} · {plural(mistakes, "review.mistakes")} {t("review.mistakesTitle")} · {plural(mistakes, "review.mistakes")}
</Link> </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> </div>
</section> </section>
@ -498,6 +528,93 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
</motion.div> </motion.div>
</AnimatePresence> </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> </div>
); );
} }

View file

@ -2,7 +2,6 @@
import Link from "next/link"; import Link from "next/link";
import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react"; import { useMemo, useState, useSyncExternalStore, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { import {
ChevronLeftIcon, ChevronLeftIcon,
@ -21,26 +20,19 @@ import { useTheme } from "@/components/ThemeToggle";
import { createFolder, folderStore } from "@/lib/folders"; import { createFolder, folderStore } from "@/lib/folders";
import { haptic } from "@/lib/haptics"; import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { springSheet } from "@/lib/motion"; import { ALL_FOLDERS, folderSelection, useSelectedFolder } from "@/lib/nav";
import { ALL_FOLDERS, folderSelection, sidebarOpen, useSelectedFolder, useSidebarOpen } from "@/lib/nav";
import { UNORGANIZED_ID } from "@/lib/schemas"; import { UNORGANIZED_ID } from "@/lib/schemas";
import { deckStore } from "@/lib/storage"; import { deckStore } from "@/lib/storage";
import { openSettings } from "@/lib/ui-events"; import { openSettings } from "@/lib/ui-events";
import { useBackClose } from "@/lib/use-back-close";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const COLLAPSE_KEY = "freezmo.sidebarCollapsed"; const COLLAPSE_KEY = "freezmo.sidebarCollapsed";
/** Shared nav content for the desktop rail and the mobile drawer. */ /**
function NavContent({ * Desktop navigation rail (lg and up). Phones use the bottom tab bar instead —
collapsed = false, * there is no drawer any more, so this is the only consumer of the rail.
onNavigate, */
desktop = false, function NavContent({ collapsed = false }: { collapsed?: boolean }) {
}: {
collapsed?: boolean;
onNavigate?: () => void;
desktop?: boolean;
}) {
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot); const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
const folders = useSyncExternalStore( const folders = useSyncExternalStore(
folderStore.subscribe, folderStore.subscribe,
@ -60,16 +52,6 @@ function NavContent({
[folders], [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 submit = () => {
const result = createFolder(draft); const result = createFolder(draft);
if ("error" in result) { if ("error" in result) {
@ -82,20 +64,15 @@ function NavContent({
setCreating(false); 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. */ /** Counts of 0 are noise — only show a count when there is something in it. */
const count = (value: number) => const count = (value: number) =>
!collapsed && value > 0 ? <span className="text-[12px] tabular-nums opacity-60">{value}</span> : null; !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) => ( const viewRow = (id: string, label: string, icon: ReactNode, trailing: ReactNode) => (
<button <button
type="button" type="button"
onClick={() => select(id)} onClick={() => folderSelection.set(id)}
aria-pressed={selected === id} aria-pressed={selected === id}
title={label} title={label}
className={cn("nav-row", collapsed && "justify-center px-2")} 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")}> <div className={cn("mb-1 flex items-center", collapsed ? "justify-center" : "justify-between")}>
<Link <Link
href="/" href="/"
onClick={onNavigate}
className={cn("nav-row gap-2.5", collapsed ? "w-auto px-2" : "flex-1")} className={cn("nav-row gap-2.5", collapsed ? "w-auto px-2" : "flex-1")}
aria-label={t("header.home")} aria-label={t("header.home")}
title="Freezmo" title="Freezmo"
@ -144,7 +120,6 @@ function NavContent({
{/* new deck: a quiet row, not a competing primary button ---------- */} {/* new deck: a quiet row, not a competing primary button ---------- */}
<Link <Link
href="/create" href="/create"
onClick={onNavigate}
className={cn("nav-row mb-3", collapsed && "justify-center px-2")} className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
title={t("common.newDeck")} title={t("common.newDeck")}
> >
@ -155,7 +130,6 @@ function NavContent({
{/* make my homework: same quiet row --------------------------------- */} {/* make my homework: same quiet row --------------------------------- */}
<Link <Link
href="/homework" href="/homework"
onClick={onNavigate}
className={cn("nav-row mb-3", collapsed && "justify-center px-2")} className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
title={t("homework.nav")} title={t("homework.nav")}
> >
@ -163,29 +137,7 @@ function NavContent({
{!collapsed ? <span className="flex-1 truncate text-left">{t("homework.nav")}</span> : null} {!collapsed ? <span className="flex-1 truncate text-left">{t("homework.nav")}</span> : null}
</Link> </Link>
{/* folders --------------------------------------------------------- */} <nav aria-label={t("sidebar.library")} className="thin-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto">
{/* 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"
>
{collapsed ? ( {collapsed ? (
<button <button
type="button" type="button"
@ -198,9 +150,7 @@ function NavContent({
</button> </button>
) : null} ) : null}
{/* Desktop: All decks + Unorganized are views, so they sit above the {/* All decks + Unorganized are views, so they sit above Folders. */}
Folders label; mobile keeps them inside the folder list. */}
{desktop ? (
<div className={cn("space-y-0.5", !collapsed && "mb-1")}> <div className={cn("space-y-0.5", !collapsed && "mb-1")}>
{viewRow( {viewRow(
ALL_FOLDERS, ALL_FOLDERS,
@ -215,25 +165,8 @@ function NavContent({
null, null,
)} )}
</div> </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),
)}
</>
)}
{/* Desktop: the Folders label sits between the views and the folders. */} {!collapsed ? (
{desktop && !collapsed ? (
<div className="flex items-center justify-between px-2 pb-1 pt-1"> <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)]"> <span className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
{t("common.folders")} {t("common.folders")}
@ -254,14 +187,13 @@ function NavContent({
<button <button
key={folder.id} key={folder.id}
type="button" type="button"
onClick={() => select(folder.id)} onClick={() => folderSelection.set(folder.id)}
aria-pressed={selected === folder.id} aria-pressed={selected === folder.id}
title={folder.name} title={folder.name}
className={cn("nav-row", collapsed && "justify-center px-2")} className={cn("nav-row", collapsed && "justify-center px-2")}
> >
<FolderIcon className="size-4 shrink-0 opacity-70" /> <FolderIcon className="size-4 shrink-0 opacity-70" />
{!collapsed ? <span className="flex-1 truncate text-left">{folder.name}</span> : null} {!collapsed ? <span className="flex-1 truncate text-left">{folder.name}</span> : null}
{desktop ? null : count(counts[folder.id] ?? 0)}
</button> </button>
))} ))}
@ -298,7 +230,6 @@ function NavContent({
<div className="mt-2 space-y-0.5 border-t border-[var(--hairline)] pt-2"> <div className="mt-2 space-y-0.5 border-t border-[var(--hairline)] pt-2">
<Link <Link
href="/stats" href="/stats"
onClick={onNavigate}
title={t("nav.stats")} title={t("nav.stats")}
className={cn("nav-row", collapsed && "justify-center px-2")} className={cn("nav-row", collapsed && "justify-center px-2")}
> >
@ -307,7 +238,6 @@ function NavContent({
</Link> </Link>
<Link <Link
href="/exam" href="/exam"
onClick={onNavigate}
title={t("nav.exam")} title={t("nav.exam")}
className={cn("nav-row", collapsed && "justify-center px-2")} 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; let collapsed = false;
@ -395,7 +325,7 @@ function useCollapsed(): boolean {
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Desktop rail + mobile drawer */ /* Desktop rail */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
export function Sidebar() { export function Sidebar() {
@ -412,61 +342,12 @@ export function Sidebar() {
> >
{/* One tall inset glass panel, so the ice scene shows around it. */} {/* 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"> <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> </div>
</aside> </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). */ /** Spacer so page content clears the fixed rail (desktop only). */
export function SidebarOffset() { export function SidebarOffset() {
const isCollapsed = useCollapsed(); const isCollapsed = useCollapsed();

View file

@ -4,7 +4,7 @@ import { motion, useReducedMotion } from "framer-motion";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; 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 { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { springSnappy } from "@/lib/motion"; import { springSnappy } from "@/lib/motion";
@ -12,20 +12,25 @@ import { openSettings } from "@/lib/ui-events";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** /**
* iOS-style bottom tab bar. Rendered on the library and create screens on * iOS-style bottom tab bar, phones only: Library · Progress · New deck (the
* phones; hidden on desktop (the header carries the same actions there) and on * prominent centre button) · Exam mode · Settings. The active item sits on a
* the full-screen study feed. * 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 * Labels use the short `tab.*` keys so they fit on one line at 360px in both
* gives a small bounce when you land on it. * languages — no ellipsis, no wrapping.
*/ */
export function TabBar() { export function TabBar() {
const pathname = usePathname(); const pathname = usePathname();
const { t } = useI18n(); const { t } = useI18n();
const reduceMotion = useReducedMotion(); 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 = 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 active = "text-[var(--text)]";
const idle = "text-[var(--text-subtle)]"; const idle = "text-[var(--text-subtle)]";
@ -53,58 +58,67 @@ export function TabBar() {
); );
const label = (text: string, isActive: boolean) => ( 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 isActive = (key: "library" | "progress" | "create" | "exam") => {
const createActive = pathname === "/create"; 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 ( return (
<nav <nav
aria-label={t("tabbar.main")} aria-label={t("tabbar.main")}
className="tabbar fixed inset-x-0 bottom-0 z-40 flex items-stretch pb-safe sm:hidden" className="tabbar fixed inset-x-0 bottom-0 z-40 flex items-stretch pb-safe sm:hidden"
> >
<Link {link("library", "/", t("tab.library"), <HomeIcon className="size-6" />)}
href="/" {link("progress", "/stats", t("tab.progress"), <FlameIcon className="size-6" />)}
aria-current={libraryActive ? "page" : undefined} {link(
className={cn(item, libraryActive ? active : idle)} "create",
onClick={(event) => { "/create",
haptic("tap"); t("tab.new"),
// Re-tapping the active tab scrolls back to the top. <span className="grad-ice grid size-7 place-items-center rounded-full text-[#06111f]">
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" /> <PlusIcon className="size-4" />
</span>, </span>,
createActive,
"create",
)} )}
{label(t("common.newDeck"), createActive)} {link("exam", "/exam", t("tab.exam"), <CompassIcon className="size-6" />)}
</Link>
<button <button
type="button" type="button"
aria-haspopup="dialog"
onClick={() => { onClick={() => {
haptic("tap"); haptic("tap");
openSettings(); openSettings();
@ -112,7 +126,7 @@ export function TabBar() {
className={cn(item, idle)} className={cn(item, idle)}
> >
{icon(<GearIcon className="size-6" />, false, "settings")} {icon(<GearIcon className="size-6" />, false, "settings")}
{label(t("common.settings"), false)} {label(t("tab.settings"), false)}
</button> </button>
</nav> </nav>
); );

View file

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

View file

@ -84,7 +84,6 @@ const en: Record<string, string> = {
"common.darkMode": "Dark mode", "common.darkMode": "Dark mode",
"common.switchToLight": "Switch to light mode", "common.switchToLight": "Switch to light mode",
"common.switchToDark": "Switch to dark mode", "common.switchToDark": "Switch to dark mode",
"common.menu": "Menu",
"common.back": "Back", "common.back": "Back",
"common.next": "Next", "common.next": "Next",
"common.more": "More", "common.more": "More",
@ -93,20 +92,24 @@ const en: Record<string, string> = {
"common.stepOf": "{n} / {total}", "common.stepOf": "{n} / {total}",
/* header ---------------------------------------------------------- */ /* header ---------------------------------------------------------- */
"header.openMenu": "Open menu",
"header.home": "Freezmo home", "header.home": "Freezmo home",
"header.tagline": "study feed", "header.tagline": "study feed",
"header.openSettings": "Open settings and Ollama status", "header.openSettings": "Open settings and Ollama status",
/* tab bar --------------------------------------------------------- */ /* tab bar --------------------------------------------------------- */
"tabbar.main": "Main", "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 --------------------------------------------------------- */
"sidebar.collapse": "Collapse sidebar", "sidebar.collapse": "Collapse sidebar",
"sidebar.expand": "Expand sidebar", "sidebar.expand": "Expand sidebar",
"sidebar.library": "Library", "sidebar.library": "Library",
"sidebar.label": "Sidebar", "sidebar.label": "Sidebar",
"sidebar.closeMenu": "Close menu",
/* language -------------------------------------------------------- */ /* language -------------------------------------------------------- */
"language.label": "Language", "language.label": "Language",
@ -267,6 +270,9 @@ const en: Record<string, string> = {
/* make my homework ------------------------------------------------- */ /* make my homework ------------------------------------------------- */
"homework.nav": "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", "homeworkHeroTitle": "Make my homework",
"homework.subtitle": "Paste it, photograph it or add a file — then pick how I should help.", "homework.subtitle": "Paste it, photograph it or add a file — then pick how I should help.",
"homework.phone.assignment": "The assignment", "homework.phone.assignment": "The assignment",
@ -343,7 +349,8 @@ const en: Record<string, string> = {
"library.title": "Your icebox", "library.title": "Your icebox",
"library.subtitle": "Decks frozen from your notes, saved in this browser.", "library.subtitle": "Decks frozen from your notes, saved in this browser.",
"library.import": "Import JSON", "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.folderCreated": "Folder created — decks you freeze while it's selected land here.",
"library.imported": "Imported “{title}”.", "library.imported": "Imported “{title}”.",
"library.couldNotRead": "Could not read that file.", "library.couldNotRead": "Could not read that file.",
@ -913,7 +920,6 @@ const cs: Record<string, string> = {
"common.darkMode": "Tmavý režim", "common.darkMode": "Tmavý režim",
"common.switchToLight": "Přepnout na světlý režim", "common.switchToLight": "Přepnout na světlý režim",
"common.switchToDark": "Přepnout na tmavý režim", "common.switchToDark": "Přepnout na tmavý režim",
"common.menu": "Menu",
"common.back": "Zpět", "common.back": "Zpět",
"common.next": "Další", "common.next": "Další",
"common.more": "Více", "common.more": "Více",
@ -922,20 +928,24 @@ const cs: Record<string, string> = {
"common.stepOf": "{n} / {total}", "common.stepOf": "{n} / {total}",
/* header ---------------------------------------------------------- */ /* header ---------------------------------------------------------- */
"header.openMenu": "Otevřít menu",
"header.home": "Domů — Freezmo", "header.home": "Domů — Freezmo",
"header.tagline": "studijní feed", "header.tagline": "studijní feed",
"header.openSettings": "Otevřít nastavení a stav Ollamy", "header.openSettings": "Otevřít nastavení a stav Ollamy",
/* tab bar --------------------------------------------------------- */ /* tab bar --------------------------------------------------------- */
"tabbar.main": "Hlavní", "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 --------------------------------------------------------- */
"sidebar.collapse": "Sbalit panel", "sidebar.collapse": "Sbalit panel",
"sidebar.expand": "Rozbalit panel", "sidebar.expand": "Rozbalit panel",
"sidebar.library": "Knihovna", "sidebar.library": "Knihovna",
"sidebar.label": "Postranní panel", "sidebar.label": "Postranní panel",
"sidebar.closeMenu": "Zavřít menu",
/* language -------------------------------------------------------- */ /* language -------------------------------------------------------- */
"language.label": "Jazyk", "language.label": "Jazyk",
@ -1096,6 +1106,9 @@ const cs: Record<string, string> = {
/* make my homework ------------------------------------------------- */ /* make my homework ------------------------------------------------- */
"homework.nav": "Udělej mi úkol", "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", "homeworkHeroTitle": "Udělej mi úkol",
"homework.subtitle": "Vlož ho, vyfoť nebo přidej soubor — a vyber, jak ti mám pomoct.", "homework.subtitle": "Vlož ho, vyfoť nebo přidej soubor — a vyber, jak ti mám pomoct.",
"homework.phone.assignment": "Zadání", "homework.phone.assignment": "Zadání",
@ -1172,7 +1185,8 @@ const cs: Record<string, string> = {
"library.title": "Tvoje mraznička", "library.title": "Tvoje mraznička",
"library.subtitle": "Sady zmrazené z tvých poznámek, uložené v tomto prohlížeči.", "library.subtitle": "Sady zmrazené z tvých poznámek, uložené v tomto prohlížeči.",
"library.import": "Importovat JSON", "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.folderCreated": "Složka vytvořena — sady, které zmrazíš, když je vybraná, přistanou tady.",
"library.imported": "Importováno „{title}“.", "library.imported": "Importováno „{title}“.",
"library.couldNotRead": "Tento soubor se nepodařilo přečíst.", "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). * - `springSnappy` — press releases and small UI changes (180–220ms feel).
* - `springGentle` — screen content, lists, feedback panels (280–350ms). * - `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. * - `EASE_OUT` — quick fades that don't need a spring.
* *
* Only transform and opacity are ever animated (plus the explicit height * 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. */ /** 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 }; 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 }; export const springSheet: Transition = { type: "spring", stiffness: 380, damping: 36, mass: 1 };
/** Crisp fade for elements that should not move. */ /** 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 { export function useSelectedFolder(): string {
return useSyncExternalStore( return useSyncExternalStore(
folderSelection.subscribe, 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 * 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 * 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 * 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 * navigation (a link inside the layer), in which case the entry is left for
* permanent listener to skip on the next Back press. * the permanent listener to skip on the next Back press.
*/ */
type State = { freezmoLayer?: boolean }; type State = { freezmoLayer?: boolean };

View file

@ -9,7 +9,7 @@
* *
* Bumping CACHE_VERSION is how a new deploy invalidates old assets. * 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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;