public/i18n/en.json: Chinese msgid -> English, seeded from PR #70 (@YuriRestia, credited) for every Chinese literal of the current code (≈830 entries; 966 of the 1 036 literals left for phase 2 already translated), completed by hand in the official EN terms. public/js/ui/lang.js: boot language (?lang=en|zh, then localStorage sp.pref.lang, else Chinese; the browser language is not consulted), switchLang, useLang, LangToggle; main.js renders after initLang and re-renders in place on a switch, and translates server texts (m.toast / m.ticker frames, room.closed reasons, error codes via ui/toasts.js describeError). Converted: title screen (English title, wordmark hidden), lobby, room, settings (语言 row, On/Off, quality, hotkeys), the shell (crash screen, toasts) and DifficultyTag. tools/i18n.mjs: extract (msgids + untranslated literals per file), check (msgids missing from en.json), codemod (html`` text / attributes / strings / template literals -> t(), nested strings, N_ for module level, i18n-ignore lines), seed (fill en.json from a msgid map).
139 lines
5.1 KiB
JavaScript
139 lines
5.1 KiB
JavaScript
// Global toast notifications (top-centre stack).
|
||
//
|
||
// Imperative API usable from anywhere: `toast(text, kind)`, `toastError(err)`; render <ToastHost/>
|
||
// once near the app root. Identical messages shown within a short window are merged (with a ×N
|
||
// counter) instead of stacking, and the stack is capped so an error storm cannot flood the screen.
|
||
|
||
import { h } from '../../vendor/preact.module.js';
|
||
import { useEffect, useReducer } from '../../vendor/hooks.module.js';
|
||
import htm from '../../vendor/htm.module.js';
|
||
import { ERR_TEXT } from '../../../shared/constants.js';
|
||
import { t } from '../../../shared/i18n.js';
|
||
|
||
// no shared static vnodes (see components.js hFresh: htm's static cache would retain unmounted DOM)
|
||
function hFresh(type, props, ...children) {
|
||
if (this && typeof this === 'object') this[0] = 3;
|
||
return h(type, props, ...children);
|
||
}
|
||
const html = htm.bind(hFresh);
|
||
|
||
export const TOAST_KINDS = ['info', 'success', 'warn', 'error'];
|
||
const MAX_TOASTS = 4;
|
||
const DEFAULT_MS = { info: 2600, success: 2400, warn: 3200, error: 3600 };
|
||
const MERGE_WINDOW_MS = 2500;
|
||
|
||
let seq = 0;
|
||
/** @type {{id:number, text:string, kind:string, count:number, at:number, ttl:number, leaving:boolean}[]} */
|
||
let toasts = [];
|
||
const listeners = new Set();
|
||
const timers = new Map();
|
||
|
||
const emit = () => { for (const fn of [...listeners]) { try { fn(toasts); } catch (err) { console.error(err); } } };
|
||
|
||
function schedule(t) {
|
||
clearTimeout(timers.get(t.id));
|
||
timers.set(t.id, setTimeout(() => dismissToast(t.id), t.ttl));
|
||
}
|
||
|
||
/**
|
||
* Show a toast.
|
||
* @param {string} text message (plain text)
|
||
* @param {'info'|'success'|'warn'|'error'} [kind]
|
||
* @param {{ ttl?: number }} [opts]
|
||
* @returns {number} toast id
|
||
*/
|
||
export function toast(text, kind = 'info', opts = {}) {
|
||
const k = TOAST_KINDS.includes(kind) ? kind : 'info';
|
||
const msg = String(text ?? '').slice(0, 200).trim();
|
||
if (!msg) return -1;
|
||
const now = Date.now();
|
||
const ttl = Number.isFinite(opts.ttl) && opts.ttl > 0 ? opts.ttl : DEFAULT_MS[k];
|
||
const same = toasts.find((t) => !t.leaving && t.text === msg && t.kind === k && now - t.at < MERGE_WINDOW_MS + t.ttl);
|
||
if (same) {
|
||
toasts = toasts.map((t) => (t === same ? { ...t, count: t.count + 1, at: now, ttl } : t));
|
||
schedule(toasts.find((t) => t.id === same.id));
|
||
emit();
|
||
return same.id;
|
||
}
|
||
const t = { id: ++seq, text: msg, kind: k, count: 1, at: now, ttl, leaving: false };
|
||
toasts = [...toasts, t];
|
||
// Cap the stack: drop the oldest immediately.
|
||
while (toasts.length > MAX_TOASTS) {
|
||
const old = toasts[0];
|
||
clearTimeout(timers.get(old.id));
|
||
timers.delete(old.id);
|
||
toasts = toasts.slice(1);
|
||
}
|
||
schedule(t);
|
||
emit();
|
||
return t.id;
|
||
}
|
||
|
||
/**
|
||
* Dismiss a toast (plays the exit animation, then removes it).
|
||
* @param {number} id
|
||
*/
|
||
export function dismissToast(id) {
|
||
const t = toasts.find((x) => x.id === id);
|
||
if (!t) return;
|
||
clearTimeout(timers.get(id));
|
||
if (t.leaving) return;
|
||
toasts = toasts.map((x) => (x.id === id ? { ...x, leaving: true } : x));
|
||
emit();
|
||
timers.set(id, setTimeout(() => {
|
||
timers.delete(id);
|
||
toasts = toasts.filter((x) => x.id !== id);
|
||
emit();
|
||
}, 200));
|
||
}
|
||
|
||
/**
|
||
* Text for an error-ish value: NetError / {code,msg} / Error / string — in the current language (the Chinese texts are
|
||
* msgids: ERR_TEXT, net.js CLIENT_ERR_TEXT, a server `msg`; anything else is shown as it is).
|
||
* @param {any} err
|
||
* @returns {string}
|
||
*/
|
||
export function describeError(err) {
|
||
if (!err) return t('发生未知错误');
|
||
if (typeof err === 'string') return t(Object.hasOwn(ERR_TEXT, err) ? ERR_TEXT[err] : err);
|
||
if (err.code && Object.hasOwn(ERR_TEXT, err.code)) return t(ERR_TEXT[err.code]);
|
||
if (typeof err.message === 'string' && err.message) return t(err.message);
|
||
if (typeof err.msg === 'string' && err.msg) return t(err.msg);
|
||
return t('发生未知错误');
|
||
}
|
||
|
||
/**
|
||
* Show an error toast for a failed request / error push.
|
||
* @param {any} err
|
||
* @returns {number}
|
||
*/
|
||
export function toastError(err) {
|
||
return toast(describeError(err), 'error');
|
||
}
|
||
|
||
const ICONS = {
|
||
info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 15h-2v-6h2v6Zm0-8h-2V7h2v2Z',
|
||
success: 'M9.5 16.2 5.3 12l-1.4 1.4 5.6 5.6L20.1 8.4 18.7 7z',
|
||
warn: 'M1 21h22L12 2 1 21Zm12-3h-2v-2h2v2Zm0-4h-2v-4h2v4Z',
|
||
error: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm5 13.6L15.6 17 12 13.4 8.4 17 7 15.6 10.6 12 7 8.4 8.4 7 12 10.6 15.6 7 17 8.4 13.4 12 17 15.6Z',
|
||
};
|
||
|
||
/** Renders the toast stack. Mount once. */
|
||
export function ToastHost() {
|
||
const [, force] = useReducer((c) => c + 1, 0);
|
||
useEffect(() => {
|
||
listeners.add(force);
|
||
return () => listeners.delete(force);
|
||
}, []);
|
||
return html`
|
||
<div class="toast-host" role="status" aria-live="polite">
|
||
${toasts.map((t) => html`
|
||
<div key=${t.id} class=${`toast toast--${t.kind}${t.leaving ? ' is-leaving' : ''}`}
|
||
onClick=${() => dismissToast(t.id)}>
|
||
<svg class="toast__icon" viewBox="0 0 24 24" aria-hidden="true"><path d=${ICONS[t.kind]} /></svg>
|
||
<span class="toast__text">${t.text}</span>
|
||
${t.count > 1 ? html`<span class="toast__count">×${t.count}</span>` : null}
|
||
</div>`)}
|
||
</div>`;
|
||
}
|