pwa and mobile client

This commit is contained in:
azykov@mail.ru
2026-09-29 15:55:25 +03:00
parent 657009bd6c
commit 1935acf2ff
96 changed files with 8370 additions and 58 deletions
+133
View File
@@ -0,0 +1,133 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Screen stack kept in the browser history: every screen change is a history entry holding the whole
* stack, so the toolbar back button, the browser back button and the Android back button all go
* through history.back() and behave the same. A reload restores the stack from history.state.
*/
export interface LoginPrefill {
server: string;
key: string;
}
export type Route =
| { name: 'cards' }
| { name: 'card'; id: string }
| { name: 'edit'; id?: string }
| { name: 'login'; prefill?: LoginPrefill };
export interface NavState {
stack: Route[];
/** Login opened over the lock screen ("forgot PIN", login link); going back returns to the lock. */
lockedLogin?: { prefill?: LoginPrefill };
}
const STATE_KEY = 'cardy';
function readState(state: unknown): NavState | undefined {
const nav = (state as Record<string, unknown> | null)?.[STATE_KEY] as NavState | undefined;
return nav && Array.isArray(nav.stack) && nav.stack.length > 0 ? nav : undefined;
}
function hashFor(nav: NavState): string {
if (nav.lockedLogin) return '#/login';
const top = nav.stack[nav.stack.length - 1];
switch (top?.name) {
case 'card':
return `#/card/${top.id}`;
case 'edit':
return top.id ? `#/edit/${top.id}` : '#/new';
case 'login':
return '#/login';
default:
return '#/cards';
}
}
// The key is in the prefill of login links: never keep it in the address bar or in history.
function forHistory(nav: NavState): NavState {
return {
stack: nav.stack.map((r) => (r.name === 'login' ? { name: 'login' } : r)),
...(nav.lockedLogin ? { lockedLogin: {} } : {}),
};
}
/** Number of history entries this app pushed above the root screen. */
function depth(nav: NavState): number {
return nav.stack.length - 1 + (nav.lockedLogin ? 1 : 0);
}
const write = (mode: 'push' | 'replace', nav: NavState) =>
history[mode === 'push' ? 'pushState' : 'replaceState']({ [STATE_KEY]: forHistory(nav) }, '', hashFor(nav));
/**
* @param initial stack to start with; `restore` allows taking the stack from history.state (after a reload).
*/
export function useNavigation(initial: Route[], restore: boolean) {
const [nav, setNav] = useState<NavState>(() => (restore && readState(history.state)) || { stack: initial });
const navRef = useRef(nav);
navRef.current = nav;
/** Set while history.back()/go() is in flight: ignore further back requests until popstate. */
const pendingPop = useRef(false);
/** Target of reset(): applied when the history.go() it started lands. */
const pendingReset = useRef<NavState | undefined>(undefined);
useEffect(() => {
write('replace', navRef.current);
const onPop = (event: PopStateEvent) => {
pendingPop.current = false;
const target = pendingReset.current;
if (target) {
pendingReset.current = undefined;
write('replace', target);
setNav(target);
return;
}
// An entry without our state (e.g. the page before the app): fall back to the root screen.
setNav(readState(event.state) ?? { stack: navRef.current.stack.slice(0, 1) });
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
const go = useCallback((next: NavState) => {
write('push', next);
setNav(next);
}, []);
const push = useCallback((route: Route) => go({ stack: [...navRef.current.stack, route] }), [go]);
/** Returns false when already at the root (nothing to go back to inside the app). */
const back = useCallback((): boolean => {
if (pendingPop.current) return true;
if (depth(navRef.current) === 0) return false;
pendingPop.current = true;
history.back();
return true;
}, []);
const replaceTop = useCallback((route: Route) => {
const next = { stack: [...navRef.current.stack.slice(0, -1), route] };
write('replace', next);
setNav(next);
}, []);
/** Replaces the whole stack, unwinding the app's history entries first. */
const reset = useCallback((stack: Route[]) => {
const next: NavState = { stack };
const entries = depth(navRef.current);
if (entries === 0) {
write('replace', next);
setNav(next);
return;
}
pendingReset.current = next;
pendingPop.current = true;
history.go(-entries);
}, []);
const openLockedLogin = useCallback((prefill?: LoginPrefill) => go({ ...navRef.current, lockedLogin: { prefill } }), [go]);
return { nav, push, back, replaceTop, reset, openLockedLogin };
}