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();
|
||||
</script>
|
||||
|
||||
<header class="topbar">
|
||||
{#if back}
|
||||
<button class="icon" onclick={() => back && navigate(back)} aria-label="Back">◄</button>
|
||||
{:else}
|
||||
<span class="spacer"></span>
|
||||
{/if}
|
||||
<h1>{title}</h1>
|
||||
<div class="end">{#if menu}{@render menu()}{/if}</div>
|
||||
<header class="nav">
|
||||
<div class="bar">
|
||||
{#if back}
|
||||
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
|
||||
<span class="chev"></span>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="spacer"></span>
|
||||
{/if}
|
||||
<h1>{title}</h1>
|
||||
<div class="end">{#if menu}{@render menu()}{/if}</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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;
|
||||
align-items: center;
|
||||
gap: var(--gap);
|
||||
padding: 10px var(--pad);
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.05rem;
|
||||
@@ -33,28 +44,40 @@
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.icon,
|
||||
.spacer,
|
||||
.end {
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
min-width: 40px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.end {
|
||||
width: auto;
|
||||
min-width: 40px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.icon {
|
||||
.back {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 8px;
|
||||
}
|
||||
.icon:hover {
|
||||
.back:hover {
|
||||
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>
|
||||
|
||||
@@ -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 { 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<void> {
|
||||
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<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 {
|
||||
@@ -184,3 +193,8 @@ export function setReduceMotion(on: boolean): void {
|
||||
applyReduceMotion(on);
|
||||
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.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.',
|
||||
|
||||
@@ -77,6 +77,20 @@ export const ru: Record<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'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<MessageKey, string> = {
|
||||
'error.not_your_turn': 'Сейчас не ваш ход.',
|
||||
'error.illegal_play': 'Это недопустимый ход.',
|
||||
'error.hint_unavailable': 'Подсказки недоступны.',
|
||||
'error.no_hint_available': 'Нет вариантов с вашим набором.',
|
||||
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
|
||||
'error.game_finished': 'Эта игра уже завершена.',
|
||||
'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 { 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<Partial<Prefs>> {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<script lang="ts">
|
||||
import Header from '../components/Header.svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
|
||||
const version = '0.7.0';
|
||||
</script>
|
||||
|
||||
<Header title={t('about.title')} back="/" />
|
||||
<main class="page">
|
||||
<h2>{t('app.title')}</h2>
|
||||
<p>{t('about.description')}</p>
|
||||
<p class="muted">{t('about.version', { v: version })}</p>
|
||||
</main>
|
||||
<Screen title={t('about.title')} back="/">
|
||||
<div class="page">
|
||||
<h2>{t('app.title')}</h2>
|
||||
<p>{t('about.description')}</p>
|
||||
<p class="muted">{t('about.version', { v: version })}</p>
|
||||
</div>
|
||||
</Screen>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
|
||||
+59
-143
@@ -1,14 +1,16 @@
|
||||
<script lang="ts">
|
||||
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 { gateway } from '../lib/gateway';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { resultBadge } from '../lib/result';
|
||||
import type { GameView } from '../lib/model';
|
||||
|
||||
let games = $state<GameView[]>([]);
|
||||
let menuOpen = $state(false);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
@@ -19,8 +21,6 @@
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
|
||||
// Refresh the lists when a live event lands (move / your-turn / match-found).
|
||||
$effect(() => {
|
||||
if (app.lastEvent) void load();
|
||||
});
|
||||
@@ -29,95 +29,72 @@
|
||||
const active = $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 {
|
||||
return g.seats
|
||||
.filter((s) => s.accountId !== myId)
|
||||
.map((s) => s.displayName)
|
||||
.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 {
|
||||
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);
|
||||
return `${me?.score ?? 0} : ${opp.join(', ')}`;
|
||||
}
|
||||
|
||||
function go(path: string) {
|
||||
menuOpen = false;
|
||||
navigate(path);
|
||||
}
|
||||
const menuItems = $derived([
|
||||
{ label: t('lobby.profile'), onclick: () => navigate('/profile') },
|
||||
{ label: t('lobby.settings'), onclick: () => navigate('/settings') },
|
||||
{ label: t('lobby.about'), onclick: () => navigate('/about') },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<Header title={app.profile?.displayName ?? t('app.title')}>
|
||||
<Screen title={app.profile?.displayName ?? t('app.title')}>
|
||||
{#snippet menu()}
|
||||
<button class="icon" onclick={() => (menuOpen = !menuOpen)} aria-label="Menu">≡</button>
|
||||
{#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}
|
||||
<Menu items={menuItems} />
|
||||
{/snippet}
|
||||
</Header>
|
||||
|
||||
<main class="lobby">
|
||||
<section>
|
||||
<h2>{t('lobby.activeGames')}</h2>
|
||||
{#if active.length === 0}
|
||||
<div class="lobby">
|
||||
{#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)}
|
||||
{#if group.list.length}
|
||||
<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>
|
||||
{/if}
|
||||
{#each active as g (g.id)}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>{t('lobby.finishedGames')}</h2>
|
||||
{#if finished.length === 0}
|
||||
<p class="empty">{t('lobby.noFinished')}</p>
|
||||
{/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>
|
||||
{#snippet tabbar()}
|
||||
<TabBar>
|
||||
<button class="tab" onclick={() => navigate('/new')}>
|
||||
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<nav class="tabs">
|
||||
<button class="tab primary" onclick={() => navigate('/new')}>{t('lobby.new')}</button>
|
||||
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>{t('lobby.stats')}</button>
|
||||
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>{t('lobby.tournaments')}</button>
|
||||
</nav>
|
||||
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
|
||||
<span class="sq">⚔️</span><span class="lbl">{t('lobby.tournaments')}</span>
|
||||
</button>
|
||||
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
|
||||
<span class="sq">🧮</span><span class="lbl">{t('lobby.stats')}</span>
|
||||
</button>
|
||||
</TabBar>
|
||||
{/snippet}
|
||||
</Screen>
|
||||
|
||||
<style>
|
||||
.lobby {
|
||||
padding: var(--pad);
|
||||
padding-bottom: 84px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
@@ -147,88 +124,27 @@
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
user-select: none;
|
||||
}
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.who {
|
||||
font-weight: 600;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.sub {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.sub.turn {
|
||||
color: var(--accent);
|
||||
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;
|
||||
.emoji {
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.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);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import Header from '../components/Header.svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { handleError } from '../lib/app.svelte';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
@@ -31,8 +31,6 @@
|
||||
navigate(`/game/${r.game.id}`);
|
||||
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 () => {
|
||||
try {
|
||||
const p = await gateway.lobbyPoll();
|
||||
@@ -53,23 +51,24 @@
|
||||
onDestroy(stop);
|
||||
</script>
|
||||
|
||||
<Header title={t('new.title')} back="/" />
|
||||
<main class="page">
|
||||
{#if searching}
|
||||
<div class="searching">
|
||||
<div class="spinner"></div>
|
||||
<p>{t('new.searching')}</p>
|
||||
<button class="cancel" onclick={() => { stop(); navigate('/'); }}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="subtitle">{t('new.subtitle')}</p>
|
||||
<div class="variants">
|
||||
{#each variants as v (v.id)}
|
||||
<button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
<Screen title={t('new.title')} back="/">
|
||||
<div class="page">
|
||||
{#if searching}
|
||||
<div class="searching">
|
||||
<div class="spinner"></div>
|
||||
<p>{t('new.searching')}</p>
|
||||
<button class="cancel" onclick={() => { stop(); navigate('/'); }}>{t('common.cancel')}</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="subtitle">{t('new.subtitle')}</p>
|
||||
<div class="variants">
|
||||
{#each variants as v (v.id)}
|
||||
<button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Screen>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
@@ -92,6 +91,7 @@
|
||||
border-radius: var(--radius);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
.searching {
|
||||
display: grid;
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
<script lang="ts">
|
||||
import Header from '../components/Header.svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import { app, logout } from '../lib/app.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
</script>
|
||||
|
||||
<Header title={t('profile.title')} back="/" />
|
||||
<main class="page">
|
||||
{#if app.profile}
|
||||
<div class="name">{app.profile.displayName}</div>
|
||||
{#if app.profile.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
|
||||
<dl>
|
||||
<dt>{t('profile.language')}</dt>
|
||||
<dd>{app.profile.preferredLanguage}</dd>
|
||||
<dt>{t('profile.timezone')}</dt>
|
||||
<dd>{app.profile.timeZone}</dd>
|
||||
<dt>{t('profile.hintBalance')}</dt>
|
||||
<dd>{app.profile.hintBalance}</dd>
|
||||
</dl>
|
||||
<p class="muted">{t('profile.readonly')}</p>
|
||||
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
|
||||
{/if}
|
||||
</main>
|
||||
<Screen title={t('profile.title')} back="/">
|
||||
<div class="page">
|
||||
{#if app.profile}
|
||||
<div class="name">{app.profile.displayName}</div>
|
||||
{#if app.profile.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
|
||||
<dl>
|
||||
<dt>{t('profile.language')}</dt>
|
||||
<dd>{app.profile.preferredLanguage}</dd>
|
||||
<dt>{t('profile.timezone')}</dt>
|
||||
<dd>{app.profile.timeZone}</dd>
|
||||
<dt>{t('profile.hintBalance')}</dt>
|
||||
<dd>{app.profile.hintBalance}</dd>
|
||||
</dl>
|
||||
<p class="muted">{t('profile.readonly')}</p>
|
||||
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
|
||||
{/if}
|
||||
</div>
|
||||
</Screen>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<script lang="ts">
|
||||
import Header from '../components/Header.svelte';
|
||||
import { app, setLocalePref, setReduceMotion, setTheme } from '../lib/app.svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import {
|
||||
app,
|
||||
setBoardLabels,
|
||||
setLocalePref,
|
||||
setReduceMotion,
|
||||
setTheme,
|
||||
} from '../lib/app.svelte';
|
||||
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import type { ThemePref } from '../lib/theme';
|
||||
import type { BoardLabelMode } from '../lib/boardlabels';
|
||||
|
||||
const themes: ThemePref[] = ['auto', 'light', 'dark'];
|
||||
const themeLabel: Record<ThemePref, MessageKey> = {
|
||||
@@ -11,43 +18,62 @@
|
||||
dark: 'settings.themeDark',
|
||||
};
|
||||
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>
|
||||
|
||||
<Header title={t('settings.title')} back="/" />
|
||||
<main class="page">
|
||||
<section>
|
||||
<h3>{t('settings.theme')}</h3>
|
||||
<div class="seg">
|
||||
{#each themes as th (th)}
|
||||
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
|
||||
{t(themeLabel[th])}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
<Screen title={t('settings.title')} back="/">
|
||||
<div class="page">
|
||||
<section>
|
||||
<h3>{t('settings.theme')}</h3>
|
||||
<div class="seg">
|
||||
{#each themes as th (th)}
|
||||
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
|
||||
{t(themeLabel[th])}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>{t('settings.language')}</h3>
|
||||
<div class="seg">
|
||||
{#each locales as lc (lc)}
|
||||
<button class="opt" class:active={app.locale === lc} onclick={() => setLocalePref(lc)}>
|
||||
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>{t('settings.language')}</h3>
|
||||
<div class="seg">
|
||||
{#each locales as lc (lc)}
|
||||
<button class="opt" class:active={app.locale === lc} onclick={() => setLocalePref(lc)}>
|
||||
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<label class="row">
|
||||
<span>{t('settings.reduceMotion')}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={app.reduceMotion}
|
||||
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
</main>
|
||||
<section>
|
||||
<h3>{t('settings.boardStyle')}</h3>
|
||||
<div class="sub">{t('settings.boardLabels')}</div>
|
||||
<div class="seg">
|
||||
{#each labelModes as lm (lm)}
|
||||
<button class="opt" class:active={app.boardLabels === lm} onclick={() => setBoardLabels(lm)}>
|
||||
{t(labelModeKey[lm])}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</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>
|
||||
.page {
|
||||
@@ -61,6 +87,11 @@
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.sub {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.seg {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -72,6 +103,7 @@
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
user-select: none;
|
||||
}
|
||||
.opt.active {
|
||||
background: var(--accent);
|
||||
|
||||
Reference in New Issue
Block a user