Files
ark-stronghold-protocol/public/js/ui/toasts.js
T
sganggs a12a539e4d feedback5 WI: UI strings through t() with an English switch on the title screen and in 设置; pilot screens converted
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).
2026-10-05 20:19:58 +08:00

139 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>`;
}