WIP phase 39: school owners, my/others menu, guest restrictions.
This commit is contained in:
@@ -23,6 +23,10 @@ const ru = {
|
||||
sessionLogout: 'Выйти',
|
||||
|
||||
schoolsTitle: 'Школы',
|
||||
schoolsMine: 'Мои',
|
||||
schoolsOthers: 'Чужие',
|
||||
schoolOwner: 'Хозяин: {owner}',
|
||||
schoolOwnerless: '—',
|
||||
createSchool: 'Создать школу',
|
||||
settings: 'Настройки',
|
||||
save: 'Сохранить',
|
||||
@@ -365,6 +369,10 @@ const en: Messages = {
|
||||
sessionLogout: 'Sign out',
|
||||
|
||||
schoolsTitle: 'Schools',
|
||||
schoolsMine: 'Mine',
|
||||
schoolsOthers: 'Others',
|
||||
schoolOwner: 'Owner: {owner}',
|
||||
schoolOwnerless: '—',
|
||||
createSchool: 'Create school',
|
||||
settings: 'Settings',
|
||||
save: 'Save',
|
||||
|
||||
@@ -13,14 +13,29 @@ export interface School {
|
||||
readonly modIds?: readonly string[];
|
||||
/** Roster generator seed. Independent of `id`; share it to recreate the same people. */
|
||||
readonly seed: number;
|
||||
readonly mine: boolean;
|
||||
}
|
||||
|
||||
export interface OtherSchool {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly gameTime: string;
|
||||
readonly running: boolean;
|
||||
readonly speedIndex: number;
|
||||
readonly modIds?: readonly string[];
|
||||
readonly seed: number;
|
||||
/** Display name of the owner, or null when ownerless. */
|
||||
readonly owner: string | null;
|
||||
}
|
||||
|
||||
export interface SchoolsResponse {
|
||||
readonly maxSchools: number;
|
||||
readonly maxSchoolsTotal: number;
|
||||
readonly defaultStartDate: string;
|
||||
readonly gameMinutesPerRealSecond: number;
|
||||
readonly schoolWeekDays: number;
|
||||
readonly schools: readonly School[];
|
||||
readonly others: readonly OtherSchool[];
|
||||
}
|
||||
|
||||
/** A failed request, with the machine-readable `code` the server puts in its problem details. */
|
||||
|
||||
@@ -14,6 +14,8 @@ interface ElementOptions {
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
rows?: number;
|
||||
autocomplete?: string;
|
||||
maxlength?: string;
|
||||
dataset?: Record<string, string>;
|
||||
onClick?: (event: Event) => void;
|
||||
onInput?: (event: Event) => void;
|
||||
@@ -44,6 +46,14 @@ export function el<K extends keyof HTMLElementTagNameMap>(
|
||||
(element as HTMLInputElement | HTMLTextAreaElement).placeholder = options.placeholder;
|
||||
}
|
||||
|
||||
if (options.autocomplete !== undefined && 'autocomplete' in element) {
|
||||
(element as HTMLInputElement).autocomplete = options.autocomplete;
|
||||
}
|
||||
|
||||
if (options.maxlength !== undefined && 'maxLength' in element) {
|
||||
(element as HTMLInputElement).maxLength = Number.parseInt(options.maxlength, 10);
|
||||
}
|
||||
|
||||
if (options.value !== undefined && 'value' in element) {
|
||||
(element as HTMLInputElement | HTMLTextAreaElement).value = options.value;
|
||||
}
|
||||
|
||||
@@ -22,4 +22,28 @@ describe('GameScreen speed buttons', () => {
|
||||
|
||||
expect(labels).toEqual(['×½', '×1', '×2', '×5', '×10']);
|
||||
});
|
||||
|
||||
it('hides management and clock controls for a guest school', () => {
|
||||
const screen = new GameScreen({
|
||||
onLeave: () => {},
|
||||
onSetRunning: () => {},
|
||||
onSetSpeed: () => {},
|
||||
onSkip: () => {},
|
||||
});
|
||||
|
||||
document.body.append(screen.element);
|
||||
screen.show({
|
||||
id: 3,
|
||||
name: 'Foreign',
|
||||
gameTime: '2012-03-31T06:00:00.000Z',
|
||||
running: true,
|
||||
speedIndex: 1,
|
||||
seed: 9,
|
||||
mine: false,
|
||||
});
|
||||
|
||||
const manageTab = screen.element.querySelectorAll<HTMLElement>('.mode-tab')[1];
|
||||
expect(manageTab?.hidden).toBe(true);
|
||||
expect(screen.element.querySelector('.clock__controls')?.hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ export class GameScreen {
|
||||
private directoryToken = 0;
|
||||
private selectedId: string | null = null;
|
||||
private schoolId: number | null = null;
|
||||
private canManage = true;
|
||||
private peopleSeed: number | null = null;
|
||||
private running = false;
|
||||
private lastGameTime: Date | null = null;
|
||||
@@ -209,6 +210,17 @@ export class GameScreen {
|
||||
|
||||
/** Called when the screen opens, before the first clock frame and snapshot arrive. */
|
||||
show(school: School): void {
|
||||
this.canManage = school.mine;
|
||||
this.manageTab.hidden = !this.canManage;
|
||||
if (!this.canManage) {
|
||||
this.showMode('overview');
|
||||
}
|
||||
|
||||
const clockControls = this.root.querySelector<HTMLElement>('.clock__controls');
|
||||
if (clockControls !== null) {
|
||||
clockControls.hidden = !this.canManage;
|
||||
}
|
||||
|
||||
this.schoolId = school.id;
|
||||
this.peopleSeed = school.seed;
|
||||
this.schoolName.textContent = school.name;
|
||||
@@ -311,6 +323,10 @@ export class GameScreen {
|
||||
}
|
||||
|
||||
private showMode(mode: 'overview' | 'manage'): void {
|
||||
if (mode === 'manage' && !this.canManage) {
|
||||
mode = 'overview';
|
||||
}
|
||||
|
||||
this.overviewTab.classList.toggle('mode-tab--active', mode === 'overview');
|
||||
this.manageTab.classList.toggle('mode-tab--active', mode === 'manage');
|
||||
this.overview.hidden = mode !== 'overview';
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
deleteSchool,
|
||||
fetchRandomName,
|
||||
fetchSchools,
|
||||
type OtherSchool,
|
||||
type School,
|
||||
type SchoolsResponse,
|
||||
} from '../net/api.ts';
|
||||
@@ -12,7 +13,7 @@ import { el } from './dom.ts';
|
||||
import { confirmDialog } from './confirmDialog.ts';
|
||||
import { createSchoolDialog } from './createSchoolDialog.ts';
|
||||
import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts';
|
||||
import { SchoolCard } from './schoolCard.ts';
|
||||
import { SchoolCard, type MenuSchool } from './schoolCard.ts';
|
||||
|
||||
interface MainMenuOptions {
|
||||
readonly onOpenSchool: (school: School) => void;
|
||||
@@ -28,10 +29,18 @@ interface MainMenuOptions {
|
||||
*/
|
||||
const REFRESH_INTERVAL_MS = 1000;
|
||||
|
||||
interface CardEntry {
|
||||
readonly card: SchoolCard;
|
||||
readonly section: 'mine' | 'other';
|
||||
}
|
||||
|
||||
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 mineHeading = el('h2', { class: 'menu__heading' });
|
||||
private readonly mineGrid = el('div', { class: 'card-grid' });
|
||||
private readonly othersHeading = el('h2', { class: 'menu__heading' });
|
||||
private readonly othersGrid = el('div', { class: 'card-grid' });
|
||||
private readonly emptyHint = el('p', { class: 'hint' });
|
||||
private readonly createButton = el('button', {
|
||||
class: 'button button--primary',
|
||||
@@ -48,7 +57,7 @@ export class MainMenu {
|
||||
|
||||
private readonly limitHint = el('p', { class: 'hint' });
|
||||
private readonly status = el('p', { class: 'hint hint--error' });
|
||||
private readonly cards = new Map<number, SchoolCard>();
|
||||
private readonly cards = new Map<number, CardEntry>();
|
||||
|
||||
private state: SchoolsResponse | null = null;
|
||||
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -71,7 +80,10 @@ export class MainMenu {
|
||||
this.limitHint,
|
||||
this.status,
|
||||
this.emptyHint,
|
||||
this.grid,
|
||||
this.mineHeading,
|
||||
this.mineGrid,
|
||||
this.othersHeading,
|
||||
this.othersGrid,
|
||||
);
|
||||
|
||||
this.localize();
|
||||
@@ -84,13 +96,15 @@ export class MainMenu {
|
||||
/** Re-applies strings after a language switch. Cards stay in place so focus is not dropped. */
|
||||
localize(): void {
|
||||
this.title.textContent = t('schoolsTitle');
|
||||
this.mineHeading.textContent = t('schoolsMine');
|
||||
this.othersHeading.textContent = t('schoolsOthers');
|
||||
this.createButton.textContent = t('createSchool');
|
||||
this.settingsButton.textContent = t('settings');
|
||||
this.logoutButton.textContent = t('sessionLogout');
|
||||
this.emptyHint.textContent = t('emptySchools');
|
||||
|
||||
for (const card of this.cards.values()) {
|
||||
card.localize();
|
||||
for (const entry of this.cards.values()) {
|
||||
entry.card.localize();
|
||||
}
|
||||
|
||||
this.paintError();
|
||||
@@ -103,8 +117,6 @@ export class MainMenu {
|
||||
|
||||
this.stop();
|
||||
|
||||
// No visibility check here: browsers already throttle timers in background tabs, and a
|
||||
// hidden-tab guard silently freezes the list in embedded views that report themselves hidden.
|
||||
this.refreshTimer = setInterval(() => void this.refresh(), REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
@@ -158,35 +170,67 @@ export class MainMenu {
|
||||
: t('schoolCount', { current: state.schools.length, max: state.maxSchools });
|
||||
|
||||
this.emptyHint.hidden = state.schools.length > 0;
|
||||
this.othersHeading.hidden = state.others.length === 0;
|
||||
this.othersGrid.hidden = state.others.length === 0;
|
||||
|
||||
// Patch the cards that are already on screen; only added and removed schools touch the DOM.
|
||||
const seen = new Set<number>();
|
||||
|
||||
for (const school of state.schools) {
|
||||
seen.add(school.id);
|
||||
|
||||
const card = this.cards.get(school.id);
|
||||
if (card === undefined) {
|
||||
const created = new SchoolCard(school, {
|
||||
onOpen: (opened) => this.options.onOpenSchool(opened),
|
||||
onDelete: (target) => void this.confirmDelete(target),
|
||||
});
|
||||
|
||||
this.cards.set(school.id, created);
|
||||
this.grid.appendChild(created.element);
|
||||
} else {
|
||||
card.update(school);
|
||||
}
|
||||
this.patchCard(school, 'mine', this.mineGrid, {
|
||||
canDelete: true,
|
||||
ownerLabel: null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [id, card] of this.cards) {
|
||||
for (const other of state.others) {
|
||||
seen.add(other.id);
|
||||
const menuSchool: MenuSchool = { ...other, mine: false };
|
||||
this.patchCard(menuSchool, 'other', this.othersGrid, {
|
||||
canDelete: other.owner === null,
|
||||
ownerLabel: other.owner ?? t('schoolOwnerless'),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [id, entry] of this.cards) {
|
||||
if (!seen.has(id)) {
|
||||
card.element.remove();
|
||||
entry.card.element.remove();
|
||||
this.cards.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async confirmDelete(school: School): Promise<void> {
|
||||
private patchCard(
|
||||
school: MenuSchool,
|
||||
section: 'mine' | 'other',
|
||||
grid: HTMLElement,
|
||||
options: { canDelete: boolean; ownerLabel: string | null },
|
||||
): void {
|
||||
const entry = this.cards.get(school.id);
|
||||
if (entry === undefined) {
|
||||
const card = new SchoolCard(school, {
|
||||
onOpen: (opened) => this.options.onOpenSchool(toSchool(opened)),
|
||||
onDelete: (target) => void this.confirmDelete(target),
|
||||
canDelete: options.canDelete,
|
||||
ownerLabel: options.ownerLabel,
|
||||
});
|
||||
this.cards.set(school.id, { card, section });
|
||||
grid.appendChild(card.element);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.section !== section) {
|
||||
entry.card.element.remove();
|
||||
grid.appendChild(entry.card.element);
|
||||
this.cards.set(school.id, { card: entry.card, section });
|
||||
}
|
||||
|
||||
entry.card.setOwnerLabel(options.ownerLabel);
|
||||
entry.card.setCanDelete(options.canDelete);
|
||||
entry.card.update(school);
|
||||
}
|
||||
|
||||
private async confirmDelete(school: MenuSchool): Promise<void> {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t('deleteSchoolTitle'),
|
||||
message: t('deleteSchoolMessage', { name: school.name }),
|
||||
@@ -227,3 +271,12 @@ export class MainMenu {
|
||||
await this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function toSchool(school: MenuSchool): School {
|
||||
if ('mine' in school && school.mine) {
|
||||
return school;
|
||||
}
|
||||
|
||||
const { owner: _owner, ...rest } = school as OtherSchool & { mine: false };
|
||||
return { ...rest, mine: false };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SchoolCard } from './schoolCard.ts';
|
||||
|
||||
describe('SchoolCard ownership', () => {
|
||||
it('hides delete on another owners school card', () => {
|
||||
const card = new SchoolCard(
|
||||
{
|
||||
id: 2,
|
||||
name: 'Foreign',
|
||||
gameTime: '2012-03-31T06:00:00.000Z',
|
||||
running: true,
|
||||
speedIndex: 1,
|
||||
seed: 1,
|
||||
mine: false,
|
||||
owner: 'Bob',
|
||||
},
|
||||
{
|
||||
onOpen: () => {},
|
||||
onDelete: () => {},
|
||||
canDelete: false,
|
||||
ownerLabel: 'Bob',
|
||||
},
|
||||
);
|
||||
|
||||
document.body.append(card.element);
|
||||
|
||||
expect(card.element.querySelector('.button--danger')?.hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { School } from '../net/api.ts';
|
||||
import type { OtherSchool, School } from '../net/api.ts';
|
||||
import { formatGameDateTime } from '../format/gameTime.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
|
||||
export type MenuSchool = School | (OtherSchool & { mine: false });
|
||||
|
||||
interface SchoolCardOptions {
|
||||
readonly onOpen: (school: School) => void;
|
||||
readonly onDelete: (school: School) => void;
|
||||
readonly onOpen: (school: MenuSchool) => void;
|
||||
readonly onDelete: (school: MenuSchool) => void;
|
||||
readonly canDelete: boolean;
|
||||
readonly ownerLabel?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -16,6 +20,7 @@ export class SchoolCard {
|
||||
readonly element = el('article', { class: 'card' });
|
||||
|
||||
private readonly title = el('h2', { class: 'card__title' });
|
||||
private readonly owner = el('p', { class: 'card__owner hint' });
|
||||
private readonly time = el('p', { class: 'card__time' });
|
||||
private readonly pausedBadge = el('span', { class: 'card__badge' });
|
||||
private readonly deleteButton = el('button', {
|
||||
@@ -23,13 +28,14 @@ export class SchoolCard {
|
||||
type: 'button',
|
||||
});
|
||||
|
||||
private school: School;
|
||||
private school: MenuSchool;
|
||||
|
||||
constructor(school: School, options: SchoolCardOptions) {
|
||||
constructor(school: MenuSchool, options: SchoolCardOptions) {
|
||||
this.school = school;
|
||||
|
||||
this.element.dataset['schoolId'] = String(school.id);
|
||||
this.element.tabIndex = 0;
|
||||
this.deleteButton.hidden = !options.canDelete;
|
||||
this.deleteButton.addEventListener('click', (event) => {
|
||||
// The whole card is clickable, so the delete button must not open the school too.
|
||||
event.stopPropagation();
|
||||
@@ -38,6 +44,7 @@ export class SchoolCard {
|
||||
|
||||
this.element.append(
|
||||
this.title,
|
||||
this.owner,
|
||||
el('div', { class: 'card__meta' }, this.time, this.pausedBadge),
|
||||
el('div', { class: 'card__actions' }, this.deleteButton),
|
||||
);
|
||||
@@ -50,6 +57,7 @@ export class SchoolCard {
|
||||
}
|
||||
});
|
||||
|
||||
this.setOwnerLabel(options.ownerLabel ?? null);
|
||||
this.localize();
|
||||
this.update(school);
|
||||
}
|
||||
@@ -60,11 +68,26 @@ export class SchoolCard {
|
||||
this.update(this.school);
|
||||
}
|
||||
|
||||
update(school: School): void {
|
||||
setOwnerLabel(label: string | null): void {
|
||||
if (label === null) {
|
||||
this.owner.hidden = true;
|
||||
this.owner.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.owner.hidden = false;
|
||||
this.owner.textContent = t('schoolOwner', { owner: label });
|
||||
}
|
||||
|
||||
update(school: MenuSchool): void {
|
||||
this.school = school;
|
||||
|
||||
this.title.textContent = school.name;
|
||||
this.time.textContent = formatGameDateTime(new Date(school.gameTime));
|
||||
this.pausedBadge.hidden = school.running;
|
||||
}
|
||||
|
||||
setCanDelete(canDelete: boolean): void {
|
||||
this.deleteButton.hidden = !canDelete;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user