Stage 7 polish: app shell + nav + lobby + settings (Parts A/B/C)
- Screen.svelte shell: nav bar grows, ad+content+tabbar pinned bottom (mobile feel) - AdBanner.svelte + banner.ts rotator (params, mock long/short, linkify); Header CSS chevron + grow; Menu (bigger CSS hamburger); TabBar + HoldConfirm shared components; user-select:none - Lobby: hide-empty sections, tab order New/Tournaments/Stats, place-based result badges (result.ts) - Settings: Board style > Labels (beginner/classic/none) + prefs plumbing (boardlabels.ts); i18n keys + ru mirror
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import {
|
||||||
|
createBannerRotator,
|
||||||
|
defaultBannerConfig,
|
||||||
|
linkify,
|
||||||
|
mockBanners,
|
||||||
|
type BannerConfig,
|
||||||
|
type BannerItem,
|
||||||
|
} from '../lib/banner';
|
||||||
|
|
||||||
|
let { items = mockBanners(), config = defaultBannerConfig }: { items?: BannerItem[]; config?: BannerConfig } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
let current = $state(0);
|
||||||
|
let tx = $state(0);
|
||||||
|
let txDur = $state(0);
|
||||||
|
let track = $state<HTMLElement>();
|
||||||
|
let viewport = $state<HTMLElement>();
|
||||||
|
|
||||||
|
let rotator: ReturnType<typeof createBannerRotator> | null = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
rotator = createBannerRotator(items, {
|
||||||
|
overflowPx: () => Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0)),
|
||||||
|
show: (i) => {
|
||||||
|
current = i;
|
||||||
|
tx = 0;
|
||||||
|
txDur = 0;
|
||||||
|
},
|
||||||
|
scrollTo: (toPx, durationMs) => {
|
||||||
|
txDur = durationMs;
|
||||||
|
tx = -toPx;
|
||||||
|
},
|
||||||
|
}, config);
|
||||||
|
rotator.start();
|
||||||
|
});
|
||||||
|
onDestroy(() => rotator?.stop());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="ad" bind:this={viewport}>
|
||||||
|
{#key current}
|
||||||
|
<div
|
||||||
|
class="track"
|
||||||
|
bind:this={track}
|
||||||
|
in:fade={{ duration: config.fadeMs }}
|
||||||
|
style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
|
||||||
|
>
|
||||||
|
{@html linkify(items[current]?.md ?? '')}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ad {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 6px var(--pad);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.track {
|
||||||
|
display: inline-block;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.track :global(a) {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,27 +5,38 @@
|
|||||||
let { title, back, menu }: { title: string; back?: string; menu?: Snippet } = $props();
|
let { title, back, menu }: { title: string; back?: string; menu?: Snippet } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="topbar">
|
<header class="nav">
|
||||||
{#if back}
|
<div class="bar">
|
||||||
<button class="icon" onclick={() => back && navigate(back)} aria-label="Back">◄</button>
|
{#if back}
|
||||||
{:else}
|
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
|
||||||
<span class="spacer"></span>
|
<span class="chev"></span>
|
||||||
{/if}
|
</button>
|
||||||
<h1>{title}</h1>
|
{:else}
|
||||||
<div class="end">{#if menu}{@render menu()}{/if}</div>
|
<span class="spacer"></span>
|
||||||
|
{/if}
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<div class="end">{#if menu}{@render menu()}{/if}</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.topbar {
|
/* The nav bar grows to fill the spare vertical space (buttons stay at the top), so
|
||||||
|
the rest of the screen pins to the bottom — a mobile-app layout. */
|
||||||
|
.nav {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 52px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--gap);
|
gap: var(--gap);
|
||||||
padding: 10px var(--pad);
|
padding: 10px var(--pad);
|
||||||
background: var(--bg-elev);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 5;
|
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
@@ -33,28 +44,40 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.icon,
|
.icon,
|
||||||
.spacer,
|
.spacer,
|
||||||
.end {
|
.end {
|
||||||
width: 40px;
|
min-width: 40px;
|
||||||
height: 32px;
|
height: 36px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.end {
|
.end {
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 40px;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
.icon {
|
.back {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 1.1rem;
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
.icon:hover {
|
.back:hover {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
}
|
}
|
||||||
|
/* A thin, compact "<" drawn from two borders — lighter than a glyph. */
|
||||||
|
.chev {
|
||||||
|
width: 11px;
|
||||||
|
height: 11px;
|
||||||
|
border-left: 2.5px solid currentColor;
|
||||||
|
border-bottom: 2.5px solid currentColor;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
// A press-and-hold control: a short tap opens a popover (the consumer renders its
|
||||||
|
// buttons), a ~holdMs hold runs `onhold` immediately. Reused by MakeMove and the
|
||||||
|
// game tab-bar confirmations. The popover snippet receives a `close` callback.
|
||||||
|
let {
|
||||||
|
onhold,
|
||||||
|
holdMs = 700,
|
||||||
|
disabled = false,
|
||||||
|
triggerClass = '',
|
||||||
|
trigger,
|
||||||
|
popover,
|
||||||
|
}: {
|
||||||
|
onhold: () => void;
|
||||||
|
holdMs?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
triggerClass?: string;
|
||||||
|
trigger: Snippet;
|
||||||
|
popover: Snippet<[() => void]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let held = false;
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function down() {
|
||||||
|
if (disabled) return;
|
||||||
|
held = false;
|
||||||
|
clear();
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
held = true;
|
||||||
|
open = false;
|
||||||
|
onhold();
|
||||||
|
}, holdMs);
|
||||||
|
}
|
||||||
|
function up() {
|
||||||
|
clear();
|
||||||
|
if (!held && !disabled) open = true;
|
||||||
|
}
|
||||||
|
function leave() {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
const close = () => (open = false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="hc">
|
||||||
|
<button
|
||||||
|
class="trigger {triggerClass}"
|
||||||
|
{disabled}
|
||||||
|
onpointerdown={down}
|
||||||
|
onpointerup={up}
|
||||||
|
onpointerleave={leave}
|
||||||
|
onpointercancel={leave}
|
||||||
|
>
|
||||||
|
{@render trigger()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="backdrop" onclick={close}></div>
|
||||||
|
<div class="popover">{@render popover(close)}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hc {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.trigger {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 18;
|
||||||
|
}
|
||||||
|
.popover {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 19;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The header hamburger + dropdown, shared by the lobby and game screens.
|
||||||
|
let { items }: { items: { label: string; onclick: () => void }[] } = $props();
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
function pick(fn: () => void) {
|
||||||
|
open = false;
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="menu">
|
||||||
|
<button class="burger" onclick={() => (open = !open)} aria-label="Menu">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
{#if open}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="backdrop" onclick={() => (open = false)}></div>
|
||||||
|
<div class="dropdown">
|
||||||
|
{#each items as it (it.label)}
|
||||||
|
<button onclick={() => pick(it.onclick)}>{it.label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.menu {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.burger {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
width: 44px;
|
||||||
|
height: 38px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 0 10px;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
.burger span {
|
||||||
|
display: block;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--text);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 8;
|
||||||
|
}
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
z-index: 9;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 170px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dropdown button {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
.dropdown button:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import Header from './Header.svelte';
|
||||||
|
import AdBanner from './AdBanner.svelte';
|
||||||
|
|
||||||
|
// The app-shell layout (all screens): the nav bar grows; the ad strip, content and
|
||||||
|
// optional tab bar pin to the bottom (ad directly above the content). Pass `scroll`
|
||||||
|
// false for screens that own their vertical fit (the game board).
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
back,
|
||||||
|
menu,
|
||||||
|
tabbar,
|
||||||
|
children,
|
||||||
|
scroll = true,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
back?: string;
|
||||||
|
menu?: Snippet;
|
||||||
|
tabbar?: Snippet;
|
||||||
|
children?: Snippet;
|
||||||
|
scroll?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="screen">
|
||||||
|
<Header {title} {back} {menu} />
|
||||||
|
<AdBanner />
|
||||||
|
<main class="content" class:scroll>{@render children?.()}</main>
|
||||||
|
{#if tabbar}
|
||||||
|
<nav class="tabbar">{@render tabbar()}</nav>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.content.scroll {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.tabbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
// The bottom tab bar: square borderless buttons, evenly distributed, mobile-OS feel.
|
||||||
|
// Direct children (plain `.tab` buttons or HoldConfirm wrappers) share the width.
|
||||||
|
let { children }: { children?: Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="tabbar">{@render children?.()}</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tabbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px var(--pad);
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
:global(.tabbar > *) {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* Tab face: an icon square (the press-highlight target) + a tiny truncated label. */
|
||||||
|
:global(.tab) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 2px 0;
|
||||||
|
color: var(--text);
|
||||||
|
width: 100%;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
:global(.tab:disabled) {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
:global(.tab .sq) {
|
||||||
|
width: 48px;
|
||||||
|
height: 40px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: background-color 0.12s;
|
||||||
|
}
|
||||||
|
:global(.tab:active:not(:disabled) .sq) {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
:global(.tab .lbl) {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,6 +10,7 @@ import { navigate, router } from './router.svelte';
|
|||||||
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
||||||
import { applyReduceMotion, applyTheme, type ThemePref } from './theme';
|
import { applyReduceMotion, applyTheme, type ThemePref } from './theme';
|
||||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
||||||
|
import type { BoardLabelMode } from './boardlabels';
|
||||||
|
|
||||||
export interface Toast {
|
export interface Toast {
|
||||||
kind: 'error' | 'info';
|
kind: 'error' | 'info';
|
||||||
@@ -25,6 +26,7 @@ export const app = $state<{
|
|||||||
theme: ThemePref;
|
theme: ThemePref;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
reduceMotion: boolean;
|
reduceMotion: boolean;
|
||||||
|
boardLabels: BoardLabelMode;
|
||||||
localeLocked: boolean;
|
localeLocked: boolean;
|
||||||
}>({
|
}>({
|
||||||
ready: false,
|
ready: false,
|
||||||
@@ -35,6 +37,7 @@ export const app = $state<{
|
|||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
reduceMotion: false,
|
reduceMotion: false,
|
||||||
|
boardLabels: 'beginner',
|
||||||
localeLocked: false,
|
localeLocked: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,6 +104,7 @@ export async function bootstrap(): Promise<void> {
|
|||||||
const prefs = await loadPrefs();
|
const prefs = await loadPrefs();
|
||||||
app.theme = prefs.theme ?? 'auto';
|
app.theme = prefs.theme ?? 'auto';
|
||||||
app.reduceMotion = prefs.reduceMotion ?? false;
|
app.reduceMotion = prefs.reduceMotion ?? false;
|
||||||
|
app.boardLabels = prefs.boardLabels ?? 'beginner';
|
||||||
applyTheme(app.theme);
|
applyTheme(app.theme);
|
||||||
applyReduceMotion(app.reduceMotion);
|
applyReduceMotion(app.reduceMotion);
|
||||||
if (prefs.locale) {
|
if (prefs.locale) {
|
||||||
@@ -163,7 +167,12 @@ export async function logout(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function persistPrefs(): void {
|
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 {
|
export function setTheme(theme: ThemePref): void {
|
||||||
@@ -184,3 +193,8 @@ export function setReduceMotion(on: boolean): void {
|
|||||||
applyReduceMotion(on);
|
applyReduceMotion(on);
|
||||||
persistPrefs();
|
persistPrefs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setBoardLabels(mode: BoardLabelMode): void {
|
||||||
|
app.boardLabels = mode;
|
||||||
|
persistPrefs();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<typeof setTimeout>[] = [];
|
||||||
|
|
||||||
|
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(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`);
|
||||||
|
} 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)!',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -76,6 +76,20 @@ export const en = {
|
|||||||
'game.wordIllegal': '“{word}” is not valid',
|
'game.wordIllegal': '“{word}” is not valid',
|
||||||
'game.complain': 'Disagree',
|
'game.complain': 'Disagree',
|
||||||
'game.complaintSent': 'Thanks, sent for review.',
|
'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.placeholder': 'Quick message…',
|
||||||
'chat.send': 'Send',
|
'chat.send': 'Send',
|
||||||
@@ -96,6 +110,11 @@ export const en = {
|
|||||||
'settings.themeLight': 'Light',
|
'settings.themeLight': 'Light',
|
||||||
'settings.themeDark': 'Dark',
|
'settings.themeDark': 'Dark',
|
||||||
'settings.language': 'Interface language',
|
'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',
|
'settings.reduceMotion': 'Reduce motion',
|
||||||
|
|
||||||
'about.title': 'About',
|
'about.title': 'About',
|
||||||
@@ -108,6 +127,7 @@ export const en = {
|
|||||||
'error.not_your_turn': "It is not your turn.",
|
'error.not_your_turn': "It is not your turn.",
|
||||||
'error.illegal_play': 'That is not a legal play.',
|
'error.illegal_play': 'That is not a legal play.',
|
||||||
'error.hint_unavailable': 'No hints available.',
|
'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.chat_rejected': 'Message rejected (too long or contains contact info).',
|
||||||
'error.game_finished': 'This game is finished.',
|
'error.game_finished': 'This game is finished.',
|
||||||
'error.not_a_player': 'You are not a player in this game.',
|
'error.not_a_player': 'You are not a player in this game.',
|
||||||
|
|||||||
@@ -77,6 +77,20 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'game.wordIllegal': '«{word}» недопустимо',
|
'game.wordIllegal': '«{word}» недопустимо',
|
||||||
'game.complain': 'Не согласен',
|
'game.complain': 'Не согласен',
|
||||||
'game.complaintSent': 'Спасибо, отправлено на проверку.',
|
'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.placeholder': 'Короткое сообщение…',
|
||||||
'chat.send': 'Отправить',
|
'chat.send': 'Отправить',
|
||||||
@@ -97,6 +111,11 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'settings.themeLight': 'Светлая',
|
'settings.themeLight': 'Светлая',
|
||||||
'settings.themeDark': 'Тёмная',
|
'settings.themeDark': 'Тёмная',
|
||||||
'settings.language': 'Язык интерфейса',
|
'settings.language': 'Язык интерфейса',
|
||||||
|
'settings.boardStyle': 'Стиль доски',
|
||||||
|
'settings.boardLabels': 'Подписи бонусов',
|
||||||
|
'settings.labelsBeginner': 'Новичок',
|
||||||
|
'settings.labelsClassic': 'Классика',
|
||||||
|
'settings.labelsNone': 'Без текста',
|
||||||
'settings.reduceMotion': 'Меньше анимаций',
|
'settings.reduceMotion': 'Меньше анимаций',
|
||||||
|
|
||||||
'about.title': 'О программе',
|
'about.title': 'О программе',
|
||||||
@@ -109,6 +128,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'error.not_your_turn': 'Сейчас не ваш ход.',
|
'error.not_your_turn': 'Сейчас не ваш ход.',
|
||||||
'error.illegal_play': 'Это недопустимый ход.',
|
'error.illegal_play': 'Это недопустимый ход.',
|
||||||
'error.hint_unavailable': 'Подсказки недоступны.',
|
'error.hint_unavailable': 'Подсказки недоступны.',
|
||||||
|
'error.no_hint_available': 'Нет вариантов с вашим набором.',
|
||||||
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
|
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
|
||||||
'error.game_finished': 'Эта игра уже завершена.',
|
'error.game_finished': 'Эта игра уже завершена.',
|
||||||
'error.not_a_player': 'Вы не участник этой игры.',
|
'error.not_a_player': 'Вы не участник этой игры.',
|
||||||
|
|||||||
@@ -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: '🏅' };
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
import type { Session } from './model';
|
import type { Session } from './model';
|
||||||
import type { ThemePref } from './theme';
|
import type { ThemePref } from './theme';
|
||||||
import type { Locale } from './i18n/catalog';
|
import type { Locale } from './i18n/catalog';
|
||||||
|
import type { BoardLabelMode } from './boardlabels';
|
||||||
|
|
||||||
const DB_NAME = 'scrabble';
|
const DB_NAME = 'scrabble';
|
||||||
const STORE = 'kv';
|
const STORE = 'kv';
|
||||||
@@ -122,6 +123,7 @@ export interface Prefs {
|
|||||||
theme: ThemePref;
|
theme: ThemePref;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
reduceMotion: boolean;
|
reduceMotion: boolean;
|
||||||
|
boardLabels: BoardLabelMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadPrefs(): Promise<Partial<Prefs>> {
|
export async function loadPrefs(): Promise<Partial<Prefs>> {
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Header from '../components/Header.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
|
||||||
const version = '0.7.0';
|
const version = '0.7.0';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Header title={t('about.title')} back="/" />
|
<Screen title={t('about.title')} back="/">
|
||||||
<main class="page">
|
<div class="page">
|
||||||
<h2>{t('app.title')}</h2>
|
<h2>{t('app.title')}</h2>
|
||||||
<p>{t('about.description')}</p>
|
<p>{t('about.description')}</p>
|
||||||
<p class="muted">{t('about.version', { v: version })}</p>
|
<p class="muted">{t('about.version', { v: version })}</p>
|
||||||
</main>
|
</div>
|
||||||
|
</Screen>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
|
|||||||
+59
-143
@@ -1,14 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Header from '../components/Header.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
|
import Menu from '../components/Menu.svelte';
|
||||||
|
import TabBar from '../components/TabBar.svelte';
|
||||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
import { resultBadge } from '../lib/result';
|
||||||
import type { GameView } from '../lib/model';
|
import type { GameView } from '../lib/model';
|
||||||
|
|
||||||
let games = $state<GameView[]>([]);
|
let games = $state<GameView[]>([]);
|
||||||
let menuOpen = $state(false);
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -19,8 +21,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
|
|
||||||
// Refresh the lists when a live event lands (move / your-turn / match-found).
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (app.lastEvent) void load();
|
if (app.lastEvent) void load();
|
||||||
});
|
});
|
||||||
@@ -29,95 +29,72 @@
|
|||||||
const active = $derived(games.filter((g) => g.status === 'active'));
|
const active = $derived(games.filter((g) => g.status === 'active'));
|
||||||
const finished = $derived(games.filter((g) => g.status !== 'active'));
|
const finished = $derived(games.filter((g) => g.status !== 'active'));
|
||||||
|
|
||||||
function mySeat(g: GameView) {
|
|
||||||
return g.seats.find((s) => s.accountId === myId);
|
|
||||||
}
|
|
||||||
function opponents(g: GameView): string {
|
function opponents(g: GameView): string {
|
||||||
return g.seats
|
return g.seats
|
||||||
.filter((s) => s.accountId !== myId)
|
.filter((s) => s.accountId !== myId)
|
||||||
.map((s) => s.displayName)
|
.map((s) => s.displayName)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
}
|
}
|
||||||
function subtitle(g: GameView): string {
|
|
||||||
const me = mySeat(g);
|
|
||||||
if (g.status === 'active') {
|
|
||||||
return g.toMove === me?.seat ? t('lobby.yourTurn') : t('lobby.theirTurn');
|
|
||||||
}
|
|
||||||
if (me?.isWinner) return t('game.won');
|
|
||||||
return g.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
|
|
||||||
}
|
|
||||||
function scoreline(g: GameView): string {
|
function scoreline(g: GameView): string {
|
||||||
const me = mySeat(g);
|
const me = g.seats.find((s) => s.accountId === myId);
|
||||||
const opp = g.seats.filter((s) => s.accountId !== myId).map((s) => s.score);
|
const opp = g.seats.filter((s) => s.accountId !== myId).map((s) => s.score);
|
||||||
return `${me?.score ?? 0} : ${opp.join(', ')}`;
|
return `${me?.score ?? 0} : ${opp.join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function go(path: string) {
|
const menuItems = $derived([
|
||||||
menuOpen = false;
|
{ label: t('lobby.profile'), onclick: () => navigate('/profile') },
|
||||||
navigate(path);
|
{ label: t('lobby.settings'), onclick: () => navigate('/settings') },
|
||||||
}
|
{ label: t('lobby.about'), onclick: () => navigate('/about') },
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Header title={app.profile?.displayName ?? t('app.title')}>
|
<Screen title={app.profile?.displayName ?? t('app.title')}>
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
<button class="icon" onclick={() => (menuOpen = !menuOpen)} aria-label="Menu">≡</button>
|
<Menu items={menuItems} />
|
||||||
{#if menuOpen}
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div class="backdrop" onclick={() => (menuOpen = false)}></div>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button onclick={() => go('/profile')}>{t('lobby.profile')}</button>
|
|
||||||
<button onclick={() => go('/settings')}>{t('lobby.settings')}</button>
|
|
||||||
<button onclick={() => go('/about')}>{t('lobby.about')}</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Header>
|
|
||||||
|
|
||||||
<main class="lobby">
|
<div class="lobby">
|
||||||
<section>
|
{#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)}
|
||||||
<h2>{t('lobby.activeGames')}</h2>
|
{#if group.list.length}
|
||||||
{#if active.length === 0}
|
<section>
|
||||||
|
<h2>{t(group.h as 'lobby.activeGames')}</h2>
|
||||||
|
{#each group.list as g (g.id)}
|
||||||
|
{@const b = resultBadge(g, myId)}
|
||||||
|
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
|
||||||
|
<span class="info">
|
||||||
|
<span class="who">{opponents(g) || '—'}</span>
|
||||||
|
<span class="sub">{t(b.key)} · {scoreline(g)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="emoji">{b.emoji}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if !active.length && !finished.length}
|
||||||
<p class="empty">{t('lobby.noActive')}</p>
|
<p class="empty">{t('lobby.noActive')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#each active as g (g.id)}
|
</div>
|
||||||
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
|
|
||||||
<span class="who">{opponents(g) || '—'}</span>
|
|
||||||
<span class="meta">
|
|
||||||
<span class="sub" class:turn={g.toMove === mySeat(g)?.seat}>{subtitle(g)}</span>
|
|
||||||
<span class="score">{scoreline(g)}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
{#snippet tabbar()}
|
||||||
<h2>{t('lobby.finishedGames')}</h2>
|
<TabBar>
|
||||||
{#if finished.length === 0}
|
<button class="tab" onclick={() => navigate('/new')}>
|
||||||
<p class="empty">{t('lobby.noFinished')}</p>
|
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
||||||
{/if}
|
|
||||||
{#each finished as g (g.id)}
|
|
||||||
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
|
|
||||||
<span class="who">{opponents(g) || '—'}</span>
|
|
||||||
<span class="meta">
|
|
||||||
<span class="sub">{subtitle(g)}</span>
|
|
||||||
<span class="score">{scoreline(g)}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
|
||||||
</section>
|
<span class="sq">⚔️</span><span class="lbl">{t('lobby.tournaments')}</span>
|
||||||
</main>
|
</button>
|
||||||
|
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
|
||||||
<nav class="tabs">
|
<span class="sq">🧮</span><span class="lbl">{t('lobby.stats')}</span>
|
||||||
<button class="tab primary" onclick={() => navigate('/new')}>{t('lobby.new')}</button>
|
</button>
|
||||||
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>{t('lobby.stats')}</button>
|
</TabBar>
|
||||||
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>{t('lobby.tournaments')}</button>
|
{/snippet}
|
||||||
</nav>
|
</Screen>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.lobby {
|
.lobby {
|
||||||
padding: var(--pad);
|
padding: var(--pad);
|
||||||
padding-bottom: 84px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
@@ -147,88 +124,27 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.who {
|
.who {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
white-space: nowrap;
|
||||||
.meta {
|
overflow: hidden;
|
||||||
display: flex;
|
text-overflow: ellipsis;
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
}
|
||||||
.sub {
|
.sub {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.sub.turn {
|
.emoji {
|
||||||
color: var(--accent);
|
font-size: 1.8rem;
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.score {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.tabs {
|
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px var(--pad);
|
|
||||||
background: var(--bg-elev);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.tab {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.tab.primary {
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--accent-text);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
.icon {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 1.3rem;
|
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
flex: 0 0 auto;
|
||||||
.backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 8;
|
|
||||||
}
|
|
||||||
.dropdown {
|
|
||||||
position: absolute;
|
|
||||||
right: 8px;
|
|
||||||
top: 44px;
|
|
||||||
z-index: 9;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 160px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.dropdown button {
|
|
||||||
padding: 11px 14px;
|
|
||||||
text-align: left;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.dropdown button:hover {
|
|
||||||
background: var(--surface-2);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import Header from '../components/Header.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { handleError } from '../lib/app.svelte';
|
import { handleError } from '../lib/app.svelte';
|
||||||
import { navigate } from '../lib/router.svelte';
|
import { navigate } from '../lib/router.svelte';
|
||||||
@@ -31,8 +31,6 @@
|
|||||||
navigate(`/game/${r.game.id}`);
|
navigate(`/game/${r.game.id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The match also arrives via the live stream (handled in app), but poll as a
|
|
||||||
// fallback for a client that is not currently streaming.
|
|
||||||
poll = setInterval(async () => {
|
poll = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const p = await gateway.lobbyPoll();
|
const p = await gateway.lobbyPoll();
|
||||||
@@ -53,23 +51,24 @@
|
|||||||
onDestroy(stop);
|
onDestroy(stop);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Header title={t('new.title')} back="/" />
|
<Screen title={t('new.title')} back="/">
|
||||||
<main class="page">
|
<div class="page">
|
||||||
{#if searching}
|
{#if searching}
|
||||||
<div class="searching">
|
<div class="searching">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>{t('new.searching')}</p>
|
<p>{t('new.searching')}</p>
|
||||||
<button class="cancel" onclick={() => { stop(); navigate('/'); }}>{t('common.cancel')}</button>
|
<button class="cancel" onclick={() => { stop(); navigate('/'); }}>{t('common.cancel')}</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="subtitle">{t('new.subtitle')}</p>
|
<p class="subtitle">{t('new.subtitle')}</p>
|
||||||
<div class="variants">
|
<div class="variants">
|
||||||
{#each variants as v (v.id)}
|
{#each variants as v (v.id)}
|
||||||
<button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button>
|
<button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</div>
|
||||||
|
</Screen>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
@@ -92,6 +91,7 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
.searching {
|
.searching {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Header from '../components/Header.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
import { app, logout } from '../lib/app.svelte';
|
import { app, logout } from '../lib/app.svelte';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Header title={t('profile.title')} back="/" />
|
<Screen title={t('profile.title')} back="/">
|
||||||
<main class="page">
|
<div class="page">
|
||||||
{#if app.profile}
|
{#if app.profile}
|
||||||
<div class="name">{app.profile.displayName}</div>
|
<div class="name">{app.profile.displayName}</div>
|
||||||
{#if app.profile.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
|
{#if app.profile.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
|
||||||
<dl>
|
<dl>
|
||||||
<dt>{t('profile.language')}</dt>
|
<dt>{t('profile.language')}</dt>
|
||||||
<dd>{app.profile.preferredLanguage}</dd>
|
<dd>{app.profile.preferredLanguage}</dd>
|
||||||
<dt>{t('profile.timezone')}</dt>
|
<dt>{t('profile.timezone')}</dt>
|
||||||
<dd>{app.profile.timeZone}</dd>
|
<dd>{app.profile.timeZone}</dd>
|
||||||
<dt>{t('profile.hintBalance')}</dt>
|
<dt>{t('profile.hintBalance')}</dt>
|
||||||
<dd>{app.profile.hintBalance}</dd>
|
<dd>{app.profile.hintBalance}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<p class="muted">{t('profile.readonly')}</p>
|
<p class="muted">{t('profile.readonly')}</p>
|
||||||
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
|
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</div>
|
||||||
|
</Screen>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Header from '../components/Header.svelte';
|
import Screen from '../components/Screen.svelte';
|
||||||
import { app, setLocalePref, setReduceMotion, setTheme } from '../lib/app.svelte';
|
import {
|
||||||
|
app,
|
||||||
|
setBoardLabels,
|
||||||
|
setLocalePref,
|
||||||
|
setReduceMotion,
|
||||||
|
setTheme,
|
||||||
|
} from '../lib/app.svelte';
|
||||||
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
|
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
|
||||||
import type { ThemePref } from '../lib/theme';
|
import type { ThemePref } from '../lib/theme';
|
||||||
|
import type { BoardLabelMode } from '../lib/boardlabels';
|
||||||
|
|
||||||
const themes: ThemePref[] = ['auto', 'light', 'dark'];
|
const themes: ThemePref[] = ['auto', 'light', 'dark'];
|
||||||
const themeLabel: Record<ThemePref, MessageKey> = {
|
const themeLabel: Record<ThemePref, MessageKey> = {
|
||||||
@@ -11,43 +18,62 @@
|
|||||||
dark: 'settings.themeDark',
|
dark: 'settings.themeDark',
|
||||||
};
|
};
|
||||||
const locales: Locale[] = ['en', 'ru'];
|
const locales: Locale[] = ['en', 'ru'];
|
||||||
|
const labelModes: BoardLabelMode[] = ['beginner', 'classic', 'none'];
|
||||||
|
const labelModeKey: Record<BoardLabelMode, MessageKey> = {
|
||||||
|
beginner: 'settings.labelsBeginner',
|
||||||
|
classic: 'settings.labelsClassic',
|
||||||
|
none: 'settings.labelsNone',
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Header title={t('settings.title')} back="/" />
|
<Screen title={t('settings.title')} back="/">
|
||||||
<main class="page">
|
<div class="page">
|
||||||
<section>
|
<section>
|
||||||
<h3>{t('settings.theme')}</h3>
|
<h3>{t('settings.theme')}</h3>
|
||||||
<div class="seg">
|
<div class="seg">
|
||||||
{#each themes as th (th)}
|
{#each themes as th (th)}
|
||||||
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
|
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
|
||||||
{t(themeLabel[th])}
|
{t(themeLabel[th])}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3>{t('settings.language')}</h3>
|
<h3>{t('settings.language')}</h3>
|
||||||
<div class="seg">
|
<div class="seg">
|
||||||
{#each locales as lc (lc)}
|
{#each locales as lc (lc)}
|
||||||
<button class="opt" class:active={app.locale === lc} onclick={() => setLocalePref(lc)}>
|
<button class="opt" class:active={app.locale === lc} onclick={() => setLocalePref(lc)}>
|
||||||
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
|
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<label class="row">
|
<h3>{t('settings.boardStyle')}</h3>
|
||||||
<span>{t('settings.reduceMotion')}</span>
|
<div class="sub">{t('settings.boardLabels')}</div>
|
||||||
<input
|
<div class="seg">
|
||||||
type="checkbox"
|
{#each labelModes as lm (lm)}
|
||||||
checked={app.reduceMotion}
|
<button class="opt" class:active={app.boardLabels === lm} onclick={() => setBoardLabels(lm)}>
|
||||||
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
|
{t(labelModeKey[lm])}
|
||||||
/>
|
</button>
|
||||||
</label>
|
{/each}
|
||||||
</section>
|
</div>
|
||||||
</main>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<label class="row">
|
||||||
|
<span>{t('settings.reduceMotion')}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={app.reduceMotion}
|
||||||
|
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Screen>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
@@ -61,6 +87,11 @@
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
.sub {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
.seg {
|
.seg {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -72,6 +103,7 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
.opt.active {
|
.opt.active {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
|||||||
Reference in New Issue
Block a user