diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte new file mode 100644 index 0000000..daae87a --- /dev/null +++ b/ui/src/components/AdBanner.svelte @@ -0,0 +1,77 @@ + + +
+ {#key current} +
+ {@html linkify(items[current]?.md ?? '')} +
+ {/key} +
+ + diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index 692b282..28b55c3 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -5,27 +5,38 @@ let { title, back, menu }: { title: string; back?: string; menu?: Snippet } = $props(); -
- {#if back} - - {:else} - - {/if} -

{title}

-
{#if menu}{@render menu()}{/if}
+ diff --git a/ui/src/components/HoldConfirm.svelte b/ui/src/components/HoldConfirm.svelte new file mode 100644 index 0000000..402d92a --- /dev/null +++ b/ui/src/components/HoldConfirm.svelte @@ -0,0 +1,102 @@ + + +
+ + + {#if open} + + +
+
{@render popover(close)}
+ {/if} +
+ + diff --git a/ui/src/components/Menu.svelte b/ui/src/components/Menu.svelte new file mode 100644 index 0000000..3fb4a08 --- /dev/null +++ b/ui/src/components/Menu.svelte @@ -0,0 +1,83 @@ + + + + + diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte new file mode 100644 index 0000000..ef0f2a6 --- /dev/null +++ b/ui/src/components/Screen.svelte @@ -0,0 +1,51 @@ + + +
+
+ +
{@render children?.()}
+ {#if tabbar} + + {/if} +
+ + diff --git a/ui/src/components/TabBar.svelte b/ui/src/components/TabBar.svelte new file mode 100644 index 0000000..4810ae9 --- /dev/null +++ b/ui/src/components/TabBar.svelte @@ -0,0 +1,62 @@ + + +
{@render children?.()}
+ + diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index d164955..2fbce2f 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -10,6 +10,7 @@ import { navigate, router } from './router.svelte'; import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte'; import { applyReduceMotion, applyTheme, type ThemePref } from './theme'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; +import type { BoardLabelMode } from './boardlabels'; export interface Toast { kind: 'error' | 'info'; @@ -25,6 +26,7 @@ export const app = $state<{ theme: ThemePref; locale: Locale; reduceMotion: boolean; + boardLabels: BoardLabelMode; localeLocked: boolean; }>({ ready: false, @@ -35,6 +37,7 @@ export const app = $state<{ theme: 'auto', locale: 'en', reduceMotion: false, + boardLabels: 'beginner', localeLocked: false, }); @@ -101,6 +104,7 @@ export async function bootstrap(): Promise { const prefs = await loadPrefs(); app.theme = prefs.theme ?? 'auto'; app.reduceMotion = prefs.reduceMotion ?? false; + app.boardLabels = prefs.boardLabels ?? 'beginner'; applyTheme(app.theme); applyReduceMotion(app.reduceMotion); if (prefs.locale) { @@ -163,7 +167,12 @@ export async function logout(): Promise { } function persistPrefs(): void { - void savePrefs({ theme: app.theme, locale: app.locale, reduceMotion: app.reduceMotion }); + void savePrefs({ + theme: app.theme, + locale: app.locale, + reduceMotion: app.reduceMotion, + boardLabels: app.boardLabels, + }); } export function setTheme(theme: ThemePref): void { @@ -184,3 +193,8 @@ export function setReduceMotion(on: boolean): void { applyReduceMotion(on); persistPrefs(); } + +export function setBoardLabels(mode: BoardLabelMode): void { + app.boardLabels = mode; + persistPrefs(); +} diff --git a/ui/src/lib/banner.ts b/ui/src/lib/banner.ts new file mode 100644 index 0000000..e942752 --- /dev/null +++ b/ui/src/lib/banner.ts @@ -0,0 +1,157 @@ +// Announcement / "ad" banner — a parameterised rotator plus a tiny markdown linkifier. +// The rotator is DOM-agnostic (the host measures overflow and applies the visual +// effects through callbacks), so its timing is unit-testable with fake timers. Today +// the content is a mock long↔short rotation; later it becomes a server-driven +// announcements channel (see ARCHITECTURE). + +export interface BannerConfig { + /** How long one message is shown before advancing (short text), ms. */ + holdMs: number; + /** Pause at each end before/after scrolling a long message, ms. */ + edgePauseMs: number; + /** Scroll speed for a long (overflowing) message, px/sec. */ + scrollPxPerSec: number; + /** Cross-fade duration between messages, ms. */ + fadeMs: number; +} + +export const defaultBannerConfig: BannerConfig = { + holdMs: 60_000, + edgePauseMs: 5_000, + scrollPxPerSec: 40, + fadeMs: 400, +}; + +export interface BannerItem { + /** Minimal markdown: plain text + `[label](url)` links. */ + md: string; +} + +/** The host the rotator drives; the Svelte component supplies the DOM measurements. */ +export interface BannerHost { + /** Overflow width of item `index` in px (0 when it fits). */ + overflowPx(index: number): number; + /** Render item `index` (the host fades it in and resets scroll to the start). */ + show(index: number): void; + /** Animate the horizontal scroll to `toPx` over `durationMs`. */ + scrollTo(toPx: number, durationMs: number): void; +} + +export interface Rotator { + start(): void; + stop(): void; +} + +/** + * createBannerRotator drives a list of messages: a fitting message holds `holdMs` + * then advances; an overflowing one pauses, scrolls to its right edge, pauses, then + * repeats while the elapsed cycle is under `holdMs`, else advances. + */ +export function createBannerRotator( + items: BannerItem[], + host: BannerHost, + config: BannerConfig = defaultBannerConfig, +): Rotator { + let index = 0; + let running = false; + let cycleStart = 0; + const timers: ReturnType[] = []; + + const at = (ms: number, fn: () => void) => { + timers.push(setTimeout(fn, ms)); + }; + const clear = () => { + for (const t of timers) clearTimeout(t); + timers.length = 0; + }; + + function advance() { + if (!running) return; + index = (index + 1) % items.length; + present(); + } + + function present() { + if (!running) return; + clear(); + host.show(index); + // Let the swapped-in message render before measuring its overflow. + at(config.fadeMs, () => { + const over = host.overflowPx(index); + if (over <= 0) { + at(config.holdMs, advance); + return; + } + cycleStart = Date.now(); + scrollCycle(over); + }); + } + + function scrollCycle(over: number) { + const dur = (over / config.scrollPxPerSec) * 1000; + at(config.edgePauseMs, () => { + host.scrollTo(over, dur); + at(dur + config.edgePauseMs, () => { + if (Date.now() - cycleStart >= config.holdMs) { + advance(); + } else { + host.show(index); // resets scroll to the start + scrollCycle(over); + } + }); + }); + } + + return { + start() { + if (running || items.length === 0) return; + running = true; + index = 0; + present(); + }, + stop() { + running = false; + clear(); + }, + }; +} + +const URL_RE = /^(https?:\/\/|\/)/i; + +function escapeHtml(s: string): string { + return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!); +} + +/** + * linkify renders minimal markdown to a safe HTML string: everything is escaped, then + * `[label](url)` becomes a link (only http(s):// or root-relative URLs are allowed). + */ +export function linkify(md: string): string { + const parts: string[] = []; + const re = /\[([^\]]+)\]\(([^)]+)\)/g; + let last = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(md)) !== null) { + parts.push(escapeHtml(md.slice(last, m.index))); + const label = escapeHtml(m[1]); + const url = m[2].trim(); + if (URL_RE.test(url)) { + parts.push(`${label}`); + } else { + parts.push(label); + } + last = re.lastIndex; + } + parts.push(escapeHtml(md.slice(last))); + return parts.join(''); +} + +/** mockBanners is the placeholder rotation (long ↔ short) to demo the mechanics. */ +export function mockBanners(): BannerItem[] { + return [ + { md: 'New season starts soon — [learn more](https://example.com/season).' }, + { + md: 'Tip: a 7-tile play earns a +50 bonus. Try the daily tournament, climb the leaderboard, and challenge friends — more modes are coming, [stay tuned](https://example.com/news)!', + }, + ]; +} diff --git a/ui/src/lib/boardlabels.ts b/ui/src/lib/boardlabels.ts new file mode 100644 index 0000000..f774ddf --- /dev/null +++ b/ui/src/lib/boardlabels.ts @@ -0,0 +1,36 @@ +// Bonus-square label modes (a client setting, separate from the theme). The board +// renders these locally — premiums are not on the wire. Default is "beginner". + +import type { Premium } from './premiums'; +import type { Locale } from './i18n/catalog'; + +export type BoardLabelMode = 'beginner' | 'classic' | 'none'; + +export type BonusLabel = + | { kind: 'single'; text: string } + | { kind: 'split'; top: string; bottom: string } + | null; + +function multiplier(p: Premium): number { + return p === 'TW' || p === 'TL' ? 3 : 2; +} + +function isWord(p: Premium): boolean { + return p === 'TW' || p === 'DW'; +} + +/** + * bonusLabel returns how a premium square is labelled: `classic` "3W"/"3С", `beginner` + * a split "3×" / "word" (localized), or nothing. + */ +export function bonusLabel(mode: BoardLabelMode, p: Premium, locale: Locale): BonusLabel { + if (mode === 'none' || p === '') return null; + const n = multiplier(p); + const word = isWord(p); + if (mode === 'classic') { + const tag = locale === 'ru' ? (word ? 'С' : 'Б') : word ? 'W' : 'L'; + return { kind: 'single', text: `${n}${tag}` }; + } + const bottom = locale === 'ru' ? (word ? 'слово' : 'буква') : word ? 'word' : 'letter'; + return { kind: 'split', top: `${n}×`, bottom }; +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index aeef360..1279f19 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -76,6 +76,20 @@ export const en = { 'game.wordIllegal': '“{word}” is not valid', 'game.complain': 'Disagree', 'game.complaintSent': 'Thanks, sent for review.', + 'game.confirm': 'Ok', + 'game.check': 'Check', + 'game.checkWait': 'Please wait a moment.', + 'game.noHintOptions': 'No options with your letters.', + 'game.scores': 'Scores: {n}', + + 'result.victory': 'Victory', + 'result.defeat': 'Defeat', + 'result.draw': 'Draw', + 'result.place2': 'II place', + 'result.place3': 'III place', + 'result.place4': 'IV place', + 'result.yourMove': 'Your move', + 'result.oppMove': "Opponent's move", 'chat.placeholder': 'Quick message…', 'chat.send': 'Send', @@ -96,6 +110,11 @@ export const en = { 'settings.themeLight': 'Light', 'settings.themeDark': 'Dark', 'settings.language': 'Interface language', + 'settings.boardStyle': 'Board style', + 'settings.boardLabels': 'Bonus labels', + 'settings.labelsBeginner': 'Beginner', + 'settings.labelsClassic': 'Classic', + 'settings.labelsNone': 'None', 'settings.reduceMotion': 'Reduce motion', 'about.title': 'About', @@ -108,6 +127,7 @@ export const en = { 'error.not_your_turn': "It is not your turn.", 'error.illegal_play': 'That is not a legal play.', 'error.hint_unavailable': 'No hints available.', + 'error.no_hint_available': 'No options with your letters.', 'error.chat_rejected': 'Message rejected (too long or contains contact info).', 'error.game_finished': 'This game is finished.', 'error.not_a_player': 'You are not a player in this game.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index b8c075e..f541894 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -77,6 +77,20 @@ export const ru: Record = { 'game.wordIllegal': '«{word}» недопустимо', 'game.complain': 'Не согласен', 'game.complaintSent': 'Спасибо, отправлено на проверку.', + 'game.confirm': 'Да', + 'game.check': 'Проверить', + 'game.checkWait': 'Секунду, пожалуйста.', + 'game.noHintOptions': 'Нет вариантов с вашим набором.', + 'game.scores': 'Очков: {n}', + + 'result.victory': 'Победа', + 'result.defeat': 'Поражение', + 'result.draw': 'Ничья', + 'result.place2': 'II место', + 'result.place3': 'III место', + 'result.place4': 'IV место', + 'result.yourMove': 'Ваш ход', + 'result.oppMove': 'Ход соперника', 'chat.placeholder': 'Короткое сообщение…', 'chat.send': 'Отправить', @@ -97,6 +111,11 @@ export const ru: Record = { 'settings.themeLight': 'Светлая', 'settings.themeDark': 'Тёмная', 'settings.language': 'Язык интерфейса', + 'settings.boardStyle': 'Стиль доски', + 'settings.boardLabels': 'Подписи бонусов', + 'settings.labelsBeginner': 'Новичок', + 'settings.labelsClassic': 'Классика', + 'settings.labelsNone': 'Без текста', 'settings.reduceMotion': 'Меньше анимаций', 'about.title': 'О программе', @@ -109,6 +128,7 @@ export const ru: Record = { 'error.not_your_turn': 'Сейчас не ваш ход.', 'error.illegal_play': 'Это недопустимый ход.', 'error.hint_unavailable': 'Подсказки недоступны.', + 'error.no_hint_available': 'Нет вариантов с вашим набором.', 'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).', 'error.game_finished': 'Эта игра уже завершена.', 'error.not_a_player': 'Вы не участник этой игры.', diff --git a/ui/src/lib/result.ts b/ui/src/lib/result.ts new file mode 100644 index 0000000..479db04 --- /dev/null +++ b/ui/src/lib/result.ts @@ -0,0 +1,30 @@ +// Pure mapping from a game view (for the viewer) to a status/result badge: a label key +// and a place-based emoji. Used by the lobby lists. + +import type { GameView } from './model'; +import type { MessageKey } from './i18n/catalog'; + +export interface ResultBadge { + key: MessageKey; + emoji: string; +} + +export function resultBadge(game: GameView, myId: string): ResultBadge { + const me = game.seats.find((s) => s.accountId === myId); + + if (game.status === 'active') { + return game.toMove === me?.seat + ? { key: 'result.yourMove', emoji: '🟢' } + : { key: 'result.oppMove', emoji: '⏳' }; + } + + if (me?.isWinner) return { key: 'result.victory', emoji: '🏆' }; + if (!game.seats.some((s) => s.isWinner)) return { key: 'result.draw', emoji: '🏅' }; + + // Someone else won — place the viewer by score (1 + number of higher scores). + const rank = 1 + game.seats.filter((s) => s.score > (me?.score ?? 0)).length; + if (rank <= 1) return { key: 'result.victory', emoji: '🏆' }; + if (rank === 2) return game.players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' }; + if (rank === 3) return { key: 'result.place3', emoji: '🥉' }; + return { key: 'result.place4', emoji: '🏅' }; +} diff --git a/ui/src/lib/session.ts b/ui/src/lib/session.ts index fac640e..f4f703d 100644 --- a/ui/src/lib/session.ts +++ b/ui/src/lib/session.ts @@ -6,6 +6,7 @@ import type { Session } from './model'; import type { ThemePref } from './theme'; import type { Locale } from './i18n/catalog'; +import type { BoardLabelMode } from './boardlabels'; const DB_NAME = 'scrabble'; const STORE = 'kv'; @@ -122,6 +123,7 @@ export interface Prefs { theme: ThemePref; locale: Locale; reduceMotion: boolean; + boardLabels: BoardLabelMode; } export async function loadPrefs(): Promise> { diff --git a/ui/src/screens/About.svelte b/ui/src/screens/About.svelte index a641724..874f0da 100644 --- a/ui/src/screens/About.svelte +++ b/ui/src/screens/About.svelte @@ -1,16 +1,17 @@ -
-
-

{t('app.title')}

-

{t('about.description')}

-

{t('about.version', { v: version })}

-
+ +
+

{t('app.title')}

+

{t('about.description')}

+

{t('about.version', { v: version })}

+
+
diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 74ee12a..40d9c04 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -1,6 +1,6 @@ -
-
- {#if searching} -
-
-

{t('new.searching')}

- -
- {:else} -

{t('new.subtitle')}

-
- {#each variants as v (v.id)} - - {/each} -
- {/if} -
+ +
+ {#if searching} +
+
+

{t('new.searching')}

+ +
+ {:else} +

{t('new.subtitle')}

+
+ {#each variants as v (v.id)} + + {/each} +
+ {/if} +
+