Native-feel pass: PWA integration, system back, offline, haptics, undo

Mobile only (<=639px / coarse pointers); desktop keeps its behaviour.

System integration
- Manifest: id, display_override, monochrome icon, Make my homework
  shortcut and a share target (text/URL via GET, files via POST).
  iOS startup images for the common iPhone sizes. Pinch-zoom is allowed
  again (double-tap zoom is disabled via touch-action instead).
- Share target: /share chooser with New deck / Make my homework; POSTed
  files wait in a 10-minute in-memory store, reach the wizard through
  sessionStorage and never touch disk.
- Web Share: Share rows for decks (JSON file where supported) and the
  homework result, with a clipboard fallback and a toast.
- Offline: a quiet pill appears and Freeze / mode cards are disabled
  with a clear reason; input is never lost (error cards keep Retry).
- State: wizard drafts (text, options, photos up to a size budget)
  survive reloads and OS kills and are cleared on success; re-tapping
  the active tab scrolls to the top; the deck page shows a skeleton
  instead of a spinner.

Navigation and gestures
- System Back closes the topmost layer first: a small history-entry
  stack (lib/use-back-close.ts) covers the drawer, Settings sheet,
  install sheet, deck actions panel and one wizard step; navigations
  are never rewound and stale entries are skipped.
- Destructive-but-reversible actions use Undo toasts: deleting a deck
  (no confirm) and removing a file in the wizard. Irreversible ones
  keep their confirm.

Input and feedback
- Every phone input got inputmode / enterkeyhint / autocomplete /
  autocapitalize / spellcheck; Enter on the last field advances the
  wizard; a clipboard-paste button appears where the API exists.
- Haptics behind a Settings toggle (on by default): tab switches,
  toggles, correct/wrong, completion; Vibration API + the iOS hidden
  switch tick, throttled and silent everywhere else.
- Wake lock during the study feed and homework guide, released on
  leave and re-acquired when the tab returns.

Verified: new native suite 35/35 (manifest/head/viewport, offline,
share target GET + POST, back-gesture layers, undo, share fallback,
tab re-tap, haptics persistence, draft restore), regression 74/74,
mobile 52/52, wizard 80/80, declutter 116/116, animation 8/8,
i18n 25/25. Lint + build clean. SW freezmo-v23.

Needs a real device (documented in the README): iOS haptic tick,
system share sheet entry, wake-lock grant, iPhone startup images,
iOS edge-swipe back, install prompts. Pull-to-refresh and the app
badge were skipped on purpose.
This commit is contained in:
Martin Dvořák 2026-10-10 12:16:03 +00:00
commit 4369ba1a17
34 changed files with 1354 additions and 65 deletions

View file

