Stage 17 round 6 (#16-20): landing page, /app/ move, cache + stream fixes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 31s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 55s
Close out Stage 17 round 6: - Landing page at / — one Vite build with two entries (index.html = game SPA, landing.html = a lightweight landing reusing the theme/i18n/ aboutContent leaf modules, not the app store). - Move the web game SPA to /app/; the Telegram Mini App stays at /telegram/ (gateway webui.Handler(stripPrefix, indexName): landing at /, SPA at /app/ + /telegram/). Per-language "Play in Telegram" link via new VITE_TELEGRAM_LINK_EN/_RU build vars (button hides when unset). - Cache headers: hash-named /assets/* immutable, HTML shells no-cache (the go:embed zero modtime emitted no validators, so the client re-downloaded the whole bundle every launch). - Live-stream 15s abort fix: an immediate heartbeat on open + a 10s default interval (the first tick at 15s raced the edge idle timeout -> reconnect storm). PLAN/ARCHITECTURE(§13)/FUNCTIONAL(+ru)/gateway+ui+deploy READMEs updated; round 6 closed. Tests: gateway webui/connectsrv units, ui landing unit + e2e, full e2e (60) green.
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { applyReduceMotion, applyTheme, type ThemePref } from './lib/theme';
|
||||
import { i18n, localeFrom, setLocale, t, type Locale, type MessageKey } from './lib/i18n/index.svelte';
|
||||
import { loadPrefs, savePrefs, type Prefs } from './lib/session';
|
||||
import { aboutContent } from './lib/aboutContent';
|
||||
import { telegramBotLink } from './lib/landing';
|
||||
|
||||
// Standalone landing page (Stage 17): the public entry at "/", separate from the game SPA
|
||||
// (served at /app/ and /telegram/). It reuses the app's theme/i18n/prefs leaf modules — but
|
||||
// not the app store — so it stays light (no gateway, auth or live stream).
|
||||
|
||||
const themes: ThemePref[] = ['auto', 'light', 'dark'];
|
||||
const themeLabel: Record<ThemePref, MessageKey> = {
|
||||
auto: 'settings.themeAuto',
|
||||
light: 'settings.themeLight',
|
||||
dark: 'settings.themeDark',
|
||||
};
|
||||
const locales: Locale[] = ['en', 'ru'];
|
||||
|
||||
let theme = $state<ThemePref>('auto');
|
||||
let prefs: Partial<Prefs> = {};
|
||||
|
||||
// The away/move clock the random-game copy mentions (backend game.DefaultTurnTimeout = 24h).
|
||||
const about = $derived(aboutContent(i18n.locale, 24));
|
||||
const tgLink = $derived(telegramBotLink(i18n.locale));
|
||||
|
||||
onMount(async () => {
|
||||
prefs = await loadPrefs();
|
||||
theme = prefs.theme ?? 'auto';
|
||||
applyTheme(theme);
|
||||
applyReduceMotion(prefs.reduceMotion ?? false);
|
||||
setLocale(prefs.locale ?? localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'));
|
||||
});
|
||||
|
||||
function persist(): void {
|
||||
// savePrefs takes the full set, so keep the labels/lines the app may have stored.
|
||||
void savePrefs({
|
||||
theme,
|
||||
locale: i18n.locale,
|
||||
reduceMotion: prefs.reduceMotion ?? false,
|
||||
boardLabels: prefs.boardLabels ?? 'beginner',
|
||||
boardLines: prefs.boardLines ?? false,
|
||||
});
|
||||
}
|
||||
function chooseTheme(th: ThemePref): void {
|
||||
theme = th;
|
||||
applyTheme(th);
|
||||
persist();
|
||||
}
|
||||
function chooseLocale(lc: Locale): void {
|
||||
setLocale(lc);
|
||||
persist();
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="landing">
|
||||
<header class="bar">
|
||||
<div class="seg">
|
||||
{#each locales as lc (lc)}
|
||||
<button class="opt" class:active={i18n.locale === lc} onclick={() => chooseLocale(lc)}>
|
||||
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="seg">
|
||||
{#each themes as th (th)}
|
||||
<button class="opt" class:active={theme === th} onclick={() => chooseTheme(th)}>
|
||||
{t(themeLabel[th])}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<h1>{about.title}</h1>
|
||||
<p class="tagline">{t('landing.tagline')}</p>
|
||||
<div class="cta">
|
||||
<a class="play primary" href="/app/">{t('landing.playWeb')}</a>
|
||||
{#if tgLink}
|
||||
<a class="play tg" href={tgLink} target="_blank" rel="noopener noreferrer">
|
||||
<img src="telegram-logo.svg" alt="" width="22" height="22" />
|
||||
{t('landing.playTelegram')}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="info">
|
||||
<p class="rules">
|
||||
<a href={about.rulesUrl} target="_blank" rel="noopener noreferrer">{about.rulesPrefix}{about.rulesLink}</a>
|
||||
</p>
|
||||
<div class="cols">
|
||||
<div class="col">
|
||||
<h2>{about.randomTitle}</h2>
|
||||
<p class="respect">❗️ {about.randomRespect}</p>
|
||||
<ul>
|
||||
{#each about.random as r (r)}<li>{r}</li>{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h2>{about.friendsTitle}</h2>
|
||||
<ul>
|
||||
{#each about.friends as f (f)}<li>{f}</li>{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="ft">{t('about.version', { v: __APP_VERSION__ })}</footer>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.landing {
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 16px var(--pad) 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
color: var(--text);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.seg {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.opt {
|
||||
padding: 7px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
user-select: none;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.opt.active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.hero {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 24px 0 8px;
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 2.4rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.tagline {
|
||||
margin: 0 auto;
|
||||
max-width: 30ch;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.cta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.play {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 12px 22px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.play.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.play.tg {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.play.tg img {
|
||||
display: block;
|
||||
}
|
||||
.info {
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 18px var(--pad);
|
||||
}
|
||||
.rules {
|
||||
margin: 0 0 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.rules a {
|
||||
color: var(--accent);
|
||||
}
|
||||
.cols {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.col h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.respect {
|
||||
margin: 0 0 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.col ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
color: var(--text);
|
||||
}
|
||||
.ft {
|
||||
margin-top: auto;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { mount } from 'svelte';
|
||||
import './app.css';
|
||||
import Landing from './Landing.svelte';
|
||||
|
||||
// Entry for the standalone landing page (served at "/" by the gateway; the game SPA lives at
|
||||
// /app/ and /telegram/). Mounts into the same #app node as the SPA's main.ts.
|
||||
export default mount(Landing, { target: document.getElementById('app')! });
|
||||
@@ -152,6 +152,10 @@ export const en = {
|
||||
'about.description': 'A multiplatform Scrabble game.',
|
||||
'about.version': 'Version {v}',
|
||||
|
||||
'landing.tagline': 'Play Scrabble with friends or a smart robot — in your browser or on Telegram.',
|
||||
'landing.playWeb': 'Play in browser',
|
||||
'landing.playTelegram': 'Play in Telegram',
|
||||
|
||||
'lang.en': 'English',
|
||||
'lang.ru': 'Русский',
|
||||
|
||||
|
||||
@@ -153,6 +153,10 @@ export const ru: Record<MessageKey, string> = {
|
||||
'about.description': 'Мультиплатформенная игра в скрабл.',
|
||||
'about.version': 'Версия {v}',
|
||||
|
||||
'landing.tagline': 'Играй в Скрэббл с друзьями или умным роботом — в браузере или в Telegram.',
|
||||
'landing.playWeb': 'Играть в браузере',
|
||||
'landing.playTelegram': 'Играть в Telegram',
|
||||
|
||||
'lang.en': 'English',
|
||||
'lang.ru': 'Русский',
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { telegramBotLink } from './landing';
|
||||
|
||||
describe('telegramBotLink', () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it('returns the per-language bot link when configured', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/Scrabble_Game');
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/Erudit_Game');
|
||||
expect(telegramBotLink('en')).toBe('https://t.me/Scrabble_Game');
|
||||
expect(telegramBotLink('ru')).toBe('https://t.me/Erudit_Game');
|
||||
});
|
||||
|
||||
it('returns null when the locale link is unset or blank', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK_EN', '');
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK_RU', ' ');
|
||||
expect(telegramBotLink('en')).toBeNull();
|
||||
expect(telegramBotLink('ru')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// Pure helpers for the public landing page (Stage 17), kept out of the Svelte component so
|
||||
// the per-language Telegram-bot link selection is unit-testable.
|
||||
|
||||
import type { Locale } from './i18n/index.svelte';
|
||||
|
||||
/**
|
||||
* telegramBotLink returns the t.me link for the locale's game bot, or null when it is not
|
||||
* configured. The two links are build-time vars (VITE_TELEGRAM_LINK_EN / VITE_TELEGRAM_LINK_RU)
|
||||
* because the test and prod contours run different bots (different usernames), so the link
|
||||
* cannot be hardcoded.
|
||||
*/
|
||||
export function telegramBotLink(locale: Locale): string | null {
|
||||
const raw = locale === 'ru' ? import.meta.env.VITE_TELEGRAM_LINK_RU : import.meta.env.VITE_TELEGRAM_LINK_EN;
|
||||
const link = (raw as string | undefined)?.trim();
|
||||
return link ? link : null;
|
||||
}
|
||||
Reference in New Issue
Block a user