Files
scrabble-game/ui/src/game/CheckScreen.svelte
T
Ilia Denisov bd0482c376
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s
feat(ui): route all in-app external links through Telegram openLink
Extend the openLink routing from the dictionary lookup to every external
link shown inside the Mini App, so none triggers the WebView's 'open this
link?' confirmation. A shared onExternalLinkClick handler resolves the anchor
via closest() (so it also works delegated on {@html} content), backed by a
pure routeExternalLinkInTelegram decision: only inside Telegram, only an
external http(s) target=_blank link, excluding same-origin/in-app and t.me
links (t.me keeps openTelegramLink). Applied to the word-check lookup, the
About rules link, the Feedback operator-reply links, and the feature-gated
announcement banner.

Outside Telegram every anchor keeps its native target=_blank.
2026-06-15 18:29:53 +02:00

152 lines
4.0 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { gateway } from '../lib/gateway';
import { handleError, showToast } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import { alphabetLetters } from '../lib/alphabet';
import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword';
import { onExternalLinkClick } from '../lib/telegram';
import type { Variant } from '../lib/model';
// Word-check on its own screen: unlimited dictionary lookups, each with a
// complaint, off the board so the soft keyboard never relayouts the play area.
let { id }: { id: string } = $props();
let variant = $state<Variant>('scrabble_en');
let word = $state('');
let result = $state<{ word: string; legal: boolean } | null>(null);
let cooling = $state(false);
const checked = new Map<string, boolean>();
onMount(async () => {
try {
// Include the alphabet so input sanitising + the check accept the variant's letters.
const st = await gateway.gameState(id, true);
variant = st.game.variant;
} catch (e) {
handleError(e);
}
});
function onInput(e: Event) {
word = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant));
}
// Disabled while cooling, for an already-checked word, or an out-of-range length.
function canCheck(): boolean {
return canCheckWord(word, checked.has(word.trim().toUpperCase()), cooling);
}
async function runCheck() {
if (!canCheck()) return;
const w = word.trim().toUpperCase();
cooling = true;
setTimeout(() => (cooling = false), 5000);
try {
const r = await gateway.checkWord(id, w, variant);
checked.set(w, r.legal);
result = { word: w, legal: r.legal };
} catch (e) {
handleError(e);
}
}
async function complain() {
if (!result) return;
try {
await gateway.complaint(id, result.word, '');
showToast(t('game.complaintSent'));
result = null;
} catch (e) {
handleError(e);
}
}
</script>
<div class="wrap">
<div class="check">
<input
value={word}
oninput={onInput}
onkeydown={(e) => e.key === 'Enter' && runCheck()}
placeholder={t('game.checkWordPrompt')}
/>
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
</div>
{#if result}
<p class="verdict" class:ok={result.legal} class:bad={!result.legal}>
{result.legal
? t('game.wordLegal', { word: result.word })
: t('game.wordIllegal', { word: result.word })}
</p>
<div class="actions">
<button class="complain" onclick={complain}>{t('game.complain')}</button>
{#if result.legal}
<a
class="lookup"
href={dictionaryLookupUrl(result.word, variant)}
target="_blank"
rel="noopener noreferrer"
onclick={onExternalLinkClick}>{t('game.lookup')}</a>
{/if}
</div>
{/if}
</div>
<style>
.wrap {
padding: 16px var(--pad);
display: flex;
flex-direction: column;
gap: 14px;
}
.check {
display: flex;
gap: 8px;
}
.check input {
flex: 1;
min-width: 0;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
text-transform: uppercase;
}
.check button {
padding: 10px 16px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
.check button:disabled {
opacity: 0.5;
}
.verdict {
margin: 0;
font-weight: 600;
}
.verdict.ok {
color: var(--ok, #2e7d32);
}
.verdict.bad {
color: var(--danger, #c0392b);
}
.actions {
display: flex;
align-items: center;
gap: 12px;
}
.complain {
padding: 8px 14px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.lookup {
color: var(--accent);
text-decoration: underline;
font-size: 0.95em;
}
</style>