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:
parent
f816abfcc5
commit
a7686cb8f1
16 changed files with 717 additions and 42 deletions
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "च्याट, प्रत्यक्ष। सार्वजनिक कोठा, निजी सन्देश, दर्ता बिना।"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Чат в прямом эфире. Публичные комнаты, личные сообщения, без регистрации."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
src/main.tsx
24
src/main.tsx
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue