Enhance internationalization support by integrating language selection for school names, updating UI components for localization, and revising documentation. Implement locale-aware formatting for dates and strings, ensuring a seamless user experience in both Russian and English. Update tests to cover new language features.
This commit is contained in:
@@ -9,8 +9,8 @@
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
<footer id="status">
|
||||
<span data-status="connection">подключение…</span>
|
||||
<span data-status="ping">-- мс</span>
|
||||
<span data-status="connection"></span>
|
||||
<span data-status="ping"></span>
|
||||
</footer>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { getLocale, setLocale } from '../i18n/locale.ts';
|
||||
import {
|
||||
formatGameDate,
|
||||
formatGameDateTime,
|
||||
@@ -10,6 +11,10 @@ import {
|
||||
|
||||
// The default start of a new school: 3 April 2012, 06:00 — a Tuesday.
|
||||
const START = new Date(Date.UTC(2012, 3, 3, 6, 0, 0));
|
||||
const initial = getLocale();
|
||||
|
||||
beforeEach(() => setLocale('ru'));
|
||||
afterEach(() => setLocale(initial));
|
||||
|
||||
describe('game time formatting', () => {
|
||||
it('shows the time of day in 24-hour form', () => {
|
||||
@@ -30,6 +35,15 @@ describe('game time formatting', () => {
|
||||
expect(formatGameDateTime(START)).toContain('06:00');
|
||||
});
|
||||
|
||||
it('formats the same instant in English when the locale is en', () => {
|
||||
setLocale('en');
|
||||
|
||||
expect(formatGameWeekday(START)).toBe('Tuesday');
|
||||
expect(formatGameDate(START)).toContain('April');
|
||||
expect(formatGameDateTime(START)).toContain('03/04/2012');
|
||||
expect(formatGameTimeOfDay(START)).toBe('06:00');
|
||||
});
|
||||
|
||||
it('reads the calendar in UTC, so the school day does not shift with the viewer', () => {
|
||||
// Just before midnight UTC: any local-time formatting would land on the 4th.
|
||||
const lateEvening = new Date(Date.UTC(2012, 3, 3, 23, 30, 0));
|
||||
|
||||
@@ -6,53 +6,76 @@
|
||||
* whatever offset they happen to live in.
|
||||
*/
|
||||
|
||||
const LOCALE = 'ru-RU';
|
||||
import { intlTag, onLocaleChange } from '../i18n/locale.ts';
|
||||
|
||||
const UTC = 'UTC';
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat(LOCALE, {
|
||||
timeZone: UTC,
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
interface Formats {
|
||||
readonly date: Intl.DateTimeFormat;
|
||||
readonly time: Intl.DateTimeFormat;
|
||||
readonly weekday: Intl.DateTimeFormat;
|
||||
readonly short: Intl.DateTimeFormat;
|
||||
}
|
||||
|
||||
let cached: { tag: string; formats: Formats } | null = null;
|
||||
|
||||
function formats(): Formats {
|
||||
const tag = intlTag();
|
||||
if (cached?.tag === tag) {
|
||||
return cached.formats;
|
||||
}
|
||||
|
||||
const next: Formats = {
|
||||
date: new Intl.DateTimeFormat(tag, {
|
||||
timeZone: UTC,
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}),
|
||||
time: new Intl.DateTimeFormat(tag, {
|
||||
timeZone: UTC,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}),
|
||||
weekday: new Intl.DateTimeFormat(tag, { timeZone: UTC, weekday: 'long' }),
|
||||
short: new Intl.DateTimeFormat(tag, {
|
||||
timeZone: UTC,
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}),
|
||||
};
|
||||
|
||||
cached = { tag, formats: next };
|
||||
return next;
|
||||
}
|
||||
|
||||
onLocaleChange(() => {
|
||||
cached = null;
|
||||
});
|
||||
|
||||
const timeFormat = new Intl.DateTimeFormat(LOCALE, {
|
||||
timeZone: UTC,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const weekdayFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: UTC, weekday: 'long' });
|
||||
|
||||
const shortFormat = new Intl.DateTimeFormat(LOCALE, {
|
||||
timeZone: UTC,
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
/** "3 апреля 2012 г." */
|
||||
/** "3 апреля 2012 г." / "3 April 2012" */
|
||||
export function formatGameDate(date: Date): string {
|
||||
return dateFormat.format(date);
|
||||
return formats().date.format(date);
|
||||
}
|
||||
|
||||
/** "06:00" */
|
||||
export function formatGameTimeOfDay(date: Date): string {
|
||||
return timeFormat.format(date);
|
||||
return formats().time.format(date);
|
||||
}
|
||||
|
||||
/** "вторник" */
|
||||
/** "вторник" / "Tuesday" */
|
||||
export function formatGameWeekday(date: Date): string {
|
||||
return weekdayFormat.format(date);
|
||||
return formats().weekday.format(date);
|
||||
}
|
||||
|
||||
/** "03.04.2012, 06:00" — the compact form the school cards use. */
|
||||
/** Compact form the school cards use — day-month-year in both languages. */
|
||||
export function formatGameDateTime(date: Date): string {
|
||||
return shortFormat.format(date);
|
||||
return formats().short.format(date);
|
||||
}
|
||||
|
||||
/** Splits an ISO instant into the `<input type="date">` and `<input type="time">` values it needs. */
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/** The two UI languages. English dates use en-GB so day-month-year matches the Russian order. */
|
||||
export type Locale = 'ru' | 'en';
|
||||
|
||||
export const LOCALES = ['ru', 'en'] as const;
|
||||
|
||||
const STORAGE_KEY = 'h-school.locale';
|
||||
|
||||
let current: Locale = detect();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function detect(): Locale {
|
||||
const stored = readStored();
|
||||
if (stored !== null) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
const language = typeof navigator === 'undefined' ? '' : navigator.language.toLowerCase();
|
||||
return language.startsWith('ru') ? 'ru' : 'en';
|
||||
}
|
||||
|
||||
function readStored(): Locale | null {
|
||||
try {
|
||||
const value = globalThis.localStorage?.getItem(STORAGE_KEY);
|
||||
return value === 'ru' || value === 'en' ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persist(locale: Locale): void {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(STORAGE_KEY, locale);
|
||||
} catch {
|
||||
// Private mode, or tests without a store — the in-memory value still changes.
|
||||
}
|
||||
}
|
||||
|
||||
function applyToDocument(locale: Locale): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
|
||||
applyToDocument(current);
|
||||
|
||||
export function getLocale(): Locale {
|
||||
return current;
|
||||
}
|
||||
|
||||
/** BCP 47 tag for `Intl` formatters. */
|
||||
export function intlTag(locale: Locale = current): 'ru-RU' | 'en-GB' {
|
||||
return locale === 'en' ? 'en-GB' : 'ru-RU';
|
||||
}
|
||||
|
||||
export function setLocale(locale: Locale): void {
|
||||
if (locale === current) {
|
||||
return;
|
||||
}
|
||||
|
||||
current = locale;
|
||||
persist(locale);
|
||||
applyToDocument(locale);
|
||||
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns an unsubscribe function. */
|
||||
export function onLocaleChange(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { getLocale, setLocale } from './locale.ts';
|
||||
import { schoolWord, t } from './strings.ts';
|
||||
|
||||
const initial = getLocale();
|
||||
|
||||
afterEach(() => setLocale(initial));
|
||||
|
||||
describe('t', () => {
|
||||
it('returns the Russian string for the active locale', () => {
|
||||
setLocale('ru');
|
||||
expect(t('createSchool')).toBe('Создать школу');
|
||||
});
|
||||
|
||||
it('returns the English string after a switch', () => {
|
||||
setLocale('en');
|
||||
expect(t('createSchool')).toBe('Create school');
|
||||
});
|
||||
|
||||
it('interpolates placeholders', () => {
|
||||
setLocale('en');
|
||||
expect(t('schoolCount', { current: 2, max: 6 })).toBe('Schools: 2 of 6.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('schoolWord', () => {
|
||||
it('uses the Russian one/few/many forms', () => {
|
||||
setLocale('ru');
|
||||
expect(schoolWord(1)).toBe('школа');
|
||||
expect(schoolWord(2)).toBe('школы');
|
||||
expect(schoolWord(5)).toBe('школ');
|
||||
expect(schoolWord(21)).toBe('школа');
|
||||
});
|
||||
|
||||
it('uses the English one/other forms', () => {
|
||||
setLocale('en');
|
||||
expect(schoolWord(1)).toBe('school');
|
||||
expect(schoolWord(2)).toBe('schools');
|
||||
expect(schoolWord(5)).toBe('schools');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { getLocale, intlTag, type Locale } from './locale.ts';
|
||||
|
||||
const ru = {
|
||||
statusConnecting: 'подключение…',
|
||||
statusConnected: 'сервер на связи',
|
||||
statusReconnecting: 'переподключение…',
|
||||
statusClosed: 'соединение закрыто',
|
||||
pingMs: '{ms} мс',
|
||||
pingPlaceholder: '-- мс',
|
||||
language: 'Язык',
|
||||
|
||||
schoolsTitle: 'Школы',
|
||||
createSchool: 'Создать школу',
|
||||
emptySchools: 'Пока ни одной школы. Создайте первую.',
|
||||
loadSchoolsFailed: 'Не удалось загрузить список школ. Проверьте соединение с сервером.',
|
||||
createDisabledTitle: 'Удалите одну из школ, чтобы создать новую',
|
||||
schoolCount: 'Школ: {current} из {max}.',
|
||||
schoolLimitReached: 'Достигнут лимит: {max} {schoolWord}. Удалите одну, чтобы создать новую.',
|
||||
|
||||
deleteSchool: 'Удалить',
|
||||
deleteSchoolTitle: 'Удалить школу?',
|
||||
deleteSchoolMessage: '«{name}» будет удалена без возможности восстановления.',
|
||||
deleteFailed: 'Не удалось удалить «{name}».',
|
||||
paused: '⏸ на паузе',
|
||||
|
||||
newSchool: 'Новая школа',
|
||||
schoolName: 'Название',
|
||||
schoolNamePlaceholder: 'Название школы',
|
||||
randomName: 'Случайное',
|
||||
randomNameTitle: 'Придумать название',
|
||||
gameStart: 'Начало игры',
|
||||
create: 'Создать',
|
||||
cancel: 'Отмена',
|
||||
confirm: 'Подтвердить',
|
||||
randomNameFailed: 'Не удалось получить название с сервера.',
|
||||
startDateRequired: 'Укажите дату и время начала.',
|
||||
serverUnavailable: 'Сервер недоступен. Попробуйте ещё раз.',
|
||||
errorSchoolLimit: 'Достигнут лимит школ — удалите одну, чтобы создать новую.',
|
||||
errorInvalidName: 'Название должно быть от 1 до 40 символов.',
|
||||
errorInvalidStartDate: 'Дата начала вне допустимого диапазона.',
|
||||
|
||||
backToMenu: '← В главное меню',
|
||||
emptySchoolHint: 'Школа пока пуста — здесь появится сама игра.',
|
||||
pause: 'Пауза',
|
||||
resume: 'Продолжить',
|
||||
} as const;
|
||||
|
||||
type Messages = { [K in keyof typeof ru]: string };
|
||||
|
||||
const en: Messages = {
|
||||
statusConnecting: 'connecting…',
|
||||
statusConnected: 'connected',
|
||||
statusReconnecting: 'reconnecting…',
|
||||
statusClosed: 'connection closed',
|
||||
pingMs: '{ms} ms',
|
||||
pingPlaceholder: '-- ms',
|
||||
language: 'Language',
|
||||
|
||||
schoolsTitle: 'Schools',
|
||||
createSchool: 'Create school',
|
||||
emptySchools: 'No schools yet. Create the first one.',
|
||||
loadSchoolsFailed: 'Could not load the school list. Check the connection to the server.',
|
||||
createDisabledTitle: 'Delete a school to create a new one',
|
||||
schoolCount: 'Schools: {current} of {max}.',
|
||||
schoolLimitReached: 'Limit reached: {max} {schoolWord}. Delete one to create another.',
|
||||
|
||||
deleteSchool: 'Delete',
|
||||
deleteSchoolTitle: 'Delete this school?',
|
||||
deleteSchoolMessage: '"{name}" will be deleted permanently.',
|
||||
deleteFailed: 'Could not delete "{name}".',
|
||||
paused: '⏸ paused',
|
||||
|
||||
newSchool: 'New school',
|
||||
schoolName: 'Name',
|
||||
schoolNamePlaceholder: 'School name',
|
||||
randomName: 'Random',
|
||||
randomNameTitle: 'Suggest a name',
|
||||
gameStart: 'Game start',
|
||||
create: 'Create',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
randomNameFailed: 'Could not fetch a name from the server.',
|
||||
startDateRequired: 'Enter a start date and time.',
|
||||
serverUnavailable: 'The server is unavailable. Try again.',
|
||||
errorSchoolLimit: 'School limit reached — delete one to create another.',
|
||||
errorInvalidName: 'The name must be 1 to 40 characters.',
|
||||
errorInvalidStartDate: 'The start date is outside the allowed range.',
|
||||
|
||||
backToMenu: '← Main menu',
|
||||
emptySchoolHint: 'The school is empty for now — the game itself will appear here.',
|
||||
pause: 'Pause',
|
||||
resume: 'Resume',
|
||||
};
|
||||
|
||||
const catalogs: Record<Locale, Messages> = { ru, en };
|
||||
|
||||
export type MessageKey = keyof Messages;
|
||||
|
||||
export function t(key: MessageKey, vars?: Record<string, string | number>): string {
|
||||
let text: string = catalogs[getLocale()][key];
|
||||
|
||||
if (vars !== undefined) {
|
||||
for (const [name, value] of Object.entries(vars)) {
|
||||
text = text.replaceAll(`{${name}}`, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
interface PluralForms {
|
||||
readonly one: string;
|
||||
readonly few?: string;
|
||||
readonly many?: string;
|
||||
readonly other: string;
|
||||
}
|
||||
|
||||
const schoolWordForms: Record<Locale, PluralForms> = {
|
||||
ru: { one: 'школа', few: 'школы', many: 'школ', other: 'школ' },
|
||||
en: { one: 'school', other: 'schools' },
|
||||
};
|
||||
|
||||
export function schoolWord(count: number): string {
|
||||
const forms = schoolWordForms[getLocale()];
|
||||
const rule = new Intl.PluralRules(intlTag()).select(count);
|
||||
|
||||
switch (rule) {
|
||||
case 'one':
|
||||
return forms.one;
|
||||
case 'few':
|
||||
return forms.few ?? forms.other;
|
||||
case 'many':
|
||||
return forms.many ?? forms.other;
|
||||
default:
|
||||
return forms.other;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,31 @@
|
||||
import { GameConnection, gameSocketUrl, type ConnectionStatus } from './net/connection.ts';
|
||||
import { onLocaleChange } from './i18n/locale.ts';
|
||||
import { t, type MessageKey } from './i18n/strings.ts';
|
||||
import { GameScreen } from './ui/gameScreen.ts';
|
||||
import { localeSwitch } from './ui/localeSwitch.ts';
|
||||
import { MainMenu } from './ui/mainMenu.ts';
|
||||
import type { School } from './net/api.ts';
|
||||
import './style.css';
|
||||
|
||||
const STATUS_LABELS: Record<ConnectionStatus, string> = {
|
||||
connecting: 'подключение…',
|
||||
connected: 'сервер на связи',
|
||||
reconnecting: 'переподключение…',
|
||||
closed: 'соединение закрыто',
|
||||
const STATUS_KEYS: Record<ConnectionStatus, MessageKey> = {
|
||||
connecting: 'statusConnecting',
|
||||
connected: 'statusConnected',
|
||||
reconnecting: 'statusReconnecting',
|
||||
closed: 'statusClosed',
|
||||
};
|
||||
|
||||
/** Wires the two screens to one WebSocket connection. */
|
||||
function bootstrap(): void {
|
||||
const app = requireElement('#app');
|
||||
const footer = requireElement('#status');
|
||||
const statusLabel = document.querySelector<HTMLElement>('[data-status="connection"]');
|
||||
const pingLabel = document.querySelector<HTMLElement>('[data-status="ping"]');
|
||||
|
||||
footer.prepend(localeSwitch());
|
||||
|
||||
let openSchool: School | null = null;
|
||||
let connectionStatus: ConnectionStatus = 'connecting';
|
||||
let lastPingMs: number | null = null;
|
||||
|
||||
const menu = new MainMenu({ onOpenSchool: (school) => enterSchool(school) });
|
||||
const game = new GameScreen({
|
||||
@@ -28,9 +36,8 @@ function bootstrap(): void {
|
||||
|
||||
const connection = new GameConnection(gameSocketUrl(), {
|
||||
onStatus: (status) => {
|
||||
if (statusLabel !== null) {
|
||||
statusLabel.textContent = STATUS_LABELS[status];
|
||||
}
|
||||
connectionStatus = status;
|
||||
paintChrome();
|
||||
},
|
||||
onClock: (clock) => {
|
||||
if (openSchool?.id === clock.schoolId) {
|
||||
@@ -44,12 +51,22 @@ function bootstrap(): void {
|
||||
}
|
||||
},
|
||||
onLatency: (rttMs) => {
|
||||
if (pingLabel !== null) {
|
||||
pingLabel.textContent = `${Math.round(rttMs)} мс`;
|
||||
}
|
||||
lastPingMs = rttMs;
|
||||
paintChrome();
|
||||
},
|
||||
});
|
||||
|
||||
function paintChrome(): void {
|
||||
if (statusLabel !== null) {
|
||||
statusLabel.textContent = t(STATUS_KEYS[connectionStatus]);
|
||||
}
|
||||
|
||||
if (pingLabel !== null) {
|
||||
pingLabel.textContent =
|
||||
lastPingMs === null ? t('pingPlaceholder') : t('pingMs', { ms: Math.round(lastPingMs) });
|
||||
}
|
||||
}
|
||||
|
||||
function enterSchool(school: School): void {
|
||||
openSchool = school;
|
||||
menu.stop();
|
||||
@@ -69,6 +86,13 @@ function bootstrap(): void {
|
||||
menu.start();
|
||||
}
|
||||
|
||||
onLocaleChange(() => {
|
||||
paintChrome();
|
||||
menu.localize();
|
||||
game.localize();
|
||||
});
|
||||
|
||||
paintChrome();
|
||||
connection.connect();
|
||||
showMenu();
|
||||
|
||||
|
||||
@@ -34,8 +34,10 @@ export async function fetchSchools(): Promise<SchoolsResponse> {
|
||||
return request<SchoolsResponse>('/api/schools');
|
||||
}
|
||||
|
||||
export async function fetchRandomName(): Promise<string> {
|
||||
const response = await request<{ name: string }>('/api/schools/random-name');
|
||||
export async function fetchRandomName(lang: string): Promise<string> {
|
||||
const response = await request<{ name: string }>(
|
||||
`/api/schools/random-name?lang=${encodeURIComponent(lang)}`,
|
||||
);
|
||||
return response.name;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,11 +34,39 @@ body {
|
||||
right: 16px;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.locale-switch {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.locale-switch__button {
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.locale-switch__button:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.locale-switch__button[aria-pressed='true'] {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Screens */
|
||||
|
||||
.screen__header {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
import { Modal } from './modal.ts';
|
||||
|
||||
@@ -17,7 +18,7 @@ export function confirmDialog(options: ConfirmOptions): Promise<boolean> {
|
||||
const confirmButton = el('button', {
|
||||
class: options.danger === true ? 'button button--danger' : 'button button--primary',
|
||||
type: 'button',
|
||||
text: options.confirmLabel ?? 'Подтвердить',
|
||||
text: options.confirmLabel ?? t('confirm'),
|
||||
onClick: () => modal.close(true),
|
||||
});
|
||||
|
||||
@@ -30,7 +31,7 @@ export function confirmDialog(options: ConfirmOptions): Promise<boolean> {
|
||||
el('button', {
|
||||
class: 'button',
|
||||
type: 'button',
|
||||
text: options.cancelLabel ?? 'Отмена',
|
||||
text: options.cancelLabel ?? t('cancel'),
|
||||
onClick: () => modal.close(false),
|
||||
}),
|
||||
confirmButton,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiError, type School } from '../net/api.ts';
|
||||
import { fromDateAndTimeInputs, toDateAndTimeInputs } from '../format/gameTime.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
import { Modal } from './modal.ts';
|
||||
|
||||
@@ -20,7 +21,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
|
||||
const nameInput = el('input', { class: 'input', type: 'text' });
|
||||
nameInput.maxLength = 40;
|
||||
nameInput.placeholder = 'Название школы';
|
||||
nameInput.placeholder = t('schoolNamePlaceholder');
|
||||
nameInput.required = true;
|
||||
|
||||
const dateInput = el('input', { class: 'input', type: 'date' });
|
||||
@@ -37,11 +38,11 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
const randomButton = el('button', {
|
||||
class: 'button',
|
||||
type: 'button',
|
||||
text: 'Случайное',
|
||||
title: 'Придумать название',
|
||||
text: t('randomName'),
|
||||
title: t('randomNameTitle'),
|
||||
});
|
||||
|
||||
const submitButton = el('button', { class: 'button button--primary', type: 'submit', text: 'Создать' });
|
||||
const submitButton = el('button', { class: 'button button--primary', type: 'submit', text: t('create') });
|
||||
|
||||
const form = el(
|
||||
'form',
|
||||
@@ -49,20 +50,20 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
el(
|
||||
'label',
|
||||
{ class: 'field' },
|
||||
el('span', { class: 'field__label', text: 'Название' }),
|
||||
el('span', { class: 'field__label', text: t('schoolName') }),
|
||||
el('div', { class: 'field__row' }, nameInput, randomButton),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'field' },
|
||||
el('span', { class: 'field__label', text: 'Начало игры' }),
|
||||
el('span', { class: 'field__label', text: t('gameStart') }),
|
||||
el('div', { class: 'field__row' }, dateInput, timeInput),
|
||||
),
|
||||
error,
|
||||
el(
|
||||
'div',
|
||||
{ class: 'dialog__actions' },
|
||||
el('button', { class: 'button', type: 'button', text: 'Отмена', onClick: () => modal.close(null) }),
|
||||
el('button', { class: 'button', type: 'button', text: t('cancel'), onClick: () => modal.close(null) }),
|
||||
submitButton,
|
||||
),
|
||||
);
|
||||
@@ -92,7 +93,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
nameInput.value = name;
|
||||
error.hidden = true;
|
||||
})
|
||||
.catch(() => showError('Не удалось получить название с сервера.'))
|
||||
.catch(() => showError(t('randomNameFailed')))
|
||||
.finally(() => setBusy(false));
|
||||
});
|
||||
|
||||
@@ -104,7 +105,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
|
||||
const startDate = fromDateAndTimeInputs(dateInput.value, timeInput.value);
|
||||
if (startDate === null) {
|
||||
showError('Укажите дату и время начала.');
|
||||
showError(t('startDateRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -118,23 +119,23 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
||||
});
|
||||
});
|
||||
|
||||
modal.element.append(el('h2', { class: 'dialog__title', text: 'Новая школа' }), form);
|
||||
modal.element.append(el('h2', { class: 'dialog__title', text: t('newSchool') }), form);
|
||||
|
||||
return modal.open(nameInput);
|
||||
}
|
||||
|
||||
function describe(reason: unknown): string {
|
||||
if (!(reason instanceof ApiError)) {
|
||||
return 'Сервер недоступен. Попробуйте ещё раз.';
|
||||
return t('serverUnavailable');
|
||||
}
|
||||
|
||||
switch (reason.code) {
|
||||
case 'school-limit-reached':
|
||||
return 'Достигнут лимит школ — удалите одну, чтобы создать новую.';
|
||||
return t('errorSchoolLimit');
|
||||
case 'invalid-name':
|
||||
return 'Название должно быть от 1 до 40 символов.';
|
||||
return t('errorInvalidName');
|
||||
case 'invalid-start-date':
|
||||
return 'Дата начала вне допустимого диапазона.';
|
||||
return t('errorInvalidStartDate');
|
||||
default:
|
||||
return reason.message;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CLOCK_SPEEDS, type ClockMessage } from '../net/protocol.ts';
|
||||
import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import type { School } from '../net/api.ts';
|
||||
import { el } from './dom.ts';
|
||||
|
||||
@@ -17,14 +18,18 @@ const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4'];
|
||||
*/
|
||||
export class GameScreen {
|
||||
private readonly root = el('section', { class: 'screen game' });
|
||||
private readonly backButton = el('button', { class: 'button', type: 'button' });
|
||||
private readonly schoolName = el('h1', { class: 'screen__title' });
|
||||
private readonly time = el('p', { class: 'clock__time', text: '--:--' });
|
||||
private readonly date = el('p', { class: 'clock__date' });
|
||||
private readonly weekday = el('p', { class: 'clock__weekday' });
|
||||
private readonly emptyHint = el('p', { class: 'hint' });
|
||||
private readonly playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' });
|
||||
private readonly speedButtons: HTMLButtonElement[];
|
||||
|
||||
private running = false;
|
||||
private lastGameTime: Date | null = null;
|
||||
private lastSpeedIndex = 0;
|
||||
|
||||
constructor(options: GameScreenOptions) {
|
||||
this.speedButtons = CLOCK_SPEEDS.map((_, index) =>
|
||||
@@ -36,15 +41,11 @@ export class GameScreen {
|
||||
}),
|
||||
);
|
||||
|
||||
this.backButton.addEventListener('click', options.onLeave);
|
||||
this.playPauseButton.addEventListener('click', () => options.onSetRunning(!this.running));
|
||||
|
||||
this.root.append(
|
||||
el(
|
||||
'header',
|
||||
{ class: 'screen__header' },
|
||||
el('button', { class: 'button', type: 'button', text: '← В главное меню', onClick: options.onLeave }),
|
||||
this.schoolName,
|
||||
),
|
||||
el('header', { class: 'screen__header' }, this.backButton, this.schoolName),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'clock' },
|
||||
@@ -53,14 +54,27 @@ export class GameScreen {
|
||||
this.weekday,
|
||||
el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons),
|
||||
),
|
||||
el('p', { class: 'hint', text: 'Школа пока пуста — здесь появится сама игра.' }),
|
||||
this.emptyHint,
|
||||
);
|
||||
|
||||
this.localize();
|
||||
}
|
||||
|
||||
get element(): HTMLElement {
|
||||
return this.root;
|
||||
}
|
||||
|
||||
localize(): void {
|
||||
this.backButton.textContent = t('backToMenu');
|
||||
this.emptyHint.textContent = t('emptySchoolHint');
|
||||
|
||||
if (this.lastGameTime !== null) {
|
||||
this.applyClock(this.lastGameTime, this.running, this.lastSpeedIndex);
|
||||
} else {
|
||||
this.playPauseButton.title = t('resume');
|
||||
}
|
||||
}
|
||||
|
||||
/** Called when the screen opens, before the first clock frame arrives. */
|
||||
show(school: School): void {
|
||||
this.schoolName.textContent = school.name;
|
||||
@@ -73,13 +87,15 @@ export class GameScreen {
|
||||
|
||||
private applyClock(gameTime: Date, running: boolean, speedIndex: number): void {
|
||||
this.running = running;
|
||||
this.lastGameTime = gameTime;
|
||||
this.lastSpeedIndex = speedIndex;
|
||||
|
||||
this.time.textContent = formatGameTimeOfDay(gameTime);
|
||||
this.date.textContent = formatGameDate(gameTime);
|
||||
this.weekday.textContent = formatGameWeekday(gameTime);
|
||||
|
||||
this.playPauseButton.textContent = running ? '⏸' : '▶';
|
||||
this.playPauseButton.title = running ? 'Пауза' : 'Продолжить';
|
||||
this.playPauseButton.title = running ? t('pause') : t('resume');
|
||||
|
||||
this.speedButtons.forEach((button, index) => {
|
||||
button.classList.toggle('button--active', index === speedIndex);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { el } from './dom.ts';
|
||||
import { LOCALES, getLocale, setLocale, onLocaleChange, type Locale } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
|
||||
const LABELS: Record<Locale, string> = { ru: 'RU', en: 'EN' };
|
||||
|
||||
/** Compact RU/EN toggle for the status footer — always on screen, both menus. */
|
||||
export function localeSwitch(): HTMLElement {
|
||||
const buttons = new Map<Locale, HTMLButtonElement>();
|
||||
|
||||
const group = el('div', { class: 'locale-switch' });
|
||||
group.setAttribute('role', 'group');
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const button = el('button', {
|
||||
class: 'locale-switch__button',
|
||||
type: 'button',
|
||||
text: LABELS[locale],
|
||||
onClick: () => setLocale(locale),
|
||||
});
|
||||
button.setAttribute('lang', locale);
|
||||
buttons.set(locale, button);
|
||||
group.append(button);
|
||||
}
|
||||
|
||||
const sync = (): void => {
|
||||
group.setAttribute('aria-label', t('language'));
|
||||
const active = getLocale();
|
||||
|
||||
for (const [locale, button] of buttons) {
|
||||
button.setAttribute('aria-pressed', locale === active ? 'true' : 'false');
|
||||
}
|
||||
};
|
||||
|
||||
onLocaleChange(sync);
|
||||
sync();
|
||||
|
||||
return group;
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
type School,
|
||||
type SchoolsResponse,
|
||||
} from '../net/api.ts';
|
||||
import { getLocale } from '../i18n/locale.ts';
|
||||
import { schoolWord, t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
import { confirmDialog } from './confirmDialog.ts';
|
||||
import { createSchoolDialog } from './createSchoolDialog.ts';
|
||||
@@ -26,12 +28,12 @@ const REFRESH_INTERVAL_MS = 1000;
|
||||
|
||||
export class MainMenu {
|
||||
private readonly root = el('section', { class: 'screen menu' });
|
||||
private readonly title = el('h1', { class: 'screen__title' });
|
||||
private readonly grid = el('div', { class: 'card-grid' });
|
||||
private readonly emptyHint = el('p', { class: 'hint', text: 'Пока ни одной школы. Создайте первую.' });
|
||||
private readonly emptyHint = el('p', { class: 'hint' });
|
||||
private readonly createButton = el('button', {
|
||||
class: 'button button--primary',
|
||||
type: 'button',
|
||||
text: 'Создать школу',
|
||||
});
|
||||
|
||||
private readonly limitHint = el('p', { class: 'hint' });
|
||||
@@ -41,6 +43,7 @@ export class MainMenu {
|
||||
private state: SchoolsResponse | null = null;
|
||||
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private busy = false;
|
||||
private error: { key: 'loadSchoolsFailed' } | { key: 'deleteFailed'; name: string } | null = null;
|
||||
|
||||
constructor(private readonly options: MainMenuOptions) {
|
||||
this.createButton.addEventListener('click', () => void this.openCreateDialog());
|
||||
@@ -50,7 +53,7 @@ export class MainMenu {
|
||||
el(
|
||||
'header',
|
||||
{ class: 'screen__header' },
|
||||
el('h1', { class: 'screen__title', text: 'Школы' }),
|
||||
this.title,
|
||||
el('div', { class: 'screen__actions' }, this.createButton),
|
||||
),
|
||||
this.limitHint,
|
||||
@@ -58,12 +61,28 @@ export class MainMenu {
|
||||
this.emptyHint,
|
||||
this.grid,
|
||||
);
|
||||
|
||||
this.localize();
|
||||
}
|
||||
|
||||
get element(): HTMLElement {
|
||||
return this.root;
|
||||
}
|
||||
|
||||
/** Re-applies strings after a language switch. Cards stay in place so focus is not dropped. */
|
||||
localize(): void {
|
||||
this.title.textContent = t('schoolsTitle');
|
||||
this.createButton.textContent = t('createSchool');
|
||||
this.emptyHint.textContent = t('emptySchools');
|
||||
|
||||
for (const card of this.cards.values()) {
|
||||
card.localize();
|
||||
}
|
||||
|
||||
this.paintError();
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** Shows the menu: loads the list once, then keeps it fresh. */
|
||||
start(): void {
|
||||
void this.refresh();
|
||||
@@ -86,16 +105,30 @@ export class MainMenu {
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
this.state = await fetchSchools();
|
||||
this.status.hidden = true;
|
||||
this.error = null;
|
||||
} catch {
|
||||
this.status.textContent = 'Не удалось загрузить список школ. Проверьте соединение с сервером.';
|
||||
this.status.hidden = false;
|
||||
this.error = { key: 'loadSchoolsFailed' };
|
||||
this.paintError();
|
||||
return;
|
||||
}
|
||||
|
||||
this.paintError();
|
||||
this.render();
|
||||
}
|
||||
|
||||
private paintError(): void {
|
||||
if (this.error === null) {
|
||||
this.status.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.status.textContent =
|
||||
this.error.key === 'loadSchoolsFailed'
|
||||
? t('loadSchoolsFailed')
|
||||
: t('deleteFailed', { name: this.error.name });
|
||||
this.status.hidden = false;
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const state = this.state;
|
||||
if (state === null) {
|
||||
@@ -104,11 +137,11 @@ export class MainMenu {
|
||||
|
||||
const atLimit = state.schools.length >= state.maxSchools;
|
||||
this.createButton.toggleAttribute('disabled', atLimit || this.busy);
|
||||
this.createButton.title = atLimit ? 'Удалите одну из школ, чтобы создать новую' : '';
|
||||
this.createButton.title = atLimit ? t('createDisabledTitle') : '';
|
||||
|
||||
this.limitHint.textContent = atLimit
|
||||
? `Достигнут лимит: ${state.maxSchools} ${plural(state.maxSchools)}. Удалите одну, чтобы создать новую.`
|
||||
: `Школ: ${state.schools.length} из ${state.maxSchools}.`;
|
||||
? t('schoolLimitReached', { max: state.maxSchools, schoolWord: schoolWord(state.maxSchools) })
|
||||
: t('schoolCount', { current: state.schools.length, max: state.maxSchools });
|
||||
|
||||
this.emptyHint.hidden = state.schools.length > 0;
|
||||
|
||||
@@ -141,9 +174,9 @@ export class MainMenu {
|
||||
|
||||
private async confirmDelete(school: School): Promise<void> {
|
||||
const confirmed = await confirmDialog({
|
||||
title: 'Удалить школу?',
|
||||
message: `«${school.name}» будет удалена без возможности восстановления.`,
|
||||
confirmLabel: 'Удалить',
|
||||
title: t('deleteSchoolTitle'),
|
||||
message: t('deleteSchoolMessage', { name: school.name }),
|
||||
confirmLabel: t('deleteSchool'),
|
||||
danger: true,
|
||||
});
|
||||
|
||||
@@ -154,8 +187,7 @@ export class MainMenu {
|
||||
try {
|
||||
await deleteSchool(school.id);
|
||||
} catch {
|
||||
this.status.textContent = `Не удалось удалить «${school.name}».`;
|
||||
this.status.hidden = false;
|
||||
this.error = { key: 'deleteFailed', name: school.name };
|
||||
}
|
||||
|
||||
await this.refresh();
|
||||
@@ -171,7 +203,7 @@ export class MainMenu {
|
||||
try {
|
||||
await createSchoolDialog({
|
||||
defaultStartDate: new Date(state.defaultStartDate),
|
||||
suggestName: fetchRandomName,
|
||||
suggestName: () => fetchRandomName(getLocale()),
|
||||
create: createSchool,
|
||||
});
|
||||
} finally {
|
||||
@@ -181,12 +213,3 @@ export class MainMenu {
|
||||
await this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function plural(count: number): string {
|
||||
const remainderTen = count % 10;
|
||||
const remainderHundred = count % 100;
|
||||
|
||||
if (remainderTen === 1 && remainderHundred !== 11) return 'школа';
|
||||
if (remainderTen >= 2 && remainderTen <= 4 && (remainderHundred < 12 || remainderHundred > 14)) return 'школы';
|
||||
return 'школ';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { School } from '../net/api.ts';
|
||||
import { formatGameDateTime } from '../format/gameTime.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
|
||||
interface SchoolCardOptions {
|
||||
@@ -16,7 +17,11 @@ export class SchoolCard {
|
||||
|
||||
private readonly title = el('h2', { class: 'card__title' });
|
||||
private readonly time = el('p', { class: 'card__time' });
|
||||
private readonly pausedBadge = el('span', { class: 'card__badge', text: '⏸ на паузе' });
|
||||
private readonly pausedBadge = el('span', { class: 'card__badge' });
|
||||
private readonly deleteButton = el('button', {
|
||||
class: 'button button--danger button--small',
|
||||
type: 'button',
|
||||
});
|
||||
|
||||
private school: School;
|
||||
|
||||
@@ -25,23 +30,16 @@ export class SchoolCard {
|
||||
|
||||
this.element.dataset['schoolId'] = String(school.id);
|
||||
this.element.tabIndex = 0;
|
||||
this.deleteButton.addEventListener('click', (event) => {
|
||||
// The whole card is clickable, so the delete button must not open the school too.
|
||||
event.stopPropagation();
|
||||
options.onDelete(this.school);
|
||||
});
|
||||
|
||||
this.element.append(
|
||||
this.title,
|
||||
el('div', { class: 'card__meta' }, this.time, this.pausedBadge),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'card__actions' },
|
||||
el('button', {
|
||||
class: 'button button--danger button--small',
|
||||
type: 'button',
|
||||
text: 'Удалить',
|
||||
onClick: (event) => {
|
||||
// The whole card is clickable, so the delete button must not open the school too.
|
||||
event.stopPropagation();
|
||||
options.onDelete(this.school);
|
||||
},
|
||||
}),
|
||||
),
|
||||
el('div', { class: 'card__actions' }, this.deleteButton),
|
||||
);
|
||||
|
||||
this.element.addEventListener('click', () => options.onOpen(this.school));
|
||||
@@ -52,9 +50,16 @@ export class SchoolCard {
|
||||
}
|
||||
});
|
||||
|
||||
this.localize();
|
||||
this.update(school);
|
||||
}
|
||||
|
||||
localize(): void {
|
||||
this.deleteButton.textContent = t('deleteSchool');
|
||||
this.pausedBadge.textContent = t('paused');
|
||||
this.update(this.school);
|
||||
}
|
||||
|
||||
update(school: School): void {
|
||||
this.school = school;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user