38be7fea96
- 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
158 lines
4.6 KiB
TypeScript
158 lines
4.6 KiB
TypeScript
// 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)!',
|
|
},
|
|
];
|
|
}
|