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:
parent
e66141e9a0
commit
4369ba1a17
34 changed files with 1354 additions and 65 deletions
63
lib/haptics.ts
Normal file
63
lib/haptics.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
|
|
@ -148,6 +148,26 @@ const en: Record<string, string> = {
|
|||
"settings.details": "Details",
|
||||
"settings.privacyLine": "Your key stays in this browser.",
|
||||
"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.ok": "Ollama is reachable and both models are available.",
|
||||
|
|
@ -300,6 +320,7 @@ const en: Record<string, string> = {
|
|||
"deck.titleLabel": "Deck title",
|
||||
"deck.rename": "Rename",
|
||||
"deck.export": "Export JSON",
|
||||
"deck.share": "Share",
|
||||
"deck.regenerate": "Regenerate deck",
|
||||
"deck.reallyDelete": "Really delete",
|
||||
"deck.delete": "Delete deck",
|
||||
|
|
@ -777,6 +798,26 @@ const cs: Record<string, string> = {
|
|||
"settings.details": "Podrobnosti",
|
||||
"settings.privacyLine": "Tvůj klíč zůstává v tomto prohlížeči.",
|
||||
"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.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.rename": "Přejmenovat",
|
||||
"deck.export": "Exportovat JSON",
|
||||
"deck.share": "Sdílet",
|
||||
"deck.regenerate": "Znovu vytvořit sadu",
|
||||
"deck.reallyDelete": "Opravdu smazat",
|
||||
"deck.delete": "Smazat sadu",
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ export type BrowserSettings = {
|
|||
* and model names always come from the server's .env, never from the client.
|
||||
*/
|
||||
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;
|
||||
const listeners = new Set<() => void>();
|
||||
|
|
@ -33,7 +35,10 @@ function read(): BrowserSettings {
|
|||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return EMPTY;
|
||||
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 {
|
||||
return EMPTY;
|
||||
}
|
||||
|
|
@ -42,8 +47,8 @@ function read(): BrowserSettings {
|
|||
function write(next: BrowserSettings): void {
|
||||
cached = next;
|
||||
try {
|
||||
if (next.apiKey) {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ apiKey: next.apiKey }));
|
||||
if (next.apiKey || next.haptics === false) {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ apiKey: next.apiKey, haptics: next.haptics }));
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
|
@ -72,12 +77,13 @@ export function getApiKey(): string {
|
|||
return settingsStore.getSnapshot().apiKey;
|
||||
}
|
||||
|
||||
export function saveSettings(next: BrowserSettings): void {
|
||||
write(next);
|
||||
/** Merge-update: callers only pass what they changed. */
|
||||
export function saveSettings(next: Partial<BrowserSettings>): void {
|
||||
write({ ...settingsStore.getSnapshot(), ...next });
|
||||
}
|
||||
|
||||
export function clearApiKey(): void {
|
||||
write({ apiKey: "" });
|
||||
write({ ...settingsStore.getSnapshot(), apiKey: "" });
|
||||
}
|
||||
|
||||
/** Header used to hand the key to our own API routes for one request. */
|
||||
|
|
|
|||
43
lib/share-inbox.ts
Normal file
43
lib/share-inbox.ts
Normal 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
42
lib/share-pending.ts
Normal 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
72
lib/share.ts
Normal 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
31
lib/toast.ts
Normal 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
109
lib/use-back-close.ts
Normal 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
21
lib/use-online.ts
Normal 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
51
lib/use-wake-lock.ts
Normal 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
66
lib/wizard-draft.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue