Stage 7 (wip): UI shell, libs, mock transport, screens (lobby->game), e2e smoke
- plain Svelte 5 + TS + Vite (no SvelteKit); CSS-token design system (Telegram-ready), hash router, IndexedDB session - pure libs: domain model, premium/value maps ported from solver, board replay, placement state machine, i18n en/ru - in-memory mock transport + seed data; pnpm start runs lobby->active game->board with no backend - board: pointer-drag + tap placement, MakeMove (popup / 1s-hold commit), two-state zoom, blank chooser, exchange, hint, word-check, chat - Playwright smoke (mock) green; svelte-check clean; mock bundle ~37 KB gzip
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
// Central app state + actions. Holds the session/profile, client preferences, a
|
||||
// transient toast, and the latest live event (screens react to it via $effect). All
|
||||
// gateway calls funnel through here so errors map to one user-facing toast and an
|
||||
// expired session logs out.
|
||||
|
||||
import type { Profile, PushEvent, Session } from './model';
|
||||
import { gateway } from './gateway';
|
||||
import { GatewayError } from './client';
|
||||
import { navigate, router } from './router.svelte';
|
||||
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
||||
import { applyReduceMotion, applyTheme, type ThemePref } from './theme';
|
||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
||||
|
||||
export interface Toast {
|
||||
kind: 'error' | 'info';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const app = $state<{
|
||||
ready: boolean;
|
||||
session: Session | null;
|
||||
profile: Profile | null;
|
||||
toast: Toast | null;
|
||||
lastEvent: PushEvent | null;
|
||||
theme: ThemePref;
|
||||
locale: Locale;
|
||||
reduceMotion: boolean;
|
||||
localeLocked: boolean;
|
||||
}>({
|
||||
ready: false,
|
||||
session: null,
|
||||
profile: null,
|
||||
toast: null,
|
||||
lastEvent: null,
|
||||
theme: 'auto',
|
||||
locale: 'en',
|
||||
reduceMotion: false,
|
||||
localeLocked: false,
|
||||
});
|
||||
|
||||
let unsubscribeStream: (() => void) | null = null;
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
|
||||
app.toast = { kind, text };
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => (app.toast = null), 4000);
|
||||
}
|
||||
|
||||
/** handleError maps a GatewayError to a toast; an invalid session logs out. */
|
||||
export function handleError(err: unknown): void {
|
||||
if (err instanceof GatewayError) {
|
||||
if (err.code === 'session_invalid' || err.code === 'unauthenticated') {
|
||||
void logout();
|
||||
return;
|
||||
}
|
||||
showToast(t(errorKey(err.code)), 'error');
|
||||
return;
|
||||
}
|
||||
showToast(t('error.generic'), 'error');
|
||||
}
|
||||
|
||||
function openStream(): void {
|
||||
closeStream();
|
||||
unsubscribeStream = gateway.subscribe(
|
||||
(e) => {
|
||||
app.lastEvent = e;
|
||||
if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
|
||||
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
|
||||
} else if (e.kind === 'nudge') {
|
||||
showToast(t('chat.nudge'), 'info');
|
||||
} else if (e.kind === 'your_turn') {
|
||||
showToast(t('game.yourTurn'), 'info');
|
||||
} else if (e.kind === 'match_found') {
|
||||
navigate(`/game/${e.gameId}`);
|
||||
}
|
||||
},
|
||||
() => showToast(t('error.unavailable'), 'error'),
|
||||
);
|
||||
}
|
||||
|
||||
function closeStream(): void {
|
||||
unsubscribeStream?.();
|
||||
unsubscribeStream = null;
|
||||
}
|
||||
|
||||
async function adoptSession(s: Session): Promise<void> {
|
||||
gateway.setToken(s.token);
|
||||
app.session = s;
|
||||
await saveSession(s);
|
||||
try {
|
||||
app.profile = await gateway.profileGet();
|
||||
if (!app.localeLocked) setLocale(localeFrom(app.profile.preferredLanguage, app.locale));
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
openStream();
|
||||
}
|
||||
|
||||
export async function bootstrap(): Promise<void> {
|
||||
const prefs = await loadPrefs();
|
||||
app.theme = prefs.theme ?? 'auto';
|
||||
app.reduceMotion = prefs.reduceMotion ?? false;
|
||||
applyTheme(app.theme);
|
||||
applyReduceMotion(app.reduceMotion);
|
||||
if (prefs.locale) {
|
||||
app.locale = prefs.locale;
|
||||
app.localeLocked = true;
|
||||
setLocale(prefs.locale);
|
||||
} else {
|
||||
const guess = localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en');
|
||||
app.locale = guess;
|
||||
setLocale(guess);
|
||||
}
|
||||
|
||||
const saved = await loadSession();
|
||||
if (saved) {
|
||||
await adoptSession(saved);
|
||||
if (router.route.name === 'login') navigate('/');
|
||||
} else if (router.route.name !== 'login') {
|
||||
navigate('/login');
|
||||
}
|
||||
app.ready = true;
|
||||
}
|
||||
|
||||
export async function loginGuest(): Promise<void> {
|
||||
try {
|
||||
const s = await gateway.authGuest(app.locale);
|
||||
await adoptSession(s);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestEmailCode(email: string): Promise<boolean> {
|
||||
try {
|
||||
await gateway.authEmailRequest(email);
|
||||
return true;
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginEmail(email: string, code: string): Promise<void> {
|
||||
try {
|
||||
const s = await gateway.authEmailLogin(email, code);
|
||||
await adoptSession(s);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
closeStream();
|
||||
gateway.setToken(null);
|
||||
await clearSession();
|
||||
app.session = null;
|
||||
app.profile = null;
|
||||
navigate('/login');
|
||||
}
|
||||
|
||||
function persistPrefs(): void {
|
||||
void savePrefs({ theme: app.theme, locale: app.locale, reduceMotion: app.reduceMotion });
|
||||
}
|
||||
|
||||
export function setTheme(theme: ThemePref): void {
|
||||
app.theme = theme;
|
||||
applyTheme(theme);
|
||||
persistPrefs();
|
||||
}
|
||||
|
||||
export function setLocalePref(locale: Locale): void {
|
||||
app.locale = locale;
|
||||
app.localeLocked = true;
|
||||
setLocale(locale);
|
||||
persistPrefs();
|
||||
}
|
||||
|
||||
export function setReduceMotion(on: boolean): void {
|
||||
app.reduceMotion = on;
|
||||
applyReduceMotion(on);
|
||||
persistPrefs();
|
||||
}
|
||||
Reference in New Issue
Block a user