diff --git a/README.md b/README.md index 75c908f..094e912 100644 --- a/README.md +++ b/README.md @@ -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 `` 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 diff --git a/app/create/page.tsx b/app/create/page.tsx index 538505e..d228053 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -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({
-
+ diff --git a/app/exam/page.tsx b/app/exam/page.tsx index 9db4110..46b4796 100644 --- a/app/exam/page.tsx +++ b/app/exam/page.tsx @@ -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() {
-
diff --git a/app/homework/page.tsx b/app/homework/page.tsx index 7e10e0c..39412a5 100644 --- a/app/homework/page.tsx +++ b/app/homework/page.tsx @@ -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({
-
+ diff --git a/app/page.tsx b/app/page.tsx index 4a8d0bf..1b3eeaa 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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({
-
@@ -24,7 +22,6 @@ export default async function HomePage({
-
diff --git a/app/share/page.tsx b/app/share/page.tsx index 704fd02..ffb66f5 100644 --- a/app/share/page.tsx +++ b/app/share/page.tsx @@ -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({
-
diff --git a/app/stats/page.tsx b/app/stats/page.tsx index d0d6c7d..984d241 100644 --- a/app/stats/page.tsx +++ b/app/stats/page.tsx @@ -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() {
-
diff --git a/components/AppHeader.tsx b/components/AppHeader.tsx index a5fea35..74f9417 100644 --- a/components/AppHeader.tsx +++ b/components/AppHeader.tsx @@ -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; )} >
- - - {/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */} - - - - Freezmo - + {/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */} + + + Freezmo {!compact ? ( - + {t("header.tagline")} ) : null} -
- {/* Settings lives in the tab bar and the drawer; the old header - gear was the same control a third time. */} +
+ setSettingsOpen(false)} diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx index 9f37127..604f0da 100644 --- a/components/LibraryScreen.tsx +++ b/components/LibraryScreen.tsx @@ -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(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(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" |
- {/* 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 ? ( {t("review.mistakesTitle")} · {plural(mistakes, "review.mistakes")} - - {t("exam.title")} -
@@ -498,6 +528,93 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | + {/* phone "⋯" sheet: Import JSON + New folder ---------------------- */} + + {sheetOpen ? ( + + + +
+
+ ) : ( +
+ + +
+ )} + + + ) : null} + +
); } diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 5cea6ee..0ed7791 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -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 = { [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 ? {value} : 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) => ( -
- ) : null} - -