Localise the bundled plugins and PWA manifest (all 10 languages)

Plugins had no i18n path, so copy/clock/invite/games showed hardcoded
English and French in every language. Add orbit.i18n (language/t/pick) +
orbit.config() to the plugin API (v4), move every plugin string into the
app locales under plugins.*, and rewrite the four bundled plugins to use it.
Also re-serve the PWA manifest (blob) with the brand name + a localized
description instead of the static French one. All 10 locales: 654 keys, in
sync.
This commit is contained in:
Jean Chevronnet 2026-07-03 19:09:08 +00:00
parent f816abfcc5
commit a7686cb8f1
16 changed files with 717 additions and 42 deletions

View file

@ -20,7 +20,7 @@ Orbit.plugin('orbit-clock', (orbit, log) => {
const mm = String(now.getMinutes()).padStart(2, '0');
return orbit.html`
<span
title="Local time (clock plugin)"
title=${orbit.i18n.t('plugins.clock.title')}
style=${{ font: '600 .82rem/1 inherit', opacity: 0.7, padding: '0 .45rem', alignSelf: 'center' }}
>${hh}:${mm}</span>
`;

View file

@ -37,8 +37,8 @@ Orbit.plugin('orbit-copy', (orbit, log) => {
// No layout styles — inherit the toolbar button look; only tint green when copied.
return orbit.html`
<button
title=${copied ? 'Copied!' : 'Copy message'}
aria-label=${copied ? 'Copied' : 'Copy message'}
title=${copied ? orbit.i18n.t('plugins.copy.copied') : orbit.i18n.t('plugins.copy.title')}
aria-label=${copied ? orbit.i18n.t('plugins.copy.copiedShort') : orbit.i18n.t('plugins.copy.title')}
onClick=${copy}
style=${{ color: copied ? '#2ea043' : undefined }}
>${copied ? '✓' : '⧉'}</button>

View file

@ -15,6 +15,7 @@
Orbit.plugin('games', (orbit, log) => {
const { useState, useEffect } = orbit.React;
const html = orbit.html;
const T = orbit.i18n.t;
const GS = 'gameserv'; // GameServ nick, lower-cased for comparison
// ════════════════════════ decoding GameServ's state ════════════════════════
@ -66,16 +67,16 @@ Orbit.plugin('games', (orbit, log) => {
<div style=${{ display: 'grid', gridTemplateColumns: 'repeat(8,1fr)', width: '300px', maxWidth: '78vw', aspectRatio: '1', border: '1px solid var(--border,#333)', borderRadius: '8px', overflow: 'hidden', userSelect: 'none', margin: '.3rem auto 0' }}>
${order.map((s) => { const light = (cf(s) + cr(s)) % 2 === 1, p = b[s]; return html`<div key=${s} onClick=${() => clickSq(s)} style=${{ display: 'flex', alignItems: 'center', justifyContent: 'center', background: s === sel ? '#caa84a' : light ? '#e9e4d6' : '#7d8aa0', fontSize: '1.65rem', lineHeight: 1, cursor: myTurn ? 'pointer' : 'default', color: p && p === p.toUpperCase() ? '#fff' : '#111', textShadow: p && p === p.toUpperCase() ? '0 1px 1px rgba(0,0,0,.5)' : 'none' }}>${p ? GLYPH[p] : ''}</div>`; })}
</div>
${promo ? html`<div style=${{ textAlign: 'center', marginTop: '.4rem' }}>Promotion :
${promo ? html`<div style=${{ textAlign: 'center', marginTop: '.4rem' }}>${T('plugins.games.promotion')}
${['q', 'r', 'b', 'n'].map((pr) => html`<button key=${pr} onClick=${() => { play(cName(promo.from) + cName(promo.to) + pr); setPromo(null); setSel(-1); }} style=${{ fontSize: '1.5rem', padding: '.2rem .5rem', cursor: 'pointer' }}>${PROMO[pr]}</button>`)}
</div>` : null}
</div>`;
}
const GAMES = {
ttt: { name: 'Morpion', icon: 'XO', decode: flatDecode, Board: TttBoard },
c4: { name: 'Puissance 4', icon: '●', decode: flatDecode, Board: C4Board },
chess: { name: 'Échecs', icon: '♟', decode: chessDecode, Board: ChessBoard },
ttt: { get name() { return T('plugins.games.name.ttt'); }, icon: 'XO', decode: flatDecode, Board: TttBoard },
c4: { get name() { return T('plugins.games.name.c4'); }, icon: '●', decode: flatDecode, Board: C4Board },
chess: { get name() { return T('plugins.games.name.chess'); }, icon: '♟', decode: chessDecode, Board: ChessBoard },
};
// ════════════════════════ store ════════════════════════
@ -110,8 +111,8 @@ Orbit.plugin('games', (orbit, log) => {
if (!g || g.status !== 'active' || g.turn !== g.mySide) return;
if (prev && prev.status === 'active' && prev.turn === g.turn) return; // no change
if (store.open && store.active === g.id && !store.showBoard && !document.hidden) return;
showToast(GAMES[g.type].name + ' : à toi contre ' + g.opp, g.id);
if (document.hidden) { flashTitle('À toi · ' + GAMES[g.type].name + ' · ' + g.opp); beep(); }
showToast(T('plugins.games.toastTurn', { game: GAMES[g.type].name, opp: g.opp }), g.id);
if (document.hidden) { flashTitle(T('plugins.games.titleTurn', { game: GAMES[g.type].name, opp: g.opp })); beep(); }
}
document.addEventListener('visibilitychange', () => { if (!document.hidden) restoreTitle(); });
window.addEventListener('focus', restoreTitle);
@ -126,7 +127,7 @@ Orbit.plugin('games', (orbit, log) => {
const g = { id, type, board: GAMES[type].decode(kv.s), turn: kv.t, mySide: kv.me, opp: kv.opp, status: kv.st, result: kv.r };
store.games[id] = g;
if (!store.active || store.active === id) { store.active = id; store.showBoard = false; }
if (g.status === 'offer' && (!prev || prev.status !== 'offer')) showToast(g.opp + ' te défie · ' + GAMES[type].name, id);
if (g.status === 'offer' && (!prev || prev.status !== 'offer')) showToast(T('plugins.games.toastOffer', { opp: g.opp, game: GAMES[type].name }), id);
notify();
alertTurn(g, prev);
} else if (text.startsWith('GS$TOPSTART')) {
@ -181,10 +182,10 @@ Orbit.plugin('games', (orbit, log) => {
const RANK_BADGE = { Bronze: '🥉', Silver: '🥈', Gold: '🥇', Master: '💎' };
function statusText(g) {
if (g.status === 'offer') return g.opp + ' te défie';
if (g.status === 'pending') return 'En attente de ' + g.opp + '…';
if (g.status === 'over') return g.result === 'draw' ? 'Match nul.' : g.result === 'win' ? 'Tu gagnes !' : 'Tu perds.';
return g.turn === g.mySide ? 'À toi' : 'Au tour de ' + g.opp;
if (g.status === 'offer') return T('plugins.games.stOffer', { opp: g.opp });
if (g.status === 'pending') return T('plugins.games.stPending', { opp: g.opp });
if (g.status === 'over') return g.result === 'draw' ? T('plugins.games.stDraw') : g.result === 'win' ? T('plugins.games.stWin') : T('plugins.games.stLoss');
return g.turn === g.mySide ? T('plugins.games.stYourTurn') : T('plugins.games.stTheirTurn', { opp: g.opp });
}
function Panel() {
@ -202,30 +203,30 @@ Orbit.plugin('games', (orbit, log) => {
return html`<div style=${{ position: 'fixed', right: '14px', bottom: '74px', zIndex: 60, width: '344px', maxWidth: '92vw', maxHeight: '85vh', overflowY: 'auto', WebkitOverflowScrolling: 'touch', background: 'var(--bg2,#15151a)', color: 'var(--tx,#eee)', border: '1px solid var(--border,#333)', borderRadius: '14px', boxShadow: '0 24px 60px -20px rgba(0,0,0,.7)', padding: '.85rem' }}>
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '.6rem' }}>
<strong style=${{ font: '700 .95rem/1 inherit' }}>Jeux</strong>
<strong style=${{ font: '700 .95rem/1 inherit' }}>${T('plugins.games.title')}</strong>
<button onClick=${() => { s.open = false; notify(); }} style=${{ ...btn, padding: '.15rem .45rem' }}></button>
</div>
${myPlayed.length ? html`<div style=${{ display: 'flex', flexDirection: 'column', gap: '.3rem', margin: '-.15rem 0 .55rem' }}>
${myPlayed.map((t) => { const r = ms[t]; return html`<div key=${t} style=${{ display: 'flex', alignItems: 'center', gap: '.45rem', fontSize: '.78rem', padding: '.32rem .55rem', background: 'rgba(255,255,255,.05)', borderRadius: '8px' }}>
<span style=${{ width: '1.1rem', textAlign: 'center' }}>${GAMES[t].icon}</span>
<span style=${{ fontWeight: 700 }}>${RANK_BADGE[r.rank] || ''} ${r.rank}</span>
<span style=${{ fontWeight: 700 }}>${RANK_BADGE[r.rank] || ''} ${T('plugins.games.rank.' + r.rank, { defaultValue: r.rank })}</span>
<span style=${{ color: 'var(--tx-dim,#aaa)' }}>${r.points} pts</span>
<span style=${{ marginLeft: 'auto', color: 'var(--tx-dim,#aaa)' }}>${r.wins} ${r.losses} =${r.draws}</span>
</div>`; })}
</div>` : null}
${invites.map((g) => html`<div key=${g.id} style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '.5rem', background: 'rgba(255,255,255,.05)', borderRadius: '8px', padding: '.45rem .6rem', marginBottom: '.4rem' }}>
<span style=${{ fontSize: '.84rem' }}><b>${g.opp}</b> te défie · ${GAMES[g.type].name}</span>
<span style=${{ fontSize: '.84rem' }}>${T('plugins.games.toastOffer', { opp: g.opp, game: GAMES[g.type].name })}</span>
<span style=${{ display: 'flex', gap: '.3rem' }}>
<button onClick=${() => accept(g.id)} style=${{ ...btn, color: '#2ea043', borderColor: '#2ea043' }}>Accepter</button>
<button onClick=${() => decline(g.id)} style=${btn}>Refuser</button>
<button onClick=${() => accept(g.id)} style=${{ ...btn, color: '#2ea043', borderColor: '#2ea043' }}>${T('plugins.games.accept')}</button>
<button onClick=${() => decline(g.id)} style=${btn}>${T('plugins.games.decline')}</button>
</span>
</div>`)}
${games.length ? html`<div style=${{ display: 'flex', flexWrap: 'wrap', gap: '.3rem', marginBottom: '.5rem', alignItems: 'center' }}>
${games.map((g) => html`<button key=${g.id} onClick=${() => { s.active = g.id; s.showBoard = false; notify(); }} style=${{ ...btn, fontSize: '.76rem', padding: '.22rem .5rem', borderColor: s.active === g.id && !s.showBoard ? 'var(--ac,#5b8cff)' : undefined }}>${GAMES[g.type].icon} ${g.opp}${g.status === 'active' && g.turn === g.mySide ? ' •' : ''}</button>`)}
<button onClick=${() => { s.active = null; notify(); }} title="Nouvelle partie" style=${{ ...btn, fontSize: '.76rem', padding: '.22rem .5rem' }}></button>
<button onClick=${() => { s.active = null; notify(); }} title=${T('plugins.games.newGame')} style=${{ ...btn, fontSize: '.76rem', padding: '.22rem .5rem' }}></button>
</div>` : null}
${!showForm ? (() => {
@ -238,22 +239,22 @@ Orbit.plugin('games', (orbit, log) => {
<${def.Board} game=${g} play=${(mv) => { sendMove(g.id, mv); restoreTitle(); hideToast(); }} key=${g.id + g.status} />
${over ? html`<div style=${{ textAlign: 'center', fontWeight: 700, fontSize: '.92rem', margin: '.45rem 0' }}>${statusText(g)}</div>` : null}
<div style=${{ display: 'flex', gap: '.4rem', justifyContent: 'center', marginTop: '.5rem' }}>
${over ? html`<button onClick=${() => { s.active = null; s.challengeNick = g.opp; notify(); }} style=${btn}>Revanche</button>` : null}
${!over ? html`<button onClick=${() => resign(g.id)} style=${btn}>Abandonner</button>` : null}
${over ? html`<button onClick=${() => { s.active = null; s.challengeNick = g.opp; notify(); }} style=${btn}>${T('plugins.games.rematch')}</button>` : null}
${!over ? html`<button onClick=${() => resign(g.id)} style=${btn}>${T('plugins.games.resign')}</button>` : null}
</div>
</div>`;
})() : html`<div>
<div style=${{ display: 'flex', gap: '.4rem', marginBottom: '.55rem' }}>
<button onClick=${() => { store.showBoard = false; notify(); }} style=${{ ...btn, flex: 1, fontSize: '.78rem', borderColor: !s.showBoard ? 'var(--ac,#5b8cff)' : undefined }}>Nouvelle partie</button>
<button onClick=${() => { store.showBoard = true; fetchTop(store.topType); notify(); }} style=${{ ...btn, flex: 1, fontSize: '.78rem', borderColor: s.showBoard ? 'var(--ac,#5b8cff)' : undefined }}>🏆 Classement</button>
<button onClick=${() => { store.showBoard = false; notify(); }} style=${{ ...btn, flex: 1, fontSize: '.78rem', borderColor: !s.showBoard ? 'var(--ac,#5b8cff)' : undefined }}>${T('plugins.games.newGame')}</button>
<button onClick=${() => { store.showBoard = true; fetchTop(store.topType); notify(); }} style=${{ ...btn, flex: 1, fontSize: '.78rem', borderColor: s.showBoard ? 'var(--ac,#5b8cff)' : undefined }}>🏆 ${T('plugins.games.leaderboard')}</button>
</div>
${s.showBoard ? html`<div>
<div style=${{ display: 'flex', gap: '.3rem', marginBottom: '.5rem' }}>
${Object.keys(GAMES).map((t) => html`<button key=${t} onClick=${() => { store.topType = t; fetchTop(t); notify(); }} style=${{ ...btn, flex: 1, fontSize: '.74rem', padding: '.25rem .3rem', borderColor: s.topType === t ? 'var(--ac,#5b8cff)' : undefined }}>${GAMES[t].icon} ${GAMES[t].name}</button>`)}
</div>
${(() => { const board = s.boards[s.topType];
return !board ? html`<div style=${{ fontSize: '.8rem', color: 'var(--tx-dim,#888)', padding: '.5rem 0' }}>Chargement…</div>`
: board.length === 0 ? html`<div style=${{ fontSize: '.8rem', color: 'var(--tx-dim,#888)', padding: '.5rem 0' }}>Personne au classement. Gagne une partie !</div>`
return !board ? html`<div style=${{ fontSize: '.8rem', color: 'var(--tx-dim,#888)', padding: '.5rem 0' }}>${T('plugins.games.loading')}</div>`
: board.length === 0 ? html`<div style=${{ fontSize: '.8rem', color: 'var(--tx-dim,#888)', padding: '.5rem 0' }}>${T('plugins.games.empty')}</div>`
: board.map((row, i) => html`<div key=${row.account} style=${{ display: 'flex', alignItems: 'center', gap: '.5rem', fontSize: '.82rem', padding: '.32rem .2rem', borderBottom: '1px solid var(--border,rgba(255,255,255,.06))' }}>
<span style=${{ width: '1.3rem', textAlign: 'right', color: 'var(--tx-dim,#aaa)' }}>${i + 1}</span>
<span>${RANK_BADGE[row.tier] || ''}</span>
@ -261,8 +262,8 @@ Orbit.plugin('games', (orbit, log) => {
<span style=${{ color: 'var(--tx-dim,#aaa)' }}>${row.points} pts</span>
</div>`); })()}
</div>` : html`<div>
<div style=${{ fontSize: '.8rem', color: 'var(--tx-dim,#aaa)', marginBottom: '.4rem' }}>Défie n'importe qui de connecté. GameServ arbitre chaque coup. Avec un compte la partie est classée ; en invité, elle est amicale.</div>
<input value=${nick} onInput=${(e) => setNick(e.target.value)} placeholder="Pseudo de l'adversaire" style=${{ width: '100%', boxSizing: 'border-box', padding: '.5rem .6rem', borderRadius: '8px', border: '1px solid var(--border,#444)', background: 'var(--bg,#0e0e12)', color: 'inherit', font: 'inherit', marginBottom: '.5rem' }} />
<div style=${{ fontSize: '.8rem', color: 'var(--tx-dim,#aaa)', marginBottom: '.4rem' }}>${T('plugins.games.challengeIntro')}</div>
<input value=${nick} onInput=${(e) => setNick(e.target.value)} placeholder=${T('plugins.games.oppPlaceholder')} style=${{ width: '100%', boxSizing: 'border-box', padding: '.5rem .6rem', borderRadius: '8px', border: '1px solid var(--border,#444)', background: 'var(--bg,#0e0e12)', color: 'inherit', font: 'inherit', marginBottom: '.5rem' }} />
<div style=${{ display: 'flex', gap: '.4rem' }}>
${Object.keys(GAMES).map((id) => html`<button key=${id} onClick=${() => nick.trim() && challenge(nick.trim(), id)} style=${{ ...btn, flex: 1, fontSize: '.8rem' }}>${GAMES[id].icon} ${GAMES[id].name}</button>`)}
</div>
@ -276,7 +277,7 @@ Orbit.plugin('games', (orbit, log) => {
const games = Object.values(s.games);
const alert = games.some((g) => g.status === 'offer') || games.some((g) => g.status === 'active' && g.turn === g.mySide);
return html`<${orbit.Fragment}>
<button title="Jeux" onClick=${() => { s.open = !s.open; notify(); }} style=${{ position: 'relative', display: 'flex', alignItems: 'center', background: 'transparent', border: 0, color: 'inherit', cursor: 'pointer', padding: '0 .4rem', alignSelf: 'center' }}>
<button title=${T('plugins.games.title')} onClick=${() => { s.open = !s.open; notify(); }} style=${{ position: 'relative', display: 'flex', alignItems: 'center', background: 'transparent', border: 0, color: 'inherit', cursor: 'pointer', padding: '0 .4rem', alignSelf: 'center' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style=${{ display: 'block' }}>
<line x1="6" x2="10" y1="11" y2="11" />
<line x1="8" x2="8" y1="9" y2="13" />
@ -303,11 +304,11 @@ Orbit.plugin('games', (orbit, log) => {
.sort((a, b) => b.points - a.points);
if (!played.length) return null;
const best = played[0]; // their strongest game
return html`<span title=${'Meilleur classement : ' + GAMES[best.t].name} style=${{ display: 'inline-flex', alignItems: 'center', gap: '.3rem', fontSize: '.8rem', fontWeight: 600, padding: '.3rem .6rem', borderRadius: '999px', background: 'rgba(255,255,255,.06)', border: '1px solid var(--border,#444)' }}>${GAMES[best.t].icon} ${RANK_BADGE[best.rank] || ''} ${best.rank} · ${best.points} pts</span>`;
return html`<span title=${T('plugins.games.bestRank', { game: GAMES[best.t].name })} style=${{ display: 'inline-flex', alignItems: 'center', gap: '.3rem', fontSize: '.8rem', fontWeight: 600, padding: '.3rem .6rem', borderRadius: '999px', background: 'rgba(255,255,255,.06)', border: '1px solid var(--border,#444)' }}>${GAMES[best.t].icon} ${RANK_BADGE[best.rank] || ''} ${T('plugins.games.rank.' + best.rank, { defaultValue: best.rank })} · ${best.points} pts</span>`;
}
const refreshStatsFor = (nick) => cmd('STATS ' + nick);
orbit.addUi('topbar_item', () => orbit.h(GamesButton));
orbit.addUserAction((u) => html`<${RankChip} nick=${u.nick} />`);
orbit.addUserAction((u) => html`<button className="pm-chip" onClick=${() => { store.challengeNick = u.nick; store.active = null; store.showBoard = false; store.open = true; notify(); u.close(); }}>♟ Défier à un jeu</button>`);
orbit.addUserAction((u) => html`<button className="pm-chip" onClick=${() => { store.challengeNick = u.nick; store.active = null; store.showBoard = false; store.open = true; notify(); u.close(); }}>♟ ${T('plugins.games.challengeAction')}</button>`);
});

View file

@ -17,6 +17,7 @@ Orbit.plugin('invite', (orbit, log) => {
function useStore() { const [, set] = useState(0); useEffect(() => { const f = () => set((x) => x + 1); store.subs.add(f); return () => store.subs.delete(f); }, []); return store; }
const origin = () => window.location.origin;
const brandName = () => { const b = orbit.config().branding; return (b && b.name) || 'Orbit'; };
function activeChannel() { const a = orbit.state.active(); return a && a[0] === '#' ? a : '#accueil'; }
const salonKey = () => activeChannel().replace(/^#+/, '');
const me = () => (orbit.state.nick() || '').trim();
@ -39,25 +40,25 @@ Orbit.plugin('invite', (orbit, log) => {
function go(which) {
const url = which === 'duel' ? duelLink() : roomLink();
const title = which === 'duel' ? 'Défi aux échecs sur Tchatou' : `Rejoins-moi sur ${chan} · Tchatou`;
const title = which === 'duel' ? orbit.i18n.t('plugins.invite.duelShare', { brand: brandName() }) : orbit.i18n.t('plugins.invite.roomShare', { chan, brand: brandName() });
share(url, title, (how) => setDone({ how, url }));
}
return html`<div style=${{ position: 'fixed', right: '14px', bottom: '74px', zIndex: 60, width: '320px', maxWidth: '92vw', background: 'var(--bg2,#15151a)', color: 'var(--tx,#eee)', border: '1px solid var(--border,#333)', borderRadius: '14px', boxShadow: '0 24px 60px -20px rgba(0,0,0,.7)', padding: '.85rem' }}>
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '.6rem' }}>
<strong style=${{ font: '700 .95rem/1 inherit' }}>Inviter</strong>
<strong style=${{ font: '700 .95rem/1 inherit' }}>${orbit.i18n.t('plugins.invite.panelTitle')}</strong>
<button onClick=${() => { s.open = false; notify(); }} style=${{ ...btn, width: 'auto', marginBottom: 0, padding: '.15rem .45rem' }}></button>
</div>
<button onClick=${() => go('room')} style=${btn}>
🔗 Inviter des amis dans <b>${chan}</b>
<span style=${sub}>Un lien à partager (WhatsApp, Discord). Aucun compte requis.</span>
🔗 ${orbit.i18n.t('plugins.invite.inRoom', { chan })}
<span style=${sub}>${orbit.i18n.t('plugins.invite.inRoomSub')}</span>
</button>
<button onClick=${() => go('duel')} style=${btn}>
Défier un ami aux échecs
<span style=${sub}>Il clique, il joue contre toi arbitré par GameServ, sans inscription.</span>
${orbit.i18n.t('plugins.invite.duel')}
<span style=${sub}>${orbit.i18n.t('plugins.invite.duelSub')}</span>
</button>
${done.how ? html`<div style=${{ marginTop: '.2rem' }}>
<div style=${{ fontSize: '.78rem', color: '#2ea043', marginBottom: '.3rem' }}>${done.how === 'shared' ? 'Lien partagé ✓' : done.how === 'copied' ? 'Lien copié ✓ — colle-le où tu veux' : 'Copie ce lien :'}</div>
<div style=${{ fontSize: '.78rem', color: '#2ea043', marginBottom: '.3rem' }}>${done.how === 'shared' ? orbit.i18n.t('plugins.invite.shared') : done.how === 'copied' ? orbit.i18n.t('plugins.invite.copied') : orbit.i18n.t('plugins.invite.copyThis')}</div>
<input readOnly value=${done.url} onClick=${(e) => e.target.select()} style=${{ width: '100%', boxSizing: 'border-box', padding: '.45rem .55rem', borderRadius: '8px', border: '1px solid var(--border,#444)', background: 'var(--bg,#0e0e12)', color: 'inherit', font: '.76rem/1.3 monospace' }} />
</div>` : null}
</div>`;
@ -66,7 +67,7 @@ Orbit.plugin('invite', (orbit, log) => {
function InviteButton() {
const s = useStore();
return html`<${orbit.Fragment}>
<button title="Inviter des amis" onClick=${() => { s.open = !s.open; notify(); }} style=${{ display: 'flex', alignItems: 'center', background: 'transparent', border: 0, color: 'inherit', cursor: 'pointer', padding: '0 .4rem', alignSelf: 'center' }}>
<button title=${orbit.i18n.t('plugins.invite.tip')} onClick=${() => { s.open = !s.open; notify(); }} style=${{ display: 'flex', alignItems: 'center', background: 'transparent', border: 0, color: 'inherit', cursor: 'pointer', padding: '0 .4rem', alignSelf: 'center' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style=${{ display: 'block' }}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />

View file

@ -725,5 +725,68 @@
"title": "Etwas ist schiefgelaufen",
"body": "Die App hatte einen unerwarteten Fehler. Ein Neuladen behebt das meist.",
"reload": "Neu laden"
},
"plugins": {
"copy": {
"title": "Nachricht kopieren",
"copied": "Kopiert!",
"copiedShort": "Kopiert"
},
"clock": {
"title": "Ortszeit"
},
"invite": {
"panelTitle": "Einladen",
"tip": "Freunde einladen",
"inRoom": "Lade Freunde zu {{chan}} ein",
"inRoomSub": "Ein Link zum Teilen (WhatsApp, Discord…). Kein Konto nötig.",
"duel": "Fordere einen Freund zum Schach heraus",
"duelSub": "Er klickt, er spielt gegen dich — von GameServ geleitet, ohne Anmeldung.",
"shared": "Link geteilt ✓",
"copied": "Link kopiert ✓ — füg ihn überall ein",
"copyThis": "Kopier diesen Link:",
"roomShare": "Komm zu mir auf {{chan}} · {{brand}}",
"duelShare": "Schach-Herausforderung auf {{brand}}"
},
"games": {
"title": "Spiele",
"name": {
"ttt": "Tic-Tac-Toe",
"c4": "Vier gewinnt",
"chess": "Schach"
},
"promotion": "Umwandlung:",
"toastTurn": "{{game}}: du bist dran gegen {{opp}}",
"titleTurn": "Du bist dran · {{game}} · {{opp}}",
"toastOffer": "{{opp}} fordert dich heraus · {{game}}",
"stOffer": "{{opp}} fordert dich heraus",
"stPending": "Warte auf {{opp}}…",
"stDraw": "Unentschieden.",
"stWin": "Du gewinnst!",
"stLoss": "Du verlierst.",
"stYourTurn": "Du bist dran",
"stTheirTurn": "{{opp}} ist dran",
"accept": "Annehmen",
"decline": "Ablehnen",
"newGame": "Neues Spiel",
"rematch": "Revanche",
"resign": "Aufgeben",
"leaderboard": "Rangliste",
"loading": "Lädt…",
"empty": "Noch niemand in der Rangliste. Gewinn ein Spiel!",
"challengeIntro": "Fordere jeden heraus, der online ist. GameServ leitet jeden Zug. Mit einem Konto ist die Partie gewertet; als Gast ist sie freundschaftlich.",
"oppPlaceholder": "Nick des Gegners",
"bestRank": "Beste Platzierung: {{game}}",
"challengeAction": "Zu einem Spiel herausfordern",
"rank": {
"Bronze": "Bronze",
"Silver": "Silber",
"Gold": "Gold",
"Master": "Meister"
}
}
},
"pwa": {
"description": "Der Chat, live. Öffentliche Räume, private Nachrichten, ohne Anmeldung."
}
}

View file

@ -725,5 +725,68 @@
"title": "Something went wrong",
"body": "The app hit an unexpected error. Reloading usually fixes it.",
"reload": "Reload"
},
"plugins": {
"copy": {
"title": "Copy message",
"copied": "Copied!",
"copiedShort": "Copied"
},
"clock": {
"title": "Local time"
},
"invite": {
"panelTitle": "Invite",
"tip": "Invite friends",
"inRoom": "Invite friends to {{chan}}",
"inRoomSub": "A link to share (WhatsApp, Discord…). No account needed.",
"duel": "Challenge a friend to chess",
"duelSub": "They click, they play you — refereed by GameServ, no sign-up.",
"shared": "Link shared ✓",
"copied": "Link copied ✓ — paste it anywhere",
"copyThis": "Copy this link:",
"roomShare": "Join me on {{chan}} · {{brand}}",
"duelShare": "Chess challenge on {{brand}}"
},
"games": {
"title": "Games",
"name": {
"ttt": "Tic-tac-toe",
"c4": "Connect 4",
"chess": "Chess"
},
"promotion": "Promotion:",
"toastTurn": "{{game}}: your turn vs {{opp}}",
"titleTurn": "Your turn · {{game}} · {{opp}}",
"toastOffer": "{{opp}} challenges you · {{game}}",
"stOffer": "{{opp}} challenges you",
"stPending": "Waiting for {{opp}}…",
"stDraw": "Draw.",
"stWin": "You win!",
"stLoss": "You lose.",
"stYourTurn": "Your turn",
"stTheirTurn": "{{opp}}'s turn",
"accept": "Accept",
"decline": "Decline",
"newGame": "New game",
"rematch": "Rematch",
"resign": "Resign",
"leaderboard": "Leaderboard",
"loading": "Loading…",
"empty": "Nobody ranked yet. Win a game!",
"challengeIntro": "Challenge anyone online. GameServ referees every move. With an account the game is ranked; as a guest it's friendly.",
"oppPlaceholder": "Opponent's nick",
"bestRank": "Best ranking: {{game}}",
"challengeAction": "Challenge to a game",
"rank": {
"Bronze": "Bronze",
"Silver": "Silver",
"Gold": "Gold",
"Master": "Master"
}
}
},
"pwa": {
"description": "The chat, live. Public rooms, private messages, no sign-up."
}
}

View file

@ -725,5 +725,68 @@
"title": "Algo salió mal",
"body": "La aplicación tuvo un error inesperado. Recargar suele solucionarlo.",
"reload": "Recargar"
},
"plugins": {
"copy": {
"title": "Copiar mensaje",
"copied": "¡Copiado!",
"copiedShort": "Copiado"
},
"clock": {
"title": "Hora local"
},
"invite": {
"panelTitle": "Invitar",
"tip": "Invita a tus amigos",
"inRoom": "Invita a tus amigos a {{chan}}",
"inRoomSub": "Un enlace para compartir (WhatsApp, Discord…). No hace falta cuenta.",
"duel": "Reta a un amigo al ajedrez",
"duelSub": "Hace clic, juega contra ti — arbitrado por GameServ, sin registro.",
"shared": "Enlace compartido ✓",
"copied": "Enlace copiado ✓ — pégalo donde quieras",
"copyThis": "Copia este enlace:",
"roomShare": "Únete a mí en {{chan}} · {{brand}}",
"duelShare": "Reto de ajedrez en {{brand}}"
},
"games": {
"title": "Juegos",
"name": {
"ttt": "Tres en raya",
"c4": "Conecta 4",
"chess": "Ajedrez"
},
"promotion": "Promoción:",
"toastTurn": "{{game}}: te toca contra {{opp}}",
"titleTurn": "Te toca · {{game}} · {{opp}}",
"toastOffer": "{{opp}} te reta · {{game}}",
"stOffer": "{{opp}} te reta",
"stPending": "Esperando a {{opp}}…",
"stDraw": "Empate.",
"stWin": "¡Ganaste!",
"stLoss": "Perdiste.",
"stYourTurn": "Te toca",
"stTheirTurn": "Turno de {{opp}}",
"accept": "Aceptar",
"decline": "Rechazar",
"newGame": "Nueva partida",
"rematch": "Revancha",
"resign": "Abandonar",
"leaderboard": "Clasificación",
"loading": "Cargando…",
"empty": "Nadie en la clasificación. ¡Gana una partida!",
"challengeIntro": "Reta a cualquiera que esté conectado. GameServ arbitra cada jugada. Con una cuenta la partida es clasificatoria; como invitado, es amistosa.",
"oppPlaceholder": "Apodo del rival",
"bestRank": "Mejor clasificación: {{game}}",
"challengeAction": "Retar a una partida",
"rank": {
"Bronze": "Bronce",
"Silver": "Plata",
"Gold": "Oro",
"Master": "Maestro"
}
}
},
"pwa": {
"description": "El chat, en directo. Salas públicas, mensajes privados, sin registro."
}
}

View file

@ -725,5 +725,68 @@
"title": "Une erreur est survenue",
"body": "L'application a rencontré une erreur inattendue. Recharger règle généralement le problème.",
"reload": "Recharger"
},
"plugins": {
"copy": {
"title": "Copier le message",
"copied": "Copié !",
"copiedShort": "Copié"
},
"clock": {
"title": "Heure locale"
},
"invite": {
"panelTitle": "Inviter",
"tip": "Inviter des amis",
"inRoom": "Inviter des amis dans {{chan}}",
"inRoomSub": "Un lien à partager (WhatsApp, Discord…). Aucun compte requis.",
"duel": "Défier un ami aux échecs",
"duelSub": "Il clique, il joue contre toi — arbitré par GameServ, sans inscription.",
"shared": "Lien partagé ✓",
"copied": "Lien copié ✓ — colle-le où tu veux",
"copyThis": "Copie ce lien :",
"roomShare": "Rejoins-moi sur {{chan}} · {{brand}}",
"duelShare": "Défi aux échecs sur {{brand}}"
},
"games": {
"title": "Jeux",
"name": {
"ttt": "Morpion",
"c4": "Puissance 4",
"chess": "Échecs"
},
"promotion": "Promotion :",
"toastTurn": "{{game}} : à toi contre {{opp}}",
"titleTurn": "À toi · {{game}} · {{opp}}",
"toastOffer": "{{opp}} te défie · {{game}}",
"stOffer": "{{opp}} te défie",
"stPending": "En attente de {{opp}}…",
"stDraw": "Match nul.",
"stWin": "Tu gagnes !",
"stLoss": "Tu perds.",
"stYourTurn": "À toi",
"stTheirTurn": "Au tour de {{opp}}",
"accept": "Accepter",
"decline": "Refuser",
"newGame": "Nouvelle partie",
"rematch": "Revanche",
"resign": "Abandonner",
"leaderboard": "Classement",
"loading": "Chargement…",
"empty": "Personne au classement. Gagne une partie !",
"challengeIntro": "Défie n'importe qui de connecté. GameServ arbitre chaque coup. Avec un compte la partie est classée ; en invité, elle est amicale.",
"oppPlaceholder": "Pseudo de l'adversaire",
"bestRank": "Meilleur classement : {{game}}",
"challengeAction": "Défier à un jeu",
"rank": {
"Bronze": "Bronze",
"Silver": "Argent",
"Gold": "Or",
"Master": "Maître"
}
}
},
"pwa": {
"description": "Le tchat, en direct. Salons publics, messages privés, sans inscription."
}
}

View file

@ -725,5 +725,68 @@
"title": "Qualcosa è andato storto",
"body": "L'app ha riscontrato un errore imprevisto. Ricaricare di solito risolve.",
"reload": "Ricarica"
},
"plugins": {
"copy": {
"title": "Copia il messaggio",
"copied": "Copiato!",
"copiedShort": "Copiato"
},
"clock": {
"title": "Ora locale"
},
"invite": {
"panelTitle": "Invita",
"tip": "Invita amici",
"inRoom": "Invita amici in {{chan}}",
"inRoomSub": "Un link da condividere (WhatsApp, Discord…). Nessun account richiesto.",
"duel": "Sfida un amico a scacchi",
"duelSub": "Clicca, gioca contro di te — arbitrato da GameServ, senza registrazione.",
"shared": "Link condiviso ✓",
"copied": "Link copiato ✓ — incollalo dove vuoi",
"copyThis": "Copia questo link:",
"roomShare": "Raggiungimi su {{chan}} · {{brand}}",
"duelShare": "Sfida a scacchi su {{brand}}"
},
"games": {
"title": "Giochi",
"name": {
"ttt": "Tris",
"c4": "Forza 4",
"chess": "Scacchi"
},
"promotion": "Promozione:",
"toastTurn": "{{game}}: tocca a te contro {{opp}}",
"titleTurn": "Tocca a te · {{game}} · {{opp}}",
"toastOffer": "{{opp}} ti sfida · {{game}}",
"stOffer": "{{opp}} ti sfida",
"stPending": "In attesa di {{opp}}…",
"stDraw": "Patta.",
"stWin": "Hai vinto!",
"stLoss": "Hai perso.",
"stYourTurn": "Tocca a te",
"stTheirTurn": "Tocca a {{opp}}",
"accept": "Accetta",
"decline": "Rifiuta",
"newGame": "Nuova partita",
"rematch": "Rivincita",
"resign": "Abbandona",
"leaderboard": "Classifica",
"loading": "Caricamento…",
"empty": "Nessuno in classifica. Vinci una partita!",
"challengeIntro": "Sfida chiunque sia online. GameServ arbitra ogni mossa. Con un account la partita è classificata; da ospite è amichevole.",
"oppPlaceholder": "Nick dell'avversario",
"bestRank": "Miglior classifica: {{game}}",
"challengeAction": "Sfida a un gioco",
"rank": {
"Bronze": "Bronzo",
"Silver": "Argento",
"Gold": "Oro",
"Master": "Maestro"
}
}
},
"pwa": {
"description": "La chat, in diretta. Stanze pubbliche, messaggi privati, senza registrazione."
}
}

View file

@ -725,5 +725,68 @@
"title": "केही गडबड भयो",
"body": "एपमा अप्रत्याशित त्रुटि आयो। पुनः लोड गर्दा प्रायः ठीक हुन्छ।",
"reload": "पुनः लोड गर्नुहोस्"
},
"plugins": {
"copy": {
"title": "सन्देश कपी गर्नुहोस्",
"copied": "कपी भयो!",
"copiedShort": "कपी भयो"
},
"clock": {
"title": "स्थानीय समय"
},
"invite": {
"panelTitle": "निम्तो",
"tip": "साथीहरूलाई बोलाउनुहोस्",
"inRoom": "{{chan}} मा साथीहरूलाई बोलाउनुहोस्",
"inRoomSub": "सेयर गर्ने लिङ्क (WhatsApp, Discord…)। खाता चाहिँदैन।",
"duel": "साथीलाई चेसमा चुनौती दिनुहोस्",
"duelSub": "उसले क्लिक गर्छ, तपाईंसँग खेल्छ — GameServ ले रेफ्री गर्छ, दर्ता चाहिँदैन।",
"shared": "लिङ्क सेयर भयो ✓",
"copied": "लिङ्क कपी भयो ✓ — जहाँ पनि टाँस्नुहोस्",
"copyThis": "यो लिङ्क कपी गर्नुहोस्:",
"roomShare": "{{chan}} मा मलाई भेट्नुहोस् · {{brand}}",
"duelShare": "{{brand}} मा चेस चुनौती"
},
"games": {
"title": "खेलहरू",
"name": {
"ttt": "टिक-ट्याक-टो",
"c4": "कनेक्ट ४",
"chess": "चेस"
},
"promotion": "प्रमोशन:",
"toastTurn": "{{game}}: {{opp}} विरुद्ध तपाईंको पालो",
"titleTurn": "तपाईंको पालो · {{game}} · {{opp}}",
"toastOffer": "{{opp}} ले तपाईंलाई चुनौती दियो · {{game}}",
"stOffer": "{{opp}} ले तपाईंलाई चुनौती दियो",
"stPending": "{{opp}} को प्रतीक्षा गर्दै…",
"stDraw": "बराबरी।",
"stWin": "तपाईं जित्नुभयो!",
"stLoss": "तपाईं हार्नुभयो।",
"stYourTurn": "तपाईंको पालो",
"stTheirTurn": "{{opp}} को पालो",
"accept": "स्वीकार्नुहोस्",
"decline": "अस्वीकार गर्नुहोस्",
"newGame": "नयाँ खेल",
"rematch": "फेरि खेल्नुहोस्",
"resign": "हार मान्नुहोस्",
"leaderboard": "लिडरबोर्ड",
"loading": "लोड हुँदै…",
"empty": "अझै कोही पनि श्रेणीमा छैन। एउटा खेल जित्नुहोस्!",
"challengeIntro": "अनलाइन जोसुकैलाई चुनौती दिनुहोस्। GameServ ले हरेक चाल रेफ्री गर्छ। खातासँग खेल श्रेणीबद्ध हुन्छ; अतिथिको रूपमा यो मैत्रीपूर्ण हुन्छ।",
"oppPlaceholder": "प्रतिद्वन्द्वीको निक",
"bestRank": "उत्कृष्ट श्रेणी: {{game}}",
"challengeAction": "खेलमा चुनौती दिनुहोस्",
"rank": {
"Bronze": "कांस्य",
"Silver": "रजत",
"Gold": "स्वर्ण",
"Master": "मास्टर"
}
}
},
"pwa": {
"description": "च्याट, प्रत्यक्ष। सार्वजनिक कोठा, निजी सन्देश, दर्ता बिना।"
}
}

View file

@ -725,5 +725,68 @@
"title": "Algo deu errado",
"body": "O app encontrou um erro inesperado. Recarregar costuma resolver.",
"reload": "Recarregar"
},
"plugins": {
"copy": {
"title": "Copiar mensagem",
"copied": "Copiado!",
"copiedShort": "Copiado"
},
"clock": {
"title": "Hora local"
},
"invite": {
"panelTitle": "Convidar",
"tip": "Convidar amigos",
"inRoom": "Convide amigos para {{chan}}",
"inRoomSub": "Um link para compartilhar (WhatsApp, Discord…). Sem precisar de conta.",
"duel": "Desafie um amigo para o xadrez",
"duelSub": "Ele clica, joga contra você — arbitrado pelo GameServ, sem cadastro.",
"shared": "Link compartilhado ✓",
"copied": "Link copiado ✓ — cole onde quiser",
"copyThis": "Copie este link:",
"roomShare": "Venha comigo em {{chan}} · {{brand}}",
"duelShare": "Desafio de xadrez no {{brand}}"
},
"games": {
"title": "Jogos",
"name": {
"ttt": "Jogo da velha",
"c4": "Lig 4",
"chess": "Xadrez"
},
"promotion": "Promoção:",
"toastTurn": "{{game}}: sua vez contra {{opp}}",
"titleTurn": "Sua vez · {{game}} · {{opp}}",
"toastOffer": "{{opp}} desafia você · {{game}}",
"stOffer": "{{opp}} desafia você",
"stPending": "Aguardando {{opp}}…",
"stDraw": "Empate.",
"stWin": "Você venceu!",
"stLoss": "Você perdeu.",
"stYourTurn": "Sua vez",
"stTheirTurn": "Vez de {{opp}}",
"accept": "Aceitar",
"decline": "Recusar",
"newGame": "Nova partida",
"rematch": "Revanche",
"resign": "Desistir",
"leaderboard": "Ranking",
"loading": "Carregando…",
"empty": "Ninguém no ranking ainda. Vença uma partida!",
"challengeIntro": "Desafie qualquer pessoa online. O GameServ arbitra cada jogada. Com uma conta a partida vale ranking; como convidado é só por diversão.",
"oppPlaceholder": "Nick do adversário",
"bestRank": "Melhor colocação: {{game}}",
"challengeAction": "Desafiar para um jogo",
"rank": {
"Bronze": "Bronze",
"Silver": "Prata",
"Gold": "Ouro",
"Master": "Mestre"
}
}
},
"pwa": {
"description": "O chat, ao vivo. Salas públicas, mensagens privadas, sem cadastro."
}
}

View file

@ -725,5 +725,68 @@
"title": "Algo correu mal",
"body": "A aplicação encontrou um erro inesperado. Recarregar costuma resolver.",
"reload": "Recarregar"
},
"plugins": {
"copy": {
"title": "Copiar mensagem",
"copied": "Copiado!",
"copiedShort": "Copiado"
},
"clock": {
"title": "Hora local"
},
"invite": {
"panelTitle": "Convidar",
"tip": "Convida amigos",
"inRoom": "Convida amigos para {{chan}}",
"inRoomSub": "Um link para partilhar (WhatsApp, Discord…). Não precisas de conta.",
"duel": "Desafia um amigo para o xadrez",
"duelSub": "Ele clica, joga contra ti — arbitrado pelo GameServ, sem registo.",
"shared": "Link partilhado ✓",
"copied": "Link copiado ✓ — cola-o onde quiseres",
"copyThis": "Copia este link:",
"roomShare": "Junta-te a mim em {{chan}} · {{brand}}",
"duelShare": "Desafio de xadrez no {{brand}}"
},
"games": {
"title": "Jogos",
"name": {
"ttt": "Jogo do galo",
"c4": "4 em linha",
"chess": "Xadrez"
},
"promotion": "Promoção:",
"toastTurn": "{{game}}: é a tua vez contra {{opp}}",
"titleTurn": "É a tua vez · {{game}} · {{opp}}",
"toastOffer": "{{opp}} desafia-te · {{game}}",
"stOffer": "{{opp}} desafia-te",
"stPending": "À espera de {{opp}}…",
"stDraw": "Empate.",
"stWin": "Ganhaste!",
"stLoss": "Perdeste.",
"stYourTurn": "É a tua vez",
"stTheirTurn": "Vez de {{opp}}",
"accept": "Aceitar",
"decline": "Recusar",
"newGame": "Novo jogo",
"rematch": "Desforra",
"resign": "Desistir",
"leaderboard": "Classificação",
"loading": "A carregar…",
"empty": "Ainda ninguém na classificação. Ganha um jogo!",
"challengeIntro": "Desafia qualquer pessoa online. O GameServ arbitra cada jogada. Com uma conta, a partida é classificada; como convidado, é amigável.",
"oppPlaceholder": "Alcunha do adversário",
"bestRank": "Melhor classificação: {{game}}",
"challengeAction": "Desafiar para um jogo",
"rank": {
"Bronze": "Bronze",
"Silver": "Prata",
"Gold": "Ouro",
"Master": "Mestre"
}
}
},
"pwa": {
"description": "O chat, em direto. Salas públicas, mensagens privadas, sem registo."
}
}

View file

@ -725,5 +725,68 @@
"title": "Что-то пошло не так",
"body": "В приложении произошла непредвиденная ошибка. Обычно помогает перезагрузка.",
"reload": "Перезагрузить"
},
"plugins": {
"copy": {
"title": "Скопировать сообщение",
"copied": "Скопировано!",
"copiedShort": "Скопировано"
},
"clock": {
"title": "Местное время"
},
"invite": {
"panelTitle": "Пригласить",
"tip": "Позвать друзей",
"inRoom": "Позови друзей в {{chan}}",
"inRoomSub": "Ссылка, которой можно поделиться (WhatsApp, Discord…). Аккаунт не нужен.",
"duel": "Вызови друга на партию в шахматы",
"duelSub": "Он кликает и играет с тобой — судит GameServ, без регистрации.",
"shared": "Ссылка отправлена ✓",
"copied": "Ссылка скопирована ✓ — вставь куда угодно",
"copyThis": "Скопируй эту ссылку:",
"roomShare": "Заходи ко мне в {{chan}} · {{brand}}",
"duelShare": "Вызов на шахматы в {{brand}}"
},
"games": {
"title": "Игры",
"name": {
"ttt": "Крестики-нолики",
"c4": "Четыре в ряд",
"chess": "Шахматы"
},
"promotion": "Превращение:",
"toastTurn": "{{game}}: твой ход против {{opp}}",
"titleTurn": "Твой ход · {{game}} · {{opp}}",
"toastOffer": "{{opp}} вызывает тебя · {{game}}",
"stOffer": "{{opp}} вызывает тебя",
"stPending": "Ждём {{opp}}…",
"stDraw": "Ничья.",
"stWin": "Ты выиграл!",
"stLoss": "Ты проиграл.",
"stYourTurn": "Твой ход",
"stTheirTurn": "Ход {{opp}}",
"accept": "Принять",
"decline": "Отклонить",
"newGame": "Новая партия",
"rematch": "Реванш",
"resign": "Сдаться",
"leaderboard": "Таблица лидеров",
"loading": "Загрузка…",
"empty": "В рейтинге пока никого. Выиграй партию!",
"challengeIntro": "Вызови любого, кто онлайн. GameServ судит каждый ход. С аккаунтом партия рейтинговая; как гость — играешь просто так.",
"oppPlaceholder": "Ник соперника",
"bestRank": "Лучший рейтинг: {{game}}",
"challengeAction": "Вызвать на игру",
"rank": {
"Bronze": "Бронза",
"Silver": "Серебро",
"Gold": "Золото",
"Master": "Мастер"
}
}
},
"pwa": {
"description": "Чат в прямом эфире. Публичные комнаты, личные сообщения, без регистрации."
}
}

View file

@ -725,5 +725,68 @@
"title": "Bir şeyler ters gitti",
"body": "Uygulama beklenmedik bir hatayla karşılaştı. Yeniden yüklemek genellikle düzeltir.",
"reload": "Yeniden yükle"
},
"plugins": {
"copy": {
"title": "Mesajı kopyala",
"copied": "Kopyalandı!",
"copiedShort": "Kopyalandı"
},
"clock": {
"title": "Yerel saat"
},
"invite": {
"panelTitle": "Davet et",
"tip": "Arkadaşlarını davet et",
"inRoom": "{{chan}} kanalına arkadaşlarını davet et",
"inRoomSub": "Paylaşılacak bir bağlantı (WhatsApp, Discord…). Hesap gerekmez.",
"duel": "Bir arkadaşını satrança davet et",
"duelSub": "Tıklar, sana karşı oynar — GameServ yönetir, kayıt yok.",
"shared": "Bağlantı paylaşıldı ✓",
"copied": "Bağlantı kopyalandı ✓ — istediğin yere yapıştır",
"copyThis": "Bu bağlantıyı kopyala:",
"roomShare": "{{chan}} kanalında bana katıl · {{brand}}",
"duelShare": "{{brand}} üzerinde satranç meydan okuması"
},
"games": {
"title": "Oyunlar",
"name": {
"ttt": "XOX",
"c4": "Dört Yap",
"chess": "Satranç"
},
"promotion": "Terfi:",
"toastTurn": "{{game}}: {{opp}} karşısında sıra sende",
"titleTurn": "Sıra sende · {{game}} · {{opp}}",
"toastOffer": "{{opp}} sana meydan okuyor · {{game}}",
"stOffer": "{{opp}} sana meydan okuyor",
"stPending": "{{opp}} bekleniyor…",
"stDraw": "Berabere.",
"stWin": "Kazandın!",
"stLoss": "Kaybettin.",
"stYourTurn": "Sıra sende",
"stTheirTurn": "Sıra {{opp}}'de",
"accept": "Kabul et",
"decline": "Reddet",
"newGame": "Yeni oyun",
"rematch": "Rövanş",
"resign": "Pes et",
"leaderboard": "Sıralama",
"loading": "Yükleniyor…",
"empty": "Henüz sıralamada kimse yok. Bir oyun kazan!",
"challengeIntro": "Çevrimiçi olan herkese meydan oku. GameServ her hamleyi yönetir. Hesapla oynanan oyun sıralamaya girer; misafir olarak dostçadır.",
"oppPlaceholder": "Rakibin takma adı",
"bestRank": "En iyi sıralama: {{game}}",
"challengeAction": "Bir oyuna davet et",
"rank": {
"Bronze": "Bronz",
"Silver": "Gümüş",
"Gold": "Altın",
"Master": "Usta"
}
}
},
"pwa": {
"description": "Sohbet, canlı. Herkese açık odalar, özel mesajlar, kayıt yok."
}
}

View file

@ -1,8 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { applyTheme, getTheme } from './ui/theme.ts'
import './i18n'
import { applyConfigDefaultLang } from './i18n'
import i18n, { applyConfigDefaultLang } from './i18n'
import './index.css'
import { initViewport } from './ui/viewport.ts'
import { loadConfig, getConfig } from './config.ts'
@ -11,12 +10,33 @@ import { AppErrorBoundary } from './components/AppErrorBoundary'
// Track the visual viewport so the layout shrinks above the on-screen keyboard.
initViewport()
// PWA install prompt: the static manifest.webmanifest is one language; re-serve
// it (as a blob) with the brand name + a description in the visitor's language.
// Icons/colors are kept from the file; root-relative icon paths still resolve.
async function localizeManifest() {
try {
const res = await fetch(`${import.meta.env.BASE_URL}manifest.webmanifest`, { cache: 'force-cache' })
if (!res.ok) return
const m = await res.json()
const brand = getConfig().branding?.name || m.short_name || 'Orbit'
m.name = brand
m.short_name = brand
m.description = i18n.t('pwa.description', { defaultValue: m.description }) as string
m.lang = i18n.language
const url = URL.createObjectURL(new Blob([JSON.stringify(m)], { type: 'application/manifest+json' }))
let link = document.querySelector('link[rel="manifest"]') as HTMLLinkElement | null
if (!link) { link = document.createElement('link'); link.rel = 'manifest'; document.head.appendChild(link) }
link.setAttribute('href', url)
} catch { /* keep the static manifest */ }
}
// Load runtime config.json FIRST, then import App (so the store initialises with
// the resolved config). Keeps the client fully re-pointable/re-brandable without
// a rebuild.
loadConfig().then(async () => {
applyTheme(getTheme()) // re-apply now that config (default theme) is loaded
applyConfigDefaultLang(getConfig().defaults.lang) // honour a config-pinned default language
void localizeManifest() // re-serve the PWA manifest with a localized name/description
const { default: App } = await import('./App.tsx') // also creates the store
// Plugin subsystem: publish window.Orbit, bridge app/IRC events onto the bus,
// then load operator-listed plugins from config. After the store exists,

View file

@ -10,6 +10,7 @@ import * as ReactJSXRuntime from 'react/jsx-runtime';
import * as ReactDOM from 'react-dom';
import htm from 'htm';
import { useChat } from '../store';
import i18n from '../i18n';
import { getTheme, setTheme, type Theme } from '../ui/theme';
import { getConfig, pluginDebug } from '../config';
import { bus } from './bus';
@ -20,7 +21,7 @@ const THEMES: Theme[] = ['light', 'dark', 'orbit', 'orbit-dark', 'yomirc', 'yomi
// Plugin API contract version. Bumped on a breaking change to the surface below
// so plugins can guard (e.g. `if (Orbit.apiVersion < 2) …`). Still experimental.
const API_VERSION = 3;
const API_VERSION = 4;
const registered = new Map<string, OrbitPluginApi>();
@ -65,6 +66,19 @@ export interface OrbitPluginApi {
part: (channel: string) => void;
list: () => void;
};
/** The resolved runtime config (branding, features, server, …). */
config: () => ReturnType<typeof getConfig>;
// ── i18n ─────────────────────────────────────────────────────────────────
i18n: {
/** Current UI language code, e.g. 'es' or 'pt-BR'. */
language: () => string;
/** Translate an app locale key with optional {{interpolation}} bundled
* plugins ship their strings as `plugins.*` keys in the app locales. */
t: (key: string, opts?: Record<string, unknown>) => string;
/** Pick from a `{ lang: text }` table by the current language (for
* self-contained third-party plugins that carry their own strings). */
pick: (table: Record<string, string>) => string;
};
// ── theming ──────────────────────────────────────────────────────────────
themes: { current: () => Theme; list: () => Theme[]; set: (t: Theme) => void };
// ── namespaced persistence ───────────────────────────────────────────────
@ -109,6 +123,15 @@ function makeApi(name: string): OrbitPluginApi {
part: (channel) => useChat.getState().client?.part(channel),
list: () => useChat.getState().client?.list(),
},
config: () => getConfig(),
i18n: {
language: () => i18n.language,
t: (key, opts) => i18n.t(key, opts) as string,
pick: (table) => {
const l = i18n.language || 'en';
return table[l] ?? table[l.split('-')[0]] ?? table.en ?? Object.values(table)[0] ?? '';
},
},
themes: { current: getTheme, list: () => THEMES.slice(), set: setTheme },
storage: {
get: <T,>(key: string, fallback?: T) => {