@ -53,6 +53,15 @@ no database, no data leaving your machine except the requests you send to Ollama
*Edit* link back to its step. A flat Back/Cancel + Next/Freeze bar sits at the bottom above *Edit* link back to its step. A flat Back/Cancel + Next/Freeze bar sits at the bottom above
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
(sheet, drawer, actions panel, 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
switches, toggles, correct/wrong answers and completion; the screen stays awake during
study sessions and homework guides; wizard drafts survive a reload or an OS kill; re-tapping
the active tab scrolls to the top; an offline pill appears and generation is disabled with
a reason until the connection is back.
- **Photos and documents**: JPG/PNG/WEBP/HEIC images plus **PDF, DOCX, PPTX, ODT/ODP/ODS, TXT, MD, CSV** - **Photos and documents**: JPG/PNG/WEBP/HEIC images plus **PDF, DOCX, PPTX, ODT/ODP/ODS, TXT, MD, CSV**
- Documents are read **in the browser**: PDF text layers are extracted, scanned PDF pages are rendered - Documents are read **in the browser**: PDF text layers are extracted, scanned PDF pages are rendered
as images for the vision model, DOCX/PPTX text, speaker notes and embedded images are pulled out. as images for the vision model, DOCX/PPTX text, speaker notes and embedded images are pulled out.
@ -505,12 +514,13 @@ What's included:
| Piece | Detail | | Piece | Detail |
| --- | --- | | --- | --- |
| `app/manifest.ts` | `display: standalone`, portrait, theme/background colours, app shortcuts | | `app/manifest.ts` | `id`, `display: standalone`, portrait, theme/background colours, `any` + `maskable` + `monochrome` icons, three app shortcuts (New deck, Make my homework, Library) and a **share target** (text/URL via GET, files via POST) |
| Icons | 192/512 px, a maskable 512 px icon, and 180/167/152 px `apple-touch-icon`s, all rendered from `assets/app-icon-source.jpeg` | | Icons | 192/512 px, a maskable 512 px icon, a monochrome SVG and 180/167/152 px `apple-touch-icon`s, all rendered from `assets/app-icon-source.jpeg` |
| iOS meta | `apple-mobile-web-app-capable`, `-title`, `-status-bar-style`, `viewport-fit=cover` | | iOS meta | `apple-mobile-web-app-capable`, `-title`, `-status-bar-style`, `viewport-fit=cover`, plus **startup images** for the common iPhone sizes so launch has no white flash |
| Service worker | `public/sw.js`, **production only**: cached app shell, stale-while-revalidate for static assets, network-first navigations with an `/offline` fallback. `/api/*` is never cached | | Service worker | `public/sw.js`, **production only**: cached app shell, stale-while-revalidate for static assets, network-first navigations with an `/offline` fallback. `/api/*` is never cached |
| Offline page | `/offline` — "your decks are still here, reconnect to generate" | | Offline page | `/offline` — "your decks are still here, reconnect to generate" |
| Safe areas | `pb-safe` / `pt-safe` utilities plus `pb-tabbar` so nothing hides behind the tab bar | | Safe areas | `pb-safe` / `pt-safe` utilities plus `pb-tabbar` so nothing hides behind the tab bar |
| Share target | `/share` chooser (New deck / Make my homework); POSTed files wait in a short-lived in-memory store, are handed to the wizard through `sessionStorage`, and are never written to disk |
The icons are generated by `scripts/make-icons.mjs` from `assets/app-icon-source.jpeg` (headless The icons are generated by `scripts/make-icons.mjs` from `assets/app-icon-source.jpeg` (headless
Chromium); it is a one-off tool and is not part of the app's dependencies. It also writes Chromium); it is a one-off tool and is not part of the app's dependencies. It also writes
@ -884,10 +894,11 @@ Things I chose while building, with the reasoning:
device, uses Chrome's deferred `beforeinstallprompt` for a one-tap install where available, and device, uses Chrome's deferred `beforeinstallprompt` for a one-tap install where available, and
stays reachable from Settings. It is mounted in the root layout, so it can appear over any stays reachable from Settings. It is mounted in the root layout, so it can appear over any
screen — including the key gate — without blocking the page underneath. screen — including the key gate — without blocking the page underneath.
30. **Phones don't zoom, and mobile fields are 16px.** The viewport sets `maximum-scale=1` / 30. **Phones use touch-action, not a zoom lock.** The viewport allows pinch-zoom (accessibility),
`user-scalable=no` for an app-like feel, and a mobile-only rule keeps every input and textarea at and double-tap zoom is disabled by `touch-action: manipulation` on the UI chrome instead. A
16px so iOS Safari's focus auto-zoom never fires (iOS ignores `user-scalable=no` for that). mobile-only rule keeps every input and textarea at 16px so iOS Safari's focus auto-zoom never
Desktop keeps the designed 12–14px field sizes. fires (iOS ignores `user-scalable=no` for that anyway). Desktop keeps the designed 12–14px
field sizes.
31. **The app icon drives the palette.** The navy rounded-square artwork in 31. **The app icon drives the palette.** The navy rounded-square artwork in
`assets/app-icon-source.jpeg` is the source of truth: `frost-*` is navy-tinted, the accent is `assets/app-icon-source.jpeg` is the source of truth: `frost-*` is navy-tinted, the accent is
glacier cyan, the ice scene uses the artwork's blues, the primary CTA is an icy gradient, and glacier cyan, the ice scene uses the artwork's blues, the primary CTA is an icy gradient, and
@ -970,6 +981,26 @@ Things I chose while building, with the reasoning:
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 drawer 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
topmost layer through a small history-entry stack (`lib/use-back-close.ts`): each layer pushes
a same-URL entry, only the top of the stack reacts, and a layer closed by a navigation leaves
its entry for the permanent listener to skip. Haptics are best-effort behind a Settings toggle
(Vibration API; the hidden `<input switch>` tick on iOS, throttled); the wake lock is requested
only while a study feed or guide is open and released on leave. Destructive-but-reversible
actions (delete deck, remove file) became Undo toasts; irreversible ones keep their confirm.
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
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
haptic tick, the Android/iOS **share target** entry in the system share sheet, the actual
**wake lock** grant, the **startup images** on an installed iPhone, the iOS **edge-swipe**
back gesture in standalone, and installability/rich-install prompts (Lighthouse covers the
manifest side). Pull-to-refresh and the app badge were skipped on purpose — the Settings
refresh icon already re-checks the connection, and there is no meaningful badge number.
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not 14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`). support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).
Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision

View file

@ -6,7 +6,7 @@ import { useEffect, useState } from "react";
import { ApiKeyGate } from "@/components/ApiKeyGate"; import { ApiKeyGate } from "@/components/ApiKeyGate";
import { StudyFeed } from "@/components/StudyFeed"; import { StudyFeed } from "@/components/StudyFeed";
import { Button, Spinner } from "@/components/ui"; import { Button } from "@/components/ui";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { Deck } from "@/lib/schemas"; import type { Deck } from "@/lib/schemas";
import { getDeck } from "@/lib/storage"; import { getDeck } from "@/lib/storage";
@ -29,9 +29,15 @@ export default function DeckPage() {
if (!loaded) { if (!loaded) {
return ( return (
<main className="frost-bg grid min-h-dvh place-items-center"> <main className="frost-bg grid min-h-dvh place-items-center px-4">
<div className="relative flex items-center gap-3 text-sm font-semibold text-frost-800 dark:text-frost-100"> <div className="w-full max-w-xl space-y-4" aria-busy="true" aria-label={t("deckPage.loading")}>
<Spinner className="size-5" /> {t("deckPage.loading")} <div className="flex items-center gap-3">
<div className="skeleton size-9 rounded-xl" />
<div className="skeleton h-2 flex-1 rounded-full" />
</div>
<div className="skeleton h-24 rounded-3xl" />
<div className="skeleton h-12 rounded-2xl" />
<div className="skeleton h-12 w-2/3 rounded-2xl" />
</div> </div>
</main> </main>
); );

View file

@ -2,7 +2,9 @@ import type { Metadata, Viewport } from "next";
import { IceBackdrop } from "@/components/IceBackdrop"; import { IceBackdrop } from "@/components/IceBackdrop";
import { InstallSheet } from "@/components/InstallSheet"; import { InstallSheet } from "@/components/InstallSheet";
import { OfflineBanner } from "@/components/OfflineBanner";
import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar"; import { ServiceWorkerRegistrar } from "@/components/ServiceWorkerRegistrar";
import { Toaster } from "@/components/Toaster";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
@ -18,6 +20,18 @@ export const metadata: Metadata = {
capable: true, capable: true,
title: "Freezmo", title: "Freezmo",
statusBarStyle: "default", statusBarStyle: "default",
/* Startup images so launching from the home screen has no white flash.
Sized for the common iPhone logical sizes at their device pixel ratio. */
startupImage: [
{ url: "/icons/splash-750x1334.png", media: "(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)" },
{ url: "/icons/splash-828x1792.png", media: "(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)" },
{ url: "/icons/splash-1170x2532.png", media: "(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3)" },
{ url: "/icons/splash-1179x2556.png", media: "(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3)" },
{ url: "/icons/splash-1284x2778.png", media: "(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3)" },
{ url: "/icons/splash-1290x2796.png", media: "(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3)" },
{ url: "/icons/splash-1536x2048.png", media: "(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2)" },
{ url: "/icons/splash-1668x2388.png", media: "(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2)" },
],
}, },
icons: { icons: {
icon: [ icon: [
@ -43,11 +57,10 @@ export const metadata: Metadata = {
export const viewport: Viewport = { export const viewport: Viewport = {
width: "device-width", width: "device-width",
initialScale: 1, initialScale: 1,
// Phones: no pinch/double-tap zoom (the app-like feel). The input font-size // Pinch-zoom stays available for accessibility (double-tap zoom is disabled
// rule in globals.css is what actually stops iOS Safari's focus auto-zoom, // by `touch-action: manipulation` on the chrome instead). The input
// which ignores user-scalable=no. // font-size rule in globals.css stops iOS Safari's focus auto-zoom, which
maximumScale: 1, // ignores the viewport meta anyway.
userScalable: false,
viewportFit: "cover", viewportFit: "cover",
themeColor: [ themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f4f7fd" }, { media: "(prefers-color-scheme: light)", color: "#f4f7fd" },
@ -82,6 +95,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<IceBackdrop /> <IceBackdrop />
{children} {children}
<InstallSheet /> <InstallSheet />
<OfflineBanner />
<Toaster />
<ServiceWorkerRegistrar /> <ServiceWorkerRegistrar />
</body> </body>
</html> </html>

View file

@ -6,6 +6,7 @@ import type { MetadataRoute } from "next";
*/ */
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
return { return {
id: "/",
name: "Freezmo — notes to study feed", name: "Freezmo — notes to study feed",
short_name: "Freezmo", short_name: "Freezmo",
description: description:
@ -13,6 +14,7 @@ export default function manifest(): MetadataRoute.Manifest {
start_url: "/", start_url: "/",
scope: "/", scope: "/",
display: "standalone", display: "standalone",
display_override: ["standalone", "minimal-ui"],
orientation: "portrait", orientation: "portrait",
background_color: "#0e1424", background_color: "#0e1424",
theme_color: "#0e1424", theme_color: "#0e1424",
@ -21,10 +23,39 @@ export default function manifest(): MetadataRoute.Manifest {
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" }, { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" }, { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
{ src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" }, { src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
{ src: "/icons/monochrome.svg", sizes: "any", type: "image/svg+xml", purpose: "monochrome" },
], ],
shortcuts: [ shortcuts: [
{ name: "New deck", short_name: "New", url: "/create" }, { name: "New deck", short_name: "New", url: "/create" },
{ name: "Make my homework", short_name: "Homework", url: "/homework" },
{ name: "Library", short_name: "Library", url: "/" }, { name: "Library", short_name: "Library", url: "/" },
], ],
/* Share text and links from other apps (GET). Files are POSTed to the
same route and handed over through a short-lived in-memory store. */
share_target: {
action: "/share-target",
method: "POST",
enctype: "multipart/form-data",
params: {
title: "title",
text: "text",
url: "url",
files: [
{
name: "files",
accept: [
"image/*",
"application/pdf",
"text/*",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.presentation",
],
},
],
},
},
}; };
} }

55
app/share-target/route.ts Normal file
View file

@ -0,0 +1,55 @@
import { MAX_SHARE_BYTES, putShare, takeShare, type SharedFile } from "@/lib/share-inbox";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
function asString(value: FormDataEntryValue | null): string | undefined {
return typeof value === "string" && value.trim() ? value.trim().slice(0, 20_000) : undefined;
}
/** GET /share?id=… — read (and consume) a payload that was POSTed in. */
export async function GET(request: Request) {
const id = new URL(request.url).searchParams.get("id");
if (!id) return Response.json({ error: "missing id" }, { status: 400 });
const payload = takeShare(id);
if (!payload) return Response.json({ error: "expired" }, { status: 404 });
return Response.json(payload);
}
/**
* POST /share — the Web Share Target. Text/URL arrive as form fields, files
* as multipart uploads; both are stashed briefly and the browser is redirected
* to the chooser page with an opaque id.
*/
export async function POST(request: Request) {
let form: FormData;
try {
form = await request.formData();
} catch {
return Response.redirect(new URL("/share?error=bad", request.url), 303);
}
const uploads = form.getAll("files").filter((entry): entry is File => entry instanceof File);
const total = uploads.reduce((sum, file) => sum + file.size, 0);
if (total > MAX_SHARE_BYTES) {
return Response.redirect(new URL("/share?error=large", request.url), 303);
}
const files: SharedFile[] = [];
for (const file of uploads.slice(0, 6)) {
files.push({
name: file.name || "shared",
type: file.type || "application/octet-stream",
data: Buffer.from(await file.arrayBuffer()).toString("base64"),
});
}
const id = crypto.randomUUID();
putShare(id, {
title: asString(form.get("title")),
text: asString(form.get("text")),
url: asString(form.get("url")),
files,
});
return Response.redirect(new URL(`/share?id=${id}`, request.url), 303);
}

36
app/share/page.tsx Normal file
View file

@ -0,0 +1,36 @@
import { ApiKeyGate } from "@/components/ApiKeyGate";
import { AppHeader } from "@/components/AppHeader";
import { ShareChooser } from "@/components/ShareChooser";
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
export const metadata = {
title: "Add to Freezmo",
};
export default async function SharePage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const { id, text, url, error } = await searchParams;
return (
<ApiKeyGate>
<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">
<ShareChooser
id={typeof id === "string" ? id : undefined}
text={typeof text === "string" ? text : undefined}
url={typeof url === "string" ? url : undefined}
error={typeof error === "string" ? error : undefined}
/>
</div>
</main>
</div>
</ApiKeyGate>
);
}

View file

@ -7,13 +7,17 @@ import { useState } from "react";
import { ApiError, generateDeck } from "@/lib/client-api"; import { ApiError, generateDeck } from "@/lib/client-api";
import { activityTypes, materialiseActivities } from "@/lib/deck-ops"; import { activityTypes, materialiseActivities } from "@/lib/deck-ops";
import { createFolder as makeFolder, listFolders } from "@/lib/folders"; import { createFolder as makeFolder, listFolders } from "@/lib/folders";
import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { springGentle } from "@/lib/motion"; import { springGentle } from "@/lib/motion";
import { UNORGANIZED_ID, type Deck } from "@/lib/schemas"; import { UNORGANIZED_ID, type Deck } from "@/lib/schemas";
import { shareDeck } from "@/lib/share";
import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage"; import { appendActivities, deleteDeck, downloadDeck, renameDeck, upsertDeck } from "@/lib/storage";
import { showToast } from "@/lib/toast";
import { useBackClose } from "@/lib/use-back-close";
import { useIsPhone } from "@/lib/use-phone"; import { useIsPhone } from "@/lib/use-phone";
import { cn, formatDate, truncate } from "@/lib/utils"; import { cn, formatDate, truncate } from "@/lib/utils";
import { ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon } from "@/components/icons"; import { ChevronDownIcon, DownloadIcon, EllipsisIcon, FolderIcon, PlayIcon, RefreshIcon, ShareIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui"; import { Button, ProgressBar } from "@/components/ui";
type Busy = null | "more" | "regenerate"; type Busy = null | "more" | "regenerate";
@ -41,13 +45,15 @@ export function DeckCard({
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
const [title, setTitle] = useState(deck.title); const [title, setTitle] = useState(deck.title);
const [confirmDelete, setConfirmDelete] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [addCount, setAddCount] = useState(5); const [addCount, setAddCount] = useState(5);
const [newFolder, setNewFolder] = useState<string | null>(null); const [newFolder, setNewFolder] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
/* System Back closes the actions panel first. */
useBackClose(isPhone && menuOpen, () => setMenuOpen(false));
const hasSource = deck.sourceText.trim().length > 20; const hasSource = deck.sourceText.trim().length > 20;
const saveTitle = () => { const saveTitle = () => {
@ -230,7 +236,10 @@ export function DeckCard({
type="button" type="button"
aria-label={t("deck.actions")} aria-label={t("deck.actions")}
aria-expanded={menuOpen} aria-expanded={menuOpen}
onClick={() => setMenuOpen((value) => !value)} onClick={() => {
haptic("tap");
setMenuOpen((value) => !value);
}}
className={cn( className={cn(
"relative z-10 grid size-8 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:-mt-1.5", "relative z-10 grid size-8 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] lg:-mt-1.5",
menuOpen && "bg-[var(--hover)] text-[var(--text)]", menuOpen && "bg-[var(--hover)] text-[var(--text)]",
@ -357,6 +366,18 @@ export function DeckCard({
<DownloadIcon className="size-4 text-[var(--text-subtle)]" /> <DownloadIcon className="size-4 text-[var(--text-subtle)]" />
{t("deck.export")} {t("deck.export")}
</button> </button>
<button
type="button"
className={menuRow}
onClick={() => {
setMenuOpen(false);
void shareDeck(deck);
}}
disabled={Boolean(busy)}
>
<ShareIcon className="size-4 text-[var(--text-subtle)]" />
{t("deck.share")}
</button>
<button <button
type="button" type="button"
className={menuRow} className={menuRow}
@ -367,23 +388,25 @@ export function DeckCard({
{t("deck.regenerate")} {t("deck.regenerate")}
</button> </button>
{confirmDelete ? ( {/* Deleting is reversible: no confirm, just an Undo toast. */}
<div className="flex items-center gap-2 px-3 py-2"> <button
<Button size="sm" variant="danger" onClick={() => deleteDeck(deck.id)}> type="button"
{t("deck.reallyDelete")} className={cn(menuRow, "text-rose-600 dark:text-rose-300")}
</Button> onClick={() => {
<Button size="sm" variant="ghost" onClick={() => setConfirmDelete(false)}> setMenuOpen(false);
{t("common.cancel")} deleteDeck(deck.id);
</Button> showToast({
</div> message: t("toast.deckDeleted"),
) : ( actionLabel: t("toast.undo"),
<button type="button" className={cn(menuRow, "text-rose-600 dark:text-rose-300")} onClick={() => setConfirmDelete(true)}> onAction: () => upsertDeck(deck),
});
}}
>
<span aria-hidden className="w-4 text-center"> <span aria-hidden className="w-4 text-center">
⌫ ⌫
</span> </span>
{t("deck.delete")} {t("deck.delete")}
</button> </button>
)}
{/* folder ---------------------------------------------------- */} {/* folder ---------------------------------------------------- */}
<div className="mt-2 rounded-xl bg-[var(--surface-sunken)] p-2.5"> <div className="mt-2 rounded-xl bg-[var(--surface-sunken)] p-2.5">

View file

@ -16,6 +16,7 @@ import {
} from "@/components/icons"; } from "@/components/icons";
import { Spinner } from "@/components/ui"; import { Spinner } from "@/components/ui";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { useBackClose } from "@/lib/use-back-close";
import { import {
detectBrowser, detectBrowser,
detectPlatform, detectPlatform,
@ -56,6 +57,9 @@ export function InstallSheet() {
const { t } = useI18n(); const { t } = useI18n();
const domain = typeof window === "undefined" ? "" : window.location.hostname; const domain = typeof window === "undefined" ? "" : window.location.hostname;
/* System Back closes the sheet first. */
useBackClose(open, () => setOpen(false));
/** Everything needed to render the sheet, or null if it shouldn't appear. */ /** Everything needed to render the sheet, or null if it shouldn't appear. */
const prepare = useCallback((): SheetInfo | null => { const prepare = useCallback((): SheetInfo | null => {
const platform = detectPlatform(); const platform = detectPlatform();

View file

@ -0,0 +1,32 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useI18n } from "@/lib/i18n";
import { fade } from "@/lib/motion";
import { useOnline } from "@/lib/use-online";
/** A quiet offline pill under the header; saved decks keep working offline. */
export function OfflineBanner() {
const online = useOnline();
const { t } = useI18n();
return (
<AnimatePresence>
{!online ? (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={fade}
role="status"
className="pointer-events-none fixed inset-x-0 top-0 z-[60] flex justify-center px-4 pt-safe sm:hidden"
>
<p className="rounded-full border border-amber-300/70 bg-amber-50/95 px-3.5 py-1.5 text-[12px] font-semibold text-amber-900 shadow-lift backdrop-blur-xl dark:border-amber-400/30 dark:bg-amber-500/20 dark:text-amber-100">
{t("offline.banner")}
</p>
</motion.div>
) : null}
</AnimatePresence>
);
}

View file

@ -1,13 +1,15 @@
"use client"; "use client";
import { AnimatePresence, motion, useDragControls, useReducedMotion } from "framer-motion"; import { AnimatePresence, motion, useDragControls, useReducedMotion } from "framer-motion";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { LOCALES, useI18n, type Locale } from "@/lib/i18n"; import { LOCALES, useI18n, type Locale } from "@/lib/i18n";
import { haptic } from "@/lib/haptics";
import { springSheet } from "@/lib/motion"; import { springSheet } from "@/lib/motion";
import { clearApiKey, saveSettings, settingsStore } from "@/lib/settings";
import { useBackClose } from "@/lib/use-back-close";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { openInstallSheet, useInstallAvailable } from "@/lib/install"; import { openInstallSheet, useInstallAvailable } from "@/lib/install";
import { clearApiKey, saveSettings } from "@/lib/settings";
import { useHealth } from "@/lib/use-health"; import { useHealth } from "@/lib/use-health";
import { import {
AlertTriangleIcon, AlertTriangleIcon,
@ -37,6 +39,13 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
const { health, loading, error, refresh, apiKey, checkedAt } = useHealth(); const { health, loading, error, refresh, apiKey, checkedAt } = useHealth();
const installAvailable = useInstallAvailable(); const installAvailable = useInstallAvailable();
const { locale, setLocale, t, plural } = useI18n(); const { locale, setLocale, t, plural } = useI18n();
const settings = useSyncExternalStore(
settingsStore.subscribe,
settingsStore.getSnapshot,
settingsStore.getServerSnapshot,
);
/* The system Back gesture closes the sheet instead of leaving the page. */
useBackClose(open, onClose);
const [keyDraft, setKeyDraft] = useState(apiKey); const [keyDraft, setKeyDraft] = useState(apiKey);
const [reveal, setReveal] = useState(false); const [reveal, setReveal] = useState(false);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
@ -221,7 +230,10 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
key={entry.id} key={entry.id}
type="button" type="button"
aria-pressed={locale === entry.id} aria-pressed={locale === entry.id}
onClick={() => setLocale(entry.id)} onClick={() => {
haptic("toggle");
setLocale(entry.id);
}}
className={cn("relative", phone && "seg-slide")} className={cn("relative", phone && "seg-slide")}
> >
{phone && locale === entry.id ? <SegmentedPill layoutId="language-pill" /> : null} {phone && locale === entry.id ? <SegmentedPill layoutId="language-pill" /> : null}
@ -306,6 +318,42 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
</p> </p>
</div> </div>
</details> </details>
{/* haptics: best-effort vibration feedback */}
<div className="flex items-center justify-between gap-3 border-t border-[var(--hairline-soft)] pt-3">
<span className="min-w-0">
<span className="block text-[13px] font-semibold text-[var(--text)]">
{t("settings.haptics")}
</span>
<span className="block text-[11.5px] leading-snug text-[var(--text-subtle)]">
{t("settings.hapticsNote")}
</span>
</span>
<button
type="button"
role="switch"
aria-checked={settings.haptics}
aria-label={t("settings.haptics")}
onClick={() => {
saveSettings({ haptics: !settings.haptics });
haptic("toggle");
}}
className={cn(
"touch-target relative h-7 w-12 shrink-0 rounded-full border transition-colors",
settings.haptics
? "grad-ice border-transparent"
: "border-[var(--hairline)] bg-[var(--surface-sunken)]",
)}
>
<span
aria-hidden
className={cn(
"absolute top-1/2 size-5 -translate-y-1/2 rounded-full bg-white shadow transition-[left] duration-200",
settings.haptics ? "left-6" : "left-0.5",
)}
/>
</button>
</div>
</section> </section>
{/* ----------------------------------------------- install */} {/* ----------------------------------------------- install */}

119
components/ShareChooser.tsx Normal file
View file

@ -0,0 +1,119 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button, Spinner } from "@/components/ui";
import { useI18n } from "@/lib/i18n";
import { setPendingShare, type PendingShareFile } from "@/lib/share-pending";
import { openSettings } from "@/lib/ui-events";
type Payload = { title?: string; text?: string; url?: string; files: PendingShareFile[] };
/**
* The share-target chooser: shows what was shared and lets the user send it
* to New deck or Make my homework. Files are stashed in sessionStorage and
* picked up by the wizard on mount.
*/
export function ShareChooser({
id,
text,
url,
error,
}: {
id?: string;
text?: string;
url?: string;
error?: string;
}) {
const { t } = useI18n();
const router = useRouter();
const [payload, setPayload] = useState<Payload | null>(id ? null : { text, url, files: [] });
const [failed, setFailed] = useState(Boolean(error));
useEffect(() => {
if (!id) return;
let cancelled = false;
void (async () => {
try {
const response = await fetch(`/share-target?id=${encodeURIComponent(id)}`, { cache: "no-store" });
if (!response.ok) throw new Error("expired");
const data = (await response.json()) as Payload;
if (!cancelled) setPayload(data);
} catch {
if (!cancelled) setFailed(true);
}
})();
return () => {
cancelled = true;
};
}, [id]);
const combinedText = [payload?.text, payload?.url].filter(Boolean).join("\n\n").trim();
const fileCount = payload?.files.length ?? 0;
const go = (target: "/create" | "/homework") => {
setPendingShare({ text: combinedText || undefined, files: payload?.files ?? [] });
router.push(target);
};
if (failed) {
return (
<div className="card mx-auto max-w-md space-y-3 p-6 text-center">
<p className="text-3xl" aria-hidden>
🧊
</p>
<h1 className="text-xl font-extrabold text-[var(--text)]">{t(error === "large" ? "share.tooLarge" : "share.expired")}</h1>
<div className="flex justify-center gap-2 pt-1">
<Button onClick={() => router.push("/")}>{t("common.library")}</Button>
<Button variant="secondary" onClick={openSettings}>
{t("common.settings")}
</Button>
</div>
</div>
);
}
if (!payload) {
return (
<div className="flex items-center justify-center gap-3 py-16 text-sm font-semibold text-[var(--text-muted)]">
<Spinner className="size-5" /> {t("share.loading")}
</div>
);
}
return (
<div className="mx-auto max-w-md space-y-4">
<section className="space-y-1.5">
<h1 className="text-[26px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)]">
{t("share.title")}
</h1>
<p className="text-[14px] text-frost-600 dark:text-frost-300">{t("share.intro")}</p>
</section>
<div className="card space-y-2 p-4">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">{t("share.preview")}</p>
{fileCount > 0 ? (
<p className="text-[13px] font-medium text-[var(--text)]">
{fileCount === 1 ? payload.files[0].name : `${fileCount} files`}
</p>
) : null}
{combinedText ? (
<p className="line-clamp-4 text-[13px] leading-relaxed text-[var(--text-muted)]">{combinedText}</p>
) : null}
{fileCount === 0 && !combinedText ? (
<p className="text-[13px] text-[var(--text-subtle)]">{t("share.nothing")}</p>
) : null}
</div>
<div className="grid gap-2">
<Button size="lg" className="touch-target w-full" onClick={() => go("/create")}>
{t("common.newDeck")}
</Button>
<Button size="lg" variant="secondary" className="touch-target w-full" onClick={() => go("/homework")}>
{t("homework.nav")}
</Button>
</div>
</div>
);
}

View file

@ -17,12 +17,14 @@ import {
} from "@/components/icons"; } from "@/components/icons";
import { useTheme } from "@/components/ThemeToggle"; import { useTheme } from "@/components/ThemeToggle";
import { createFolder, folderStore } from "@/lib/folders"; import { createFolder, folderStore } from "@/lib/folders";
import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { springSheet } from "@/lib/motion"; import { springSheet } from "@/lib/motion";
import { ALL_FOLDERS, folderSelection, sidebarOpen, useSelectedFolder, useSidebarOpen } 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";
@ -303,7 +305,10 @@ function NavContent({
</button> </button>
<button <button
type="button" type="button"
onClick={toggle} onClick={() => {
haptic("toggle");
toggle();
}}
aria-label={theme === "dark" ? t("common.switchToLight") : t("common.switchToDark")} aria-label={theme === "dark" ? t("common.switchToLight") : t("common.switchToDark")}
title={theme === "dark" ? t("common.lightMode") : t("common.darkMode")} title={theme === "dark" ? t("common.lightMode") : t("common.darkMode")}
className={cn("nav-row", collapsed && "justify-center px-2")} className={cn("nav-row", collapsed && "justify-center px-2")}
@ -396,6 +401,8 @@ export function Sidebar() {
export function MobileSidebar() { export function MobileSidebar() {
const open = useSidebarOpen(); const open = useSidebarOpen();
const { t } = useI18n(); const { t } = useI18n();
/* Back closes the drawer before it navigates away. */
const { skipRewind } = useBackClose(open, () => sidebarOpen.set(false));
return ( return (
<AnimatePresence> <AnimatePresence>
@ -427,7 +434,12 @@ export function MobileSidebar() {
}} }}
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" 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={() => sidebarOpen.set(false)} /> <NavContent
onNavigate={() => {
skipRewind();
sidebarOpen.set(false);
}}
/>
</motion.aside> </motion.aside>
</motion.div> </motion.div>
) : null} ) : null}

View file

@ -8,9 +8,11 @@ import { ActivityRenderer } from "@/components/activities";
import { celebrate } from "@/lib/confetti"; import { celebrate } from "@/lib/confetti";
import { ApiError, generateDeck } from "@/lib/client-api"; import { ApiError, generateDeck } from "@/lib/client-api";
import { materialiseActivities } from "@/lib/deck-ops"; import { materialiseActivities } from "@/lib/deck-ops";
import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { Activity, Deck } from "@/lib/schemas"; import type { Activity, Deck } from "@/lib/schemas";
import { recordAttempt, upsertDeck } from "@/lib/storage"; import { recordAttempt, upsertDeck } from "@/lib/storage";
import { useWakeLock } from "@/lib/use-wake-lock";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BoltIcon, FlameIcon, XIcon } from "@/components/icons"; import { BoltIcon, FlameIcon, XIcon } from "@/components/icons";
import { Button, ProgressBar } from "@/components/ui"; import { Button, ProgressBar } from "@/components/ui";
@ -21,6 +23,8 @@ export type AttemptResult = { correct: boolean; userAnswer?: string };
export function StudyFeed({ deck }: { deck: Deck }) { export function StudyFeed({ deck }: { deck: Deck }) {
const { t } = useI18n(); const { t } = useI18n();
/* Keep the screen awake for the whole study session (feature-detected). */
useWakeLock(true);
const [currentDeck, setCurrentDeck] = useState(deck); const [currentDeck, setCurrentDeck] = useState(deck);
const [queue, setQueue] = useState<Activity[]>(deck.activities); const [queue, setQueue] = useState<Activity[]>(deck.activities);
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
@ -108,6 +112,7 @@ export function StudyFeed({ deck }: { deck: Deck }) {
// 0% attempt before the feed had snapped back to the first slide. // 0% attempt before the feed had snapped back to the first slide.
if (answered.length > 0) { if (answered.length > 0) {
recordAttempt(currentDeck.id, { scorePercent: score, correct, total }); recordAttempt(currentDeck.id, { scorePercent: score, correct, total });
haptic("celebrate");
void celebrate("big"); void celebrate("big");
} }
} }
@ -120,12 +125,14 @@ export function StudyFeed({ deck }: { deck: Deck }) {
return { ...current, [activity.id]: { correct, userAnswer: meta?.userAnswer } }; return { ...current, [activity.id]: { correct, userAnswer: meta?.userAnswer } };
}); });
if (correct) { if (correct) {
haptic("success");
const nextStreak = streak + 1; const nextStreak = streak + 1;
setStreak(nextStreak); setStreak(nextStreak);
setBestStreak((best) => Math.max(best, nextStreak)); setBestStreak((best) => Math.max(best, nextStreak));
setXp((value) => value + activity.xp); setXp((value) => value + activity.xp);
if (nextStreak > 0 && nextStreak % 5 === 0) void celebrate("normal"); if (nextStreak > 0 && nextStreak % 5 === 0) void celebrate("normal");
} else { } else {
haptic("error");
setStreak(0); setStreak(0);
} }
}, },

View file

@ -5,6 +5,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { GearIcon, HomeIcon, PlusIcon } from "@/components/icons"; import { GearIcon, HomeIcon, PlusIcon } from "@/components/icons";
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";
import { openSettings } from "@/lib/ui-events"; import { openSettings } from "@/lib/ui-events";
@ -67,6 +68,14 @@ export function TabBar() {
href="/" href="/"
aria-current={libraryActive ? "page" : undefined} aria-current={libraryActive ? "page" : undefined}
className={cn(item, libraryActive ? active : idle)} 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)} {pill(libraryActive)}
{icon(<HomeIcon className="size-6" />, libraryActive, "library")} {icon(<HomeIcon className="size-6" />, libraryActive, "library")}
@ -76,6 +85,13 @@ export function TabBar() {
href="/create" href="/create"
aria-current={createActive ? "page" : undefined} aria-current={createActive ? "page" : undefined}
className={cn(item, createActive ? active : idle)} className={cn(item, createActive ? active : idle)}
onClick={(event) => {
haptic("tap");
if (createActive) {
event.preventDefault();
window.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" });
}
}}
> >
{pill(createActive)} {pill(createActive)}
{icon( {icon(
@ -87,7 +103,14 @@ export function TabBar() {
)} )}
{label(t("common.newDeck"), createActive)} {label(t("common.newDeck"), createActive)}
</Link> </Link>
<button type="button" onClick={openSettings} className={cn(item, idle)}> <button
type="button"
onClick={() => {
haptic("tap");
openSettings();
}}
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("common.settings"), false)}
</button> </button>

View file

@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { haptic } from "@/lib/haptics";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MoonIcon, SunIcon } from "@/components/icons"; import { MoonIcon, SunIcon } from "@/components/icons";
@ -64,7 +65,10 @@ export function ThemeToggle({ className }: { className?: string }) {
return ( return (
<button <button
type="button" type="button"
onClick={toggle} onClick={() => {
haptic("toggle");
toggle();
}}
aria-label={dark ? t("common.switchToLight") : t("common.switchToDark")} aria-label={dark ? t("common.switchToLight") : t("common.switchToDark")}
title={dark ? t("common.lightMode") : t("common.darkMode")} title={dark ? t("common.lightMode") : t("common.darkMode")}
className={cn( className={cn(

67
components/Toaster.tsx Normal file
View file

@ -0,0 +1,67 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui";
import { springGentle } from "@/lib/motion";
import { toastBus, type Toast } from "@/lib/toast";
/**
* Global toast host: undo for reversible destructive actions, share fallbacks.
* Sits above the tab bar on phones and near the bottom on desktop.
*/
export function Toaster() {
const [toasts, setToasts] = useState<Toast[]>([]);
useEffect(
() =>
toastBus.subscribe((toast) => {
setToasts((current) => [...current.slice(-2), toast]);
window.setTimeout(
() => setToasts((current) => current.filter((entry) => entry.id !== toast.id)),
toast.durationMs ?? 5000,
);
}),
[],
);
const dismiss = (id: number) => setToasts((current) => current.filter((entry) => entry.id !== id));
return (
<div className="pointer-events-none fixed inset-x-0 bottom-[calc(env(safe-area-inset-bottom,0px)+5.25rem)] z-[60] flex flex-col items-center gap-2 px-4 sm:bottom-6">
<AnimatePresence initial={false}>
{toasts.map((toast) => (
<motion.div
key={toast.id}
layout
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={springGentle}
role="status"
className="pointer-events-auto flex w-full max-w-md items-center gap-3 rounded-2xl border border-[var(--glass-border)] bg-[color-mix(in_oklab,var(--surface)_92%,transparent)] px-4 py-3 shadow-lift backdrop-blur-xl"
>
<p className="min-w-0 flex-1 text-[13px] font-medium text-[var(--text)]">{toast.message}</p>
{toast.actionLabel ? (
<button
type="button"
onClick={() => {
toast.onAction?.();
dismiss(toast.id);
}}
className="shrink-0 text-[13px] font-bold text-accent-700 dark:text-accent-200"
>
{toast.actionLabel}
</button>
) : (
<Button size="sm" variant="ghost" className="shrink-0" onClick={() => dismiss(toast.id)} aria-label="Dismiss">
✕
</Button>
)}
</motion.div>
))}
</AnimatePresence>
</div>
);
}

View file

@ -27,12 +27,18 @@ import {
type PreparedImage, type PreparedImage,
} from "@/lib/image"; } from "@/lib/image";
import { createFolder, folderStore } from "@/lib/folders"; import { createFolder, folderStore } from "@/lib/folders";
import { haptic } from "@/lib/haptics";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { stepVariants } from "@/lib/motion"; import { stepVariants } from "@/lib/motion";
import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas"; import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
import { takePendingShare } from "@/lib/share-pending";
import { upsertDeck } from "@/lib/storage"; import { upsertDeck } from "@/lib/storage";
import { showToast } from "@/lib/toast";
import { openSettings } from "@/lib/ui-events"; import { openSettings } from "@/lib/ui-events";
import { useBackClose } from "@/lib/use-back-close";
import { useIsPhone } from "@/lib/use-phone"; import { useIsPhone } from "@/lib/use-phone";
import { useOnline } from "@/lib/use-online";
import { clearWizardDraft, loadWizardDraft, saveWizardDraft } from "@/lib/wizard-draft";
import { cn, uid } from "@/lib/utils"; import { cn, uid } from "@/lib/utils";
import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons"; import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
import { SegmentedPill } from "@/components/SegmentedPill"; import { SegmentedPill } from "@/components/SegmentedPill";
@ -112,6 +118,20 @@ export function UploadScreen() {
const [pasteOpen, setPasteOpen] = useState(false); const [pasteOpen, setPasteOpen] = useState(false);
const isPhone = useIsPhone(); const isPhone = useIsPhone();
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const online = useOnline();
const [clipboardAvailable, setClipboardAvailable] = useState(false);
/* System Back goes one wizard step back (phones), then leaves the page. */
useBackClose(isPhone && step > 1, () => {
setStepDir(-1);
setStep((value) => Math.max(1, value - 1));
});
useEffect(() => {
setClipboardAvailable(
typeof navigator !== "undefined" && typeof navigator.clipboard?.readText === "function",
);
}, []);
const folders = useSyncExternalStore( const folders = useSyncExternalStore(
folderStore.subscribe, folderStore.subscribe,
@ -264,6 +284,37 @@ export function UploadScreen() {
const imagesSkipped = const imagesSkipped =
images.length + documentImages.length - imagesToSend.length; images.length + documentImages.length - imagesToSend.length;
/* Restore a draft after an OS kill / reload; clear it on success. */
useEffect(() => {
const draft = loadWizardDraft("deck");
if (!draft) return;
if (draft.text) setText(draft.text);
if (draft.focus) setFocus(draft.focus);
if (draft.difficulty === "easy" || draft.difficulty === "medium" || draft.difficulty === "hard") {
setDifficulty(draft.difficulty);
}
if (draft.count) setCount(draft.count);
if (draft.folderId) setFolderId(draft.folderId);
if (draft.images?.length) setImages(draft.images);
}, []);
/* Content shared in from another app (share target) lands here. */
useEffect(() => {
const pending = takePendingShare();
if (!pending) return;
if (pending.text) setText((current) => (current ? `${current}\n${pending.text}` : pending.text));
if (pending.files.length > 0) void addFiles(pending.files);
}, [addFiles]);
/* Persist the draft while the user works (debounced, best-effort). */
useEffect(() => {
if (phase === "loading") return;
const timer = window.setTimeout(() => {
saveWizardDraft("deck", { text, focus, difficulty, count, folderId, images });
}, 400);
return () => window.clearTimeout(timer);
}, [text, focus, difficulty, count, folderId, images, phase]);
const generate = async () => { const generate = async () => {
if (!canGenerate) return; if (!canGenerate) return;
setError(null); setError(null);
@ -300,6 +351,7 @@ export function UploadScreen() {
attempts: 0, attempts: 0,
}; };
upsertDeck(stored); upsertDeck(stored);
clearWizardDraft("deck");
router.push(`/deck/${stored.id}`); router.push(`/deck/${stored.id}`);
} catch (generateError) { } catch (generateError) {
setError({ setError({
@ -330,7 +382,10 @@ export function UploadScreen() {
key={folder.id} key={folder.id}
type="button" type="button"
aria-pressed={active} aria-pressed={active}
onClick={() => setFolderId(folder.id)} onClick={() => {
haptic("toggle");
setFolderId(folder.id);
}}
className={cn( className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-[13px] font-medium transition", "inline-flex items-center gap-1.5 rounded-full border px-3.5 py-1.5 text-[13px] font-medium transition",
active active
@ -369,6 +424,10 @@ export function UploadScreen() {
aria-label={t("common.newFolderName")} aria-label={t("common.newFolderName")}
placeholder={t("upload.folderPlaceholder")} placeholder={t("upload.folderPlaceholder")}
maxLength={60} maxLength={60}
enterKeyHint="done"
autoCapitalize="words"
autoComplete="off"
spellCheck={false}
className="w-32 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-1.5 text-sm outline-none" className="w-32 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-1.5 text-sm outline-none"
/> />
<Button <Button
@ -410,7 +469,10 @@ export function UploadScreen() {
type="button" type="button"
role="radio" role="radio"
aria-checked={selected} aria-checked={selected}
onClick={() => setDifficulty(option.value)} onClick={() => {
haptic("toggle");
setDifficulty(option.value);
}}
className={cn( className={cn(
"relative flex flex-col items-center gap-1 overflow-hidden rounded-2xl border px-2 py-3 text-center backdrop-blur-xl transition-colors", "relative flex flex-col items-center gap-1 overflow-hidden rounded-2xl border px-2 py-3 text-center backdrop-blur-xl transition-colors",
selected && isPhone selected && isPhone
@ -448,7 +510,10 @@ export function UploadScreen() {
key={option} key={option}
type="button" type="button"
aria-pressed={count === option} aria-pressed={count === option}
onClick={() => setCount(option)} onClick={() => {
haptic("toggle");
setCount(option);
}}
className={cn( className={cn(
"relative min-w-12 overflow-hidden rounded-xl border-2 px-3 py-1.5 text-sm font-bold transition", "relative min-w-12 overflow-hidden rounded-xl border-2 px-3 py-1.5 text-sm font-bold transition",
count === option && isPhone count === option && isPhone
@ -493,7 +558,9 @@ export function UploadScreen() {
</div> </div>
) : null; ) : null;
const phoneHelper = documentsBusy const phoneHelper = !online
? t("offline.generate")
: documentsBusy
? t("upload.readingDocs") ? t("upload.readingDocs")
: hasContent : hasContent
? t("upload.ready") ? t("upload.ready")
@ -622,13 +689,50 @@ export function UploadScreen() {
{t("upload.pasteInstead")} {t("upload.pasteInstead")}
</Button> </Button>
</div> </div>
{clipboardAvailable ? (
<Button
variant="ghost"
size="lg"
className="touch-target w-full"
onClick={() => {
void (async () => {
try {
const clip = await navigator.clipboard.readText();
if (clip) {
setText((current) => (current ? `${current}\n${clip}` : clip));
setPasteOpen(true);
}
haptic("tap");
} catch {
setFileError(t("upload.clipboardDenied"));
}
})();
}}
>
{t("upload.pasteClipboard")}
</Button>
) : null}
{pasteOpen || text.trim().length > 0 ? ( {pasteOpen || text.trim().length > 0 ? (
<textarea <textarea
value={text} value={text}
onChange={(event) => setText(event.target.value)} onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (hasContent && !documentsBusy) {
setStepDir(1);
setStep(2);
}
}
}}
rows={5} rows={5}
placeholder={t("upload.pastedPlaceholder")} placeholder={t("upload.pastedPlaceholder")}
aria-label={t("upload.pastedNotes")} aria-label={t("upload.pastedNotes")}
inputMode="text"
enterKeyHint="done"
autoComplete="off"
autoCapitalize="sentences"
spellCheck
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50" className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/> />
) : null} ) : null}
@ -645,7 +749,20 @@ export function UploadScreen() {
/> />
<button <button
type="button" type="button"
onClick={() => removeImage(image.id)} onClick={() => {
const removed = images.find((entry) => entry.id === image.id);
removeImage(image.id);
if (removed) {
showToast({
message: t("toast.fileRemoved"),
actionLabel: t("toast.undo"),
onAction: () =>
setImages((current) =>
current.some((entry) => entry.id === removed.id) ? current : [...current, removed],
),
});
}
}}
aria-label={t("upload.remove", { name: image.name })} aria-label={t("upload.remove", { name: image.name })}
className="absolute -right-1.5 -top-1.5 grid size-6 place-items-center rounded-full bg-frost-950/80 text-[11px] font-bold text-white" className="absolute -right-1.5 -top-1.5 grid size-6 place-items-center rounded-full bg-frost-950/80 text-[11px] font-bold text-white"
> >
@ -676,7 +793,20 @@ export function UploadScreen() {
) : null} ) : null}
<button <button
type="button" type="button"
onClick={() => removeDocument(entry.id)} onClick={() => {
const snapshot = documents.find((doc) => doc.id === entry.id);
removeDocument(entry.id);
if (snapshot) {
showToast({
message: t("toast.fileRemoved"),
actionLabel: t("toast.undo"),
onAction: () =>
setDocuments((current) =>
current.some((doc) => doc.id === snapshot.id) ? current : [...current, snapshot],
),
});
}
}}
aria-label={t("upload.remove", { name: entry.name })} aria-label={t("upload.remove", { name: entry.name })}
className="grid size-6 place-items-center rounded-full text-[11px] font-bold text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]" className="grid size-6 place-items-center rounded-full text-[11px] font-bold text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
> >
@ -764,6 +894,11 @@ export function UploadScreen() {
value={focus} value={focus}
onChange={(event) => setFocus(event.target.value)} onChange={(event) => setFocus(event.target.value)}
placeholder={t("upload.topicPlaceholder")} placeholder={t("upload.topicPlaceholder")}
inputMode="text"
enterKeyHint="done"
autoComplete="off"
autoCapitalize="sentences"
spellCheck={false}
className="w-full rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50" className="w-full rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/> />
</div> </div>
@ -874,7 +1009,7 @@ export function UploadScreen() {
{t("common.next")} {t("common.next")}
</Button> </Button>
) : ( ) : (
<Button size="lg" className="touch-target flex-[1.5]" onClick={() => void generate()} disabled={!canGenerate}> <Button size="lg" className="touch-target flex-[1.5]" onClick={() => void generate()} disabled={!canGenerate || !online}>
<SparklesIcon className="size-5" /> {t("upload.freezeDeck")} <SparklesIcon className="size-5" /> {t("upload.freezeDeck")}
</Button> </Button>
)} )}

View file

@ -10,6 +10,7 @@ import { Button, ProgressBar } from "@/components/ui";
import { assembleSolution } from "@/lib/homework"; import { assembleSolution } from "@/lib/homework";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { HomeworkGuideRecord, HomeworkStep } from "@/lib/schemas"; import type { HomeworkGuideRecord, HomeworkStep } from "@/lib/schemas";
import { useWakeLock } from "@/lib/use-wake-lock";
import { CopyButton } from "./CopyButton"; import { CopyButton } from "./CopyButton";
@ -33,6 +34,8 @@ export function HomeworkGuide({
againLoading: boolean; againLoading: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
/* Keep the screen awake while working through the guide. */
useWakeLock(true);
const steps = record.steps; const steps = record.steps;
const slideCount = steps.length + 1; // + the solution slide const slideCount = steps.length + 1; // + the solution slide
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);

View file

@ -16,8 +16,12 @@ import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas";
import { ACCEPTED_TYPES, formatBytes, imageFilesFromDataTransfer } from "@/lib/image"; import { ACCEPTED_TYPES, formatBytes, imageFilesFromDataTransfer } from "@/lib/image";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { stepVariants } from "@/lib/motion"; import { stepVariants } from "@/lib/motion";
import { takePendingShare } from "@/lib/share-pending";
import { useBackClose } from "@/lib/use-back-close";
import type { useNotesIntake } from "@/lib/use-notes-intake"; import type { useNotesIntake } from "@/lib/use-notes-intake";
import { useIsPhone } from "@/lib/use-phone"; import { useIsPhone } from "@/lib/use-phone";
import { useOnline } from "@/lib/use-online";
import { clearWizardDraft, loadWizardDraft, saveWizardDraft } from "@/lib/wizard-draft";
import { openSettings } from "@/lib/ui-events"; import { openSettings } from "@/lib/ui-events";
import { cn, truncate } from "@/lib/utils"; import { cn, truncate } from "@/lib/utils";
@ -76,6 +80,43 @@ export function HomeworkInput({
const [pasteOpen, setPasteOpen] = useState(false); const [pasteOpen, setPasteOpen] = useState(false);
const isPhone = useIsPhone(); const isPhone = useIsPhone();
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const online = useOnline();
const [clipboardAvailable, setClipboardAvailable] = useState(false);
/* System Back goes one wizard step back (phones), then leaves the page. */
useBackClose(isPhone && step > 1, () => {
setStepDir(-1);
setStep(1);
});
useEffect(() => {
setClipboardAvailable(
typeof navigator !== "undefined" && typeof navigator.clipboard?.readText === "function",
);
}, []);
/* Restore a draft after an OS kill / reload. */
useEffect(() => {
const draft = loadWizardDraft("homework");
if (draft?.text) onTextChange(draft.text);
// eslint-disable-next-line react-hooks/exhaustive-deps -- restore once on mount
}, []);
/* Content shared in from another app (share target) lands here. */
useEffect(() => {
const pending = takePendingShare();
if (!pending) return;
if (pending.text) onTextChange(pending.text);
if (pending.files.length > 0) void intake.addFiles(pending.files);
// eslint-disable-next-line react-hooks/exhaustive-deps -- once on mount
}, [intake.addFiles]);
/* Persist the typed assignment while the user works (debounced). */
useEffect(() => {
if (!isPhone) return;
const timer = window.setTimeout(() => saveWizardDraft("homework", { text }), 400);
return () => window.clearTimeout(timer);
}, [text, isPhone]);
// Clipboard: screenshots become photos, long copied text becomes the assignment. // Clipboard: screenshots become photos, long copied text becomes the assignment.
useEffect(() => { useEffect(() => {
@ -286,15 +327,51 @@ export function HomeworkInput({
{t("upload.pasteInstead")} {t("upload.pasteInstead")}
</Button> </Button>
</div> </div>
{clipboardAvailable ? (
<Button
variant="ghost"
size="lg"
className="touch-target w-full"
onClick={() => {
void (async () => {
try {
const clip = await navigator.clipboard.readText();
if (clip) {
onTextChange(text ? `${text}\n${clip}` : clip);
setPasteOpen(true);
}
} catch {
/* denied — the textarea still works */
}
})();
}}
>
{t("upload.pasteClipboard")}
</Button>
) : null}
{showPaste ? ( {showPaste ? (
<textarea <textarea
id="homework-assignment-phone" id="homework-assignment-phone"
value={text} value={text}
onChange={(event) => onTextChange(event.target.value)} onChange={(event) => onTextChange(event.target.value)}
onKeyDown={(event) => event.stopPropagation()} onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (canSubmit) {
setStepDir(1);
setStep(2);
}
}
}}
rows={6} rows={6}
placeholder={t("homework.source.placeholder")} placeholder={t("homework.source.placeholder")}
aria-label={t("homework.source.textLabel")} aria-label={t("homework.source.textLabel")}
inputMode="text"
enterKeyHint="done"
autoComplete="off"
autoCapitalize="sentences"
spellCheck
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50" className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/> />
) : null} ) : null}
@ -397,13 +474,16 @@ export function HomeworkInput({
<div className="grid gap-3"> <div className="grid gap-3">
{MODES.map((option) => {MODES.map((option) =>
modeCard(option, () => { modeCard(option, () => {
if (!canSubmit) return; if (!canSubmit || !online) return;
clearWizardDraft("homework");
onModeChange(option.id); onModeChange(option.id);
onSubmit(option.id); onSubmit(option.id);
}), }),
)} )}
</div> </div>
<p className="text-center text-[12px] text-[var(--text-subtle)]">{t("homework.phone.startHint")}</p> <p className="text-center text-[12px] text-[var(--text-subtle)]">
{online ? t("homework.phone.startHint") : t("offline.generate")}
</p>
</div> </div>
) : null} ) : null}
</motion.div> </motion.div>

View file

@ -3,6 +3,7 @@
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { HomeworkMakeRecord } from "@/lib/schemas"; import type { HomeworkMakeRecord } from "@/lib/schemas";
import { shareHomework } from "@/lib/share";
import { CopyButton } from "./CopyButton"; import { CopyButton } from "./CopyButton";
@ -39,6 +40,9 @@ export function HomeworkResult({
<div className="flex flex-wrap items-start gap-2"> <div className="flex flex-wrap items-start gap-2">
<CopyButton text={record.answer} /> <CopyButton text={record.answer} />
<Button variant="secondary" className="touch-target" onClick={() => void shareHomework(record)}>
{t("deck.share")}
</Button>
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}> <Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
{t("homework.result.again")} {t("homework.result.again")}
</Button> </Button>

View file

@ -16,6 +16,7 @@ import {
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas"; import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas";
import { useNotesIntake } from "@/lib/use-notes-intake"; import { useNotesIntake } from "@/lib/use-notes-intake";
import { clearWizardDraft } from "@/lib/wizard-draft";
import { HomeworkGuide } from "./HomeworkGuide"; import { HomeworkGuide } from "./HomeworkGuide";
import { HomeworkInput } from "./HomeworkInput"; import { HomeworkInput } from "./HomeworkInput";
@ -154,6 +155,7 @@ export function HomeworkScreen({ initialRecordId }: { initialRecordId?: string }
intake.clear(); intake.clear();
setText(""); setText("");
setError(null); setError(null);
clearWizardDraft("homework");
setView({ kind: "start" }); setView({ kind: "start" });
}; };

63
lib/haptics.ts Normal file
View file

@ -0,0 +1,63 @@
"use client";
import { settingsStore } from "./settings";
/**
* Best-effort haptics, behind the Settings → Haptics toggle (on by default).
*
* Android/Chrome use the Vibration API. iOS ignores `navigator.vibrate`;
* since iOS 17.4 the system tick can be triggered by clicking a hidden
* `<input type="checkbox" switch>` label — we try that, throttled, and fail
* silently everywhere else. Nothing here ever throws or blocks input.
*/
export type HapticKind = "tap" | "toggle" | "success" | "error" | "celebrate";
const PATTERNS: Record<HapticKind, number | number[]> = {
tap: 8,
toggle: 12,
success: [14, 40, 18],
error: [30, 60, 30],
celebrate: [18, 50, 18, 50, 26],
};
let lastIosTick = 0;
function iosTick(): void {
const now = Date.now();
if (now - lastIosTick < 80) return;
lastIosTick = now;
try {
const label = document.createElement("label");
label.setAttribute("aria-hidden", "true");
label.style.position = "fixed";
label.style.left = "-9999px";
const input = document.createElement("input");
input.type = "checkbox";
input.setAttribute("switch", "");
label.appendChild(input);
document.body.appendChild(label);
input.click();
window.setTimeout(() => label.remove(), 0);
} catch {
/* no haptics available */
}
}
export function haptic(kind: HapticKind = "tap"): void {
if (typeof window === "undefined") return;
try {
if (settingsStore.getSnapshot().haptics === false) return;
} catch {
return;
}
try {
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
navigator.vibrate(PATTERNS[kind]);
return;
}
iosTick();
} catch {
/* silent */
}
}

View file

@ -148,6 +148,26 @@ const en: Record<string, string> = {
"settings.details": "Details", "settings.details": "Details",
"settings.privacyLine": "Your key stays in this browser.", "settings.privacyLine": "Your key stays in this browser.",
"settings.recheck": "Re-check connection", "settings.recheck": "Re-check connection",
"settings.haptics": "Haptics",
"settings.hapticsNote": "Vibration feedback on supported phones.",
/* offline / share / toasts ---------------------------------------- */
"offline.banner": "You're offline — saved decks still work.",
"offline.generate": "You're offline — connect to generate.",
"share.title": "Add to Freezmo",
"share.intro": "What should I do with this?",
"share.preview": "Shared content",
"share.loading": "Loading the shared content…",
"share.expired": "That share expired — try again.",
"share.tooLarge": "That's too large to share (max 30 MB).",
"share.nothing": "Nothing to add.",
"share.copied": "Copied to the clipboard.",
"share.failed": "Sharing isn't available here.",
"toast.deckDeleted": "Deck deleted",
"toast.fileRemoved": "File removed",
"toast.undo": "Undo",
"upload.pasteClipboard": "Paste from clipboard",
"upload.clipboardDenied": "Clipboard access was denied.",
/* health ---------------------------------------------------------- */ /* health ---------------------------------------------------------- */
"health.ok": "Ollama is reachable and both models are available.", "health.ok": "Ollama is reachable and both models are available.",
@ -300,6 +320,7 @@ const en: Record<string, string> = {
"deck.titleLabel": "Deck title", "deck.titleLabel": "Deck title",
"deck.rename": "Rename", "deck.rename": "Rename",
"deck.export": "Export JSON", "deck.export": "Export JSON",
"deck.share": "Share",
"deck.regenerate": "Regenerate deck", "deck.regenerate": "Regenerate deck",
"deck.reallyDelete": "Really delete", "deck.reallyDelete": "Really delete",
"deck.delete": "Delete deck", "deck.delete": "Delete deck",
@ -777,6 +798,26 @@ const cs: Record<string, string> = {
"settings.details": "Podrobnosti", "settings.details": "Podrobnosti",
"settings.privacyLine": "Tvůj klíč zůstává v tomto prohlížeči.", "settings.privacyLine": "Tvůj klíč zůstává v tomto prohlížeči.",
"settings.recheck": "Zkontrolovat připojení", "settings.recheck": "Zkontrolovat připojení",
"settings.haptics": "Haptika",
"settings.hapticsNote": "Vibrace na podporovaných telefonech.",
/* offline / share / toasts ---------------------------------------- */
"offline.banner": "Jsi offline — uložené sady fungují dál.",
"offline.generate": "Jsi offline — pro vytvoření se připoj.",
"share.title": "Přidat do Freezma",
"share.intro": "Co s tím mám udělat?",
"share.preview": "Sdílený obsah",
"share.loading": "Načítám sdílený obsah…",
"share.expired": "Sdílení vypršelo — zkus to znovu.",
"share.tooLarge": "To je příliš velké (max 30 MB).",
"share.nothing": "Není co přidat.",
"share.copied": "Zkopírováno do schránky.",
"share.failed": "Sdílení tu není dostupné.",
"toast.deckDeleted": "Sada smazána",
"toast.fileRemoved": "Soubor odebrán",
"toast.undo": "Vrátit",
"upload.pasteClipboard": "Vložit ze schránky",
"upload.clipboardDenied": "Přístup ke schránce byl zamítnut.",
/* health ---------------------------------------------------------- */ /* health ---------------------------------------------------------- */
"health.ok": "Ollama je dostupná a oba modely jsou k dispozici.", "health.ok": "Ollama je dostupná a oba modely jsou k dispozici.",
@ -929,6 +970,7 @@ const cs: Record<string, string> = {
"deck.titleLabel": "Název sady", "deck.titleLabel": "Název sady",
"deck.rename": "Přejmenovat", "deck.rename": "Přejmenovat",
"deck.export": "Exportovat JSON", "deck.export": "Exportovat JSON",
"deck.share": "Sdílet",
"deck.regenerate": "Znovu vytvořit sadu", "deck.regenerate": "Znovu vytvořit sadu",
"deck.reallyDelete": "Opravdu smazat", "deck.reallyDelete": "Opravdu smazat",
"deck.delete": "Smazat sadu", "deck.delete": "Smazat sadu",

View file

@ -20,9 +20,11 @@ export type BrowserSettings = {
* and model names always come from the server's .env, never from the client. * and model names always come from the server's .env, never from the client.
*/ */
apiKey: string; apiKey: string;
/** Best-effort haptics on supported devices (phones). On by default. */
haptics: boolean;
}; };
const EMPTY: BrowserSettings = { apiKey: "" }; const EMPTY: BrowserSettings = { apiKey: "", haptics: true };
let cached: BrowserSettings | null = null; let cached: BrowserSettings | null = null;
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
@ -33,7 +35,10 @@ function read(): BrowserSettings {
const raw = window.localStorage.getItem(STORAGE_KEY); const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return EMPTY; if (!raw) return EMPTY;
const parsed = JSON.parse(raw) as Partial<BrowserSettings>; const parsed = JSON.parse(raw) as Partial<BrowserSettings>;
return { apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey.slice(0, 512) : "" }; return {
apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey.slice(0, 512) : "",
haptics: parsed.haptics !== false,
};
} catch { } catch {
return EMPTY; return EMPTY;
} }
@ -42,8 +47,8 @@ function read(): BrowserSettings {
function write(next: BrowserSettings): void { function write(next: BrowserSettings): void {
cached = next; cached = next;
try { try {
if (next.apiKey) { if (next.apiKey || next.haptics === false) {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ apiKey: next.apiKey })); window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ apiKey: next.apiKey, haptics: next.haptics }));
} else { } else {
window.localStorage.removeItem(STORAGE_KEY); window.localStorage.removeItem(STORAGE_KEY);
} }
@ -72,12 +77,13 @@ export function getApiKey(): string {
return settingsStore.getSnapshot().apiKey; return settingsStore.getSnapshot().apiKey;
} }
export function saveSettings(next: BrowserSettings): void { /** Merge-update: callers only pass what they changed. */
write(next); export function saveSettings(next: Partial<BrowserSettings>): void {
write({ ...settingsStore.getSnapshot(), ...next });
} }
export function clearApiKey(): void { export function clearApiKey(): void {
write({ apiKey: "" }); write({ ...settingsStore.getSnapshot(), apiKey: "" });
} }
/** Header used to hand the key to our own API routes for one request. */ /** Header used to hand the key to our own API routes for one request. */

43
lib/share-inbox.ts Normal file
View file

@ -0,0 +1,43 @@
/**
* Short-lived, single-use store for content shared into Freezmo from another
* app (the Web Share Target POST). Lives in memory only, expires after ten
* minutes and is deleted on first read — nothing is written to disk.
*/
export type SharedFile = { name: string; type: string; data: string };
export type SharedPayload = {
title?: string;
text?: string;
url?: string;
files: SharedFile[];
expires: number;
};
type Store = Map<string, SharedPayload>;
const globalStore = globalThis as typeof globalThis & { __freezmoShareStore?: Store };
const store: Store = (globalStore.__freezmoShareStore ??= new Map());
const TTL_MS = 10 * 60 * 1000;
export const MAX_SHARE_BYTES = 30 * 1024 * 1024;
function prune(): void {
const now = Date.now();
for (const [id, payload] of store) {
if (payload.expires < now) store.delete(id);
}
}
export function putShare(id: string, payload: Omit<SharedPayload, "expires">): void {
prune();
store.set(id, { ...payload, expires: Date.now() + TTL_MS });
}
export function takeShare(id: string): SharedPayload | null {
prune();
const payload = store.get(id);
if (!payload) return null;
store.delete(id);
return payload;
}

42
lib/share-pending.ts Normal file
View file

@ -0,0 +1,42 @@
"use client";
/**
* Content handed over by the share chooser: a small sessionStorage payload
* the wizards pick up on mount. Files are rebuilt from base64 into real File
* objects so the normal intake (validation, compression, parsing) runs.
*/
export type PendingShareFile = { name: string; type: string; data: string };
const KEY = "freezmo.share.pending.v1";
export function setPendingShare(payload: { text?: string; files?: PendingShareFile[] }): void {
try {
window.sessionStorage.setItem(KEY, JSON.stringify(payload));
} catch {
/* private mode */
}
}
function base64ToBytes(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
return bytes;
}
/** Reads and clears the pending share; returns real File objects. */
export function takePendingShare(): { text: string; files: File[] } | null {
try {
const raw = window.sessionStorage.getItem(KEY);
if (!raw) return null;
window.sessionStorage.removeItem(KEY);
const parsed = JSON.parse(raw) as { text?: string; files?: PendingShareFile[] };
const files = (parsed.files ?? []).map(
(file) => new File([base64ToBytes(file.data) as unknown as BlobPart], file.name, { type: file.type }),
);
return { text: parsed.text ?? "", files };
} catch {
return null;
}
}

72
lib/share.ts Normal file
View file

@ -0,0 +1,72 @@
"use client";
import { exportDeck } from "./storage";
import { showToast } from "./toast";
import type { Deck, HomeworkRecord } from "./schemas";
import { t } from "./i18n";
/**
* Web Share with graceful fallbacks: file share (deck JSON) where the browser
* supports it, text share otherwise, clipboard copy as the last resort.
*/
type ShareNavigator = Navigator & {
canShare?: (data: { files?: File[]; text?: string; title?: string }) => boolean;
share?: (data: { files?: File[]; text?: string; title?: string }) => Promise<void>;
};
function deckFile(deck: Deck): File {
return new File([exportDeck(deck)], `${deck.title.replace(/[^\p{L}\p{N} _-]/gu, "").trim() || "deck"}.json`, {
type: "application/json",
});
}
async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
/* fall through */
}
return false;
}
export async function shareDeck(deck: Deck): Promise<void> {
const nav = navigator as ShareNavigator;
const file = deckFile(deck);
try {
if (nav.share && nav.canShare?.({ files: [file] })) {
await nav.share({ files: [file], title: deck.title });
return;
}
if (nav.share) {
await nav.share({ title: deck.title, text: deck.title });
return;
}
} catch (error) {
// The user cancelling the share sheet is not an error.
if (error instanceof DOMException && error.name === "AbortError") return;
}
if (await copyText(exportDeck(deck))) showToast({ message: t("share.copied") });
else showToast({ message: t("share.failed") });
}
export async function shareHomework(record: HomeworkRecord): Promise<void> {
const nav = navigator as ShareNavigator;
const text =
record.mode === "make"
? record.answer
: record.steps.map((step, index) => `${index + 1}. ${step.title}`).join("\n");
try {
if (nav.share) {
await nav.share({ title: record.title, text });
return;
}
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
}
if (await copyText(text)) showToast({ message: t("share.copied") });
else showToast({ message: t("share.failed") });
}

31
lib/toast.ts Normal file
View file

@ -0,0 +1,31 @@
"use client";
/**
* Tiny toast bus: destructive-but-reversible actions (delete deck, remove a
* file) show an Undo toast instead of a blocking confirm.
*/
export type ToastInput = {
message: string;
actionLabel?: string;
onAction?: () => void;
durationMs?: number;
};
export type Toast = ToastInput & { id: number };
const listeners = new Set<(toast: Toast) => void>();
let nextId = 1;
export function showToast(input: ToastInput): number {
const toast: Toast = { durationMs: 5000, ...input, id: nextId++ };
listeners.forEach((listener) => listener(toast));
return toast.id;
}
export const toastBus = {
subscribe(listener: (toast: Toast) => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
},
};

109
lib/use-back-close.ts Normal file
View file

@ -0,0 +1,109 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
/**
* Lets the system Back gesture/button close the topmost layer first.
*
* While `open` is true a same-URL history entry is pushed; a popstate (the
* Android back gesture, the browser back button, an iOS edge swipe in
* 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.
*/
type State = { freezmoLayer?: boolean };
const stack: symbol[] = [];
export function useBackClose(open: boolean, onClose: () => void) {
const closeRef = useRef(onClose);
closeRef.current = onClose;
const openRef = useRef(open);
openRef.current = open;
const idRef = useRef<symbol>(Symbol("layer"));
const pushedRef = useRef(false);
const closingRef = useRef(false);
const skipRef = useRef(false);
const poppedRef = useRef(false);
const urlRef = useRef("");
/** Call before closing for a navigation: the entry is left for the skipper. */
const skipRewind = useCallback(() => {
skipRef.current = true;
}, []);
// Push the entry when the layer opens; rewind it when it closes on its own.
useEffect(() => {
const id = idRef.current;
if (open) {
if (pushedRef.current) return;
pushedRef.current = true;
urlRef.current = window.location.href;
stack.push(id);
try {
window.history.pushState({ freezmoLayer: true }, "");
} catch {
pushedRef.current = false;
stack.splice(stack.indexOf(id), 1);
}
return;
}
if (!pushedRef.current) return;
pushedRef.current = false;
const index = stack.indexOf(id);
if (index >= 0) stack.splice(index, 1);
if (skipRef.current) {
// Closed because of a navigation: leave the entry alone, the permanent
// listener skips it on the next Back press.
skipRef.current = false;
return;
}
if (poppedRef.current) {
// The Back press already consumed the entry — nothing to rewind.
poppedRef.current = false;
return;
}
if (window.location.href === urlRef.current) {
closingRef.current = true;
try {
window.history.back();
} catch {
/* ignore */
}
}
}, [open]);
// One listener per mounted component: close this layer if it is the top of
// the stack; otherwise skip stale entries left behind by navigations.
useEffect(() => {
const id = idRef.current;
const onPop = () => {
if (closingRef.current) {
closingRef.current = false;
return;
}
if (openRef.current) {
if (stack[stack.length - 1] !== id) return;
poppedRef.current = true;
closeRef.current();
return;
}
const state = window.history.state as State | null;
if (state?.freezmoLayer) {
try {
window.history.back();
} catch {
/* ignore */
}
}
};
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
return { skipRewind };
}

21
lib/use-online.ts Normal file
View file

@ -0,0 +1,21 @@
"use client";
import { useEffect, useState } from "react";
/** True while the browser reports a connection. Starts optimistic (SSR). */
export function useOnline(): boolean {
const [online, setOnline] = useState(true);
useEffect(() => {
const sync = () => setOnline(navigator.onLine);
sync();
window.addEventListener("online", sync);
window.addEventListener("offline", sync);
return () => {
window.removeEventListener("online", sync);
window.removeEventListener("offline", sync);
};
}, []);
return online;
}

51
lib/use-wake-lock.ts Normal file
View file

@ -0,0 +1,51 @@
"use client";
import { useEffect } from "react";
type WakeLockSentinelLike = {
release: () => Promise<void>;
addEventListener: (type: string, listener: () => void) => void;
};
type NavigatorWithWakeLock = Navigator & {
wakeLock?: { request: (type: "screen") => Promise<WakeLockSentinelLike> };
};
/**
* Keeps the screen awake while a study session or guide is open
* (feature-detected; silently does nothing where unsupported or denied).
* Re-acquires when the tab becomes visible again, releases on leave.
*/
export function useWakeLock(active = true): void {
useEffect(() => {
if (!active || typeof navigator === "undefined") return;
const wakeLock = (navigator as NavigatorWithWakeLock).wakeLock;
if (!wakeLock) return;
let sentinel: WakeLockSentinelLike | null = null;
let disposed = false;
const acquire = async () => {
try {
sentinel = await wakeLock.request("screen");
sentinel.addEventListener("release", () => {
sentinel = null;
});
} catch {
/* denied or not allowed without a gesture — fine */
}
};
const onVisibility = () => {
if (document.visibilityState === "visible" && !sentinel && !disposed) void acquire();
};
void acquire();
document.addEventListener("visibilitychange", onVisibility);
return () => {
disposed = true;
document.removeEventListener("visibilitychange", onVisibility);
void sentinel?.release().catch(() => {});
};
}, [active]);
}

66
lib/wizard-draft.ts Normal file
View file

@ -0,0 +1,66 @@
"use client";
/**
* Draft persistence for the wizards, so an OS kill (or an accidental reload)
* doesn't lose the user's photos, pasted text or options. Best-effort: photo
* data URLs can be large, so every write is guarded and skipped on quota
* errors instead of breaking the flow.
*/
export type WizardDraft = {
v: 1;
savedAt: number;
text?: string;
focus?: string;
difficulty?: string;
count?: number;
folderId?: string;
images?: Array<{ id: string; name: string; dataUrl: string; bytes: number; width: number; height: number }>;
};
const TTL_MS = 24 * 60 * 60 * 1000;
const MAX_BYTES = 3_000_000;
function keyFor(scope: "deck" | "homework"): string {
return `freezmo.draft.${scope}.v1`;
}
export function saveWizardDraft(scope: "deck" | "homework", draft: Omit<WizardDraft, "v" | "savedAt">): void {
try {
const images = (draft.images ?? []).filter(() => true);
const size = images.reduce((sum, image) => sum + image.dataUrl.length, 0);
const payload: WizardDraft = {
v: 1,
savedAt: Date.now(),
...draft,
// Photos over the budget are dropped rather than failing the whole save.
images: size > MAX_BYTES ? [] : images,
};
window.localStorage.setItem(keyFor(scope), JSON.stringify(payload));
} catch {
/* quota or private mode — drafts are a nice-to-have */
}
}
export function loadWizardDraft(scope: "deck" | "homework"): WizardDraft | null {
try {
const raw = window.localStorage.getItem(keyFor(scope));
if (!raw) return null;
const parsed = JSON.parse(raw) as WizardDraft;
if (parsed.v !== 1 || Date.now() - parsed.savedAt > TTL_MS) {
window.localStorage.removeItem(keyFor(scope));
return null;
}
return parsed;
} catch {
return null;
}
}
export function clearWizardDraft(scope: "deck" | "homework"): void {
try {
window.localStorage.removeItem(keyFor(scope));
} catch {
/* ignore */
}
}

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<g fill="#ffffff">
<rect x="96" y="96" width="320" height="320" rx="72" fill="none" stroke="#ffffff" stroke-width="34" />
<path d="M256 176v160M191 216l130 80M321 216l-130 80" fill="none" stroke="#ffffff" stroke-width="26" stroke-linecap="round" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 336 B

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-v22"; const CACHE_VERSION = "freezmo-v23";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;