Refactor UI components for people management by introducing tabbed navigation between map and people views. Enhance styling for better layout and responsiveness, ensuring a clear distinction between the two panels. Update localization strings to include new titles for the people tab. Improve the people panel functionality to support selection events, allowing for a seamless user experience when inspecting individuals. Revise related styles to maintain consistency across the UI.
ci / server (push) Failing after 10s
ci / client (push) Successful in 14s

This commit is contained in:
Leonid Pershin
2026-08-18 21:31:12 +03:00
parent 3366e8e44b
commit d8f8db6a48
4 changed files with 162 additions and 105 deletions
+2
View File
@@ -74,6 +74,7 @@ const ru = {
eventsTitle: 'События', eventsTitle: 'События',
eventsEmpty: 'Пока ничего не происходит.', eventsEmpty: 'Пока ничего не происходит.',
locationName: 'Локация', locationName: 'Локация',
personTitle: 'Человек',
locationItems: 'Предметы', locationItems: 'Предметы',
locationCharacters: 'Персонажи', locationCharacters: 'Персонажи',
locationActivities: 'Сейчас', locationActivities: 'Сейчас',
@@ -201,6 +202,7 @@ const en: Messages = {
eventsTitle: 'Events', eventsTitle: 'Events',
eventsEmpty: 'Nothing is happening yet.', eventsEmpty: 'Nothing is happening yet.',
locationName: 'Location', locationName: 'Location',
personTitle: 'Person',
locationItems: 'Items', locationItems: 'Items',
locationCharacters: 'People', locationCharacters: 'People',
locationActivities: 'Now', locationActivities: 'Now',
+65 -32
View File
@@ -18,6 +18,14 @@
box-sizing: border-box; box-sizing: border-box;
} }
/*
* `hidden` is a UA rule, so any author `display` beats it — a flex panel body stayed on screen
* with the attribute set. Everything here toggles visibility through `hidden`, so it has to win.
*/
[hidden] {
display: none !important;
}
body { body {
margin: 0; margin: 0;
min-height: 100dvh; min-height: 100dvh;
@@ -29,9 +37,13 @@ body {
* as a half-loaded document rather than a game, so every screen is a full-height flex column and * as a half-loaded document rather than a game, so every screen is a full-height flex column and
* decides its own comfortable width. * decides its own comfortable width.
*/ */
/*
* A fixed height, not just a minimum: with only a minimum the shell grows to whatever its tallest
* panel wants, and a table of four hundred people made the page twice the window.
*/
#app { #app {
display: flex; display: flex;
min-height: 100dvh; height: 100dvh;
padding: 24px 24px 52px; padding: 24px 24px 52px;
} }
@@ -244,21 +256,18 @@ body {
/* Manager */ /* Manager */
.shell { /*
display: flex; * Two panels, both full height. Left is a list — the map tree or the people table, whichever tab
flex-direction: column; * is up. Right inspects whatever was picked last, a room or a person. Nothing lives below the
* fold: the people list used to sit in a bottom row that had to be scrolled to.
*/
.manager {
display: grid;
grid-template-columns: minmax(340px, 1.05fr) minmax(320px, 1fr);
gap: 16px;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
margin-top: 16px; margin-top: 16px;
gap: 16px;
}
.manager {
display: grid;
grid-template-columns: minmax(210px, 0.85fr) minmax(240px, 1fr) minmax(280px, 1.25fr);
gap: 16px;
flex: 1;
min-height: 0;
} }
.panel { .panel {
@@ -283,6 +292,36 @@ body {
text-transform: uppercase; text-transform: uppercase;
} }
/* A tab strip sits where a title would, and reads like one until you pick the other tab. */
.panel__tabs {
display: flex;
flex: 0 0 auto;
border-bottom: 1px solid var(--border);
}
.panel__tab {
padding: 11px 16px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
}
.panel__tab:hover {
color: var(--text);
}
.panel__tab--active {
border-bottom-color: var(--accent);
color: var(--accent);
}
/* The body scrolls, the title stays: a long tree must not push the panel out of the window. */ /* The body scrolls, the title stays: a long tree must not push the panel out of the window. */
.panel__body { .panel__body {
flex: 1; flex: 1;
@@ -365,18 +404,19 @@ body {
color: var(--accent); color: var(--accent);
} }
.panel--people { /* The table scrolls, not the panel body around it — otherwise the pager scrolls away with it. */
flex: 0 1 38vh; .panel__body--fill {
min-height: 220px; display: flex;
max-height: 42vh; overflow: hidden;
} }
.people { .people {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
gap: 10px; gap: 10px;
min-width: 0;
min-height: 0; min-height: 0;
height: 100%;
} }
.people__toolbar { .people__toolbar {
@@ -403,17 +443,10 @@ body {
font-size: 13px; font-size: 13px;
} }
.people__main {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(240px, 0.42fr);
gap: 12px;
flex: 1;
min-height: 0;
}
.people__list { .people__list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
} }
@@ -530,7 +563,8 @@ body {
/* Below three columns the panels stop competing for height and the page scrolls instead. */ /* Below three columns the panels stop competing for height and the page scrolls instead. */
@media (max-width: 900px) { @media (max-width: 900px) {
#app { #app {
min-height: 0; height: auto;
min-height: 100dvh;
} }
.manager { .manager {
@@ -543,13 +577,12 @@ body {
overflow: visible; overflow: visible;
} }
.panel--people { .panel__body--fill {
flex: 0 0 auto; overflow: visible;
max-height: none;
} }
.people__main { .people__table-wrap {
grid-template-columns: 1fr; overflow-x: auto;
} }
} }
+58 -39
View File
@@ -28,11 +28,13 @@ export class GameScreen {
private readonly playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' }); private readonly playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' });
private readonly speedButtons: HTMLButtonElement[]; private readonly speedButtons: HTMLButtonElement[];
private readonly mapTitle = el('h2', { class: 'panel__title' }); private readonly mapTab = el('button', { class: 'panel__tab', type: 'button' });
private readonly peopleTab = el('button', { class: 'panel__tab', type: 'button' });
private readonly tree = el('ul', { class: 'tree' }); private readonly tree = el('ul', { class: 'tree' });
private readonly eventsTitle = el('h2', { class: 'panel__title' }); private readonly mapBody = el('div', { class: 'panel__body' });
private readonly eventsEmpty = el('p', { class: 'panel__empty' }); private readonly peopleBody = el('div', { class: 'panel__body panel__body--fill' });
private readonly locationTitle = el('h2', { class: 'panel__title' }); private readonly inspectTitle = el('h2', { class: 'panel__title' });
private readonly locationBody = el('div', { class: 'panel__body' });
private readonly locationName = el('p', { class: 'panel__name' }); private readonly locationName = el('p', { class: 'panel__name' });
private readonly itemsHeading = el('h3', { class: 'panel__section-title' }); private readonly itemsHeading = el('h3', { class: 'panel__section-title' });
private readonly itemsEmpty = el('p', { class: 'panel__empty' }); private readonly itemsEmpty = el('p', { class: 'panel__empty' });
@@ -46,7 +48,7 @@ export class GameScreen {
private readonly positionsEmpty = el('p', { class: 'panel__empty' }); private readonly positionsEmpty = el('p', { class: 'panel__empty' });
private readonly positionsList = el('ul', { class: 'panel__list' }); private readonly positionsList = el('ul', { class: 'panel__list' });
private readonly people = new PeoplePanel(); private readonly people = new PeoplePanel({ onSelect: () => this.inspect('person') });
private readonly treeButtons = new Map<string, HTMLButtonElement>(); private readonly treeButtons = new Map<string, HTMLButtonElement>();
private nodes: readonly MapSnapshotNode[] = []; private nodes: readonly MapSnapshotNode[] = [];
@@ -55,6 +57,7 @@ export class GameScreen {
private running = false; private running = false;
private lastGameTime: Date | null = null; private lastGameTime: Date | null = null;
private lastSpeedIndex = 0; private lastSpeedIndex = 0;
private inspected: 'location' | 'person' = 'location';
constructor(options: GameScreenOptions) { constructor(options: GameScreenOptions) {
this.speedButtons = CLOCK_SPEEDS.map((_, index) => this.speedButtons = CLOCK_SPEEDS.map((_, index) =>
@@ -84,41 +87,33 @@ export class GameScreen {
), ),
el( el(
'div', 'div',
{ class: 'shell' }, { class: 'manager' },
el( el(
'div', 'section',
{ class: 'manager' }, { class: 'panel' },
el( el('div', { class: 'panel__tabs' }, this.mapTab, this.peopleTab),
'section', this.mapBody,
{ class: 'panel' }, this.peopleBody,
this.mapTitle,
el('div', { class: 'panel__body' }, this.tree),
),
el(
'section',
{ class: 'panel' },
this.eventsTitle,
el('div', { class: 'panel__body' }, this.eventsEmpty),
),
el(
'section',
{ class: 'panel' },
this.locationTitle,
el(
'div',
{ class: 'panel__body' },
this.locationName,
el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty, this.itemsList, this.pupilSlotsLine),
el('div', { class: 'panel__section' }, this.charactersHeading, this.charactersEmpty),
el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty),
el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList),
),
),
), ),
this.people.element, el('section', { class: 'panel' }, this.inspectTitle, this.locationBody, this.people.cardElement),
), ),
); );
this.mapBody.append(this.tree);
this.peopleBody.append(this.people.listElement);
this.locationBody.append(
this.locationName,
el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty, this.itemsList, this.pupilSlotsLine),
el('div', { class: 'panel__section' }, this.charactersHeading, this.charactersEmpty),
el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty),
el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList),
);
this.mapTab.addEventListener('click', () => this.showTab('map'));
this.peopleTab.addEventListener('click', () => this.showTab('people'));
this.showTab('map');
this.inspect('location');
this.localize(); this.localize();
} }
@@ -128,10 +123,9 @@ export class GameScreen {
localize(): void { localize(): void {
this.backButton.textContent = t('backToMenu'); this.backButton.textContent = t('backToMenu');
this.mapTitle.textContent = t('mapTitle'); this.mapTab.textContent = t('mapTitle');
this.eventsTitle.textContent = t('eventsTitle'); this.peopleTab.textContent = t('peopleTitle');
this.eventsEmpty.textContent = t('eventsEmpty'); this.paintInspectTitle();
this.locationTitle.textContent = t('locationName');
this.itemsHeading.textContent = t('locationItems'); this.itemsHeading.textContent = t('locationItems');
this.itemsEmpty.textContent = t('itemsEmpty'); this.itemsEmpty.textContent = t('itemsEmpty');
this.charactersHeading.textContent = t('locationCharacters'); this.charactersHeading.textContent = t('locationCharacters');
@@ -160,6 +154,8 @@ export class GameScreen {
this.rebuildTree(); this.rebuildTree();
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex); this.applyClock(new Date(school.gameTime), school.running, school.speedIndex);
this.people.show(school.id); this.people.show(school.id);
this.showTab('map');
this.inspect('location');
} }
applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void { applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void {
@@ -211,9 +207,32 @@ export class GameScreen {
private select(id: string): void { private select(id: string): void {
this.selectedId = id; this.selectedId = id;
this.inspect('location');
this.paintSelection(); this.paintSelection();
} }
private showTab(tab: 'map' | 'people'): void {
this.mapTab.classList.toggle('panel__tab--active', tab === 'map');
this.peopleTab.classList.toggle('panel__tab--active', tab === 'people');
this.mapBody.hidden = tab !== 'map';
this.peopleBody.hidden = tab !== 'people';
}
/**
* The middle panel follows the last thing picked, whichever list it came from. Switching tabs
* on the left does not change it — the map tab is often just a way to find the next room.
*/
private inspect(what: 'location' | 'person'): void {
this.inspected = what;
this.locationBody.hidden = what !== 'location';
this.people.cardElement.hidden = what !== 'person';
this.paintInspectTitle();
}
private paintInspectTitle(): void {
this.inspectTitle.textContent = this.inspected === 'person' ? t('personTitle') : t('locationName');
}
private paintSelection(): void { private paintSelection(): void {
for (const [id, button] of this.treeButtons) { for (const [id, button] of this.treeButtons) {
button.classList.toggle('tree__button--active', id === this.selectedId); button.classList.toggle('tree__button--active', id === this.selectedId);
+37 -34
View File
@@ -24,14 +24,22 @@ const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [
{ sort: 'age', label: 'peopleColAge' }, { sort: 'age', label: 'peopleColAge' },
]; ];
interface PeoplePanelOptions {
/** Somebody was picked, so the inspector has to stop showing a location. */
readonly onSelect: () => void;
}
/** /**
* Bottom row of the manager shell: filters, a page of people, and one open card. * The people list and the card of whoever is picked. They live in two different panels — the list
* Fetches over HTTP; never ticks locally. * on the left next to the map tree, the card in the middle inspector — so this owns two elements
* rather than one. Fetches over HTTP; never ticks locally.
*/ */
export class PeoplePanel { export class PeoplePanel {
readonly element: HTMLElement; /** Filters, the table and the pager. Goes under the "people" tab of the left panel. */
readonly listElement: HTMLElement;
private readonly title = el('h2', { class: 'panel__title' }); /** The open card. Goes into the inspector in the middle. */
readonly cardElement: HTMLElement;
private readonly roleSelect = el('select', { class: 'input people__input' }); private readonly roleSelect = el('select', { class: 'input people__input' });
private readonly yearSelect = el('select', { class: 'input people__input' }); private readonly yearSelect = el('select', { class: 'input people__input' });
private readonly letterSelect = el('select', { class: 'input people__input' }); private readonly letterSelect = el('select', { class: 'input people__input' });
@@ -53,7 +61,7 @@ export class PeoplePanel {
private readonly pagerLabel = el('span', { class: 'people__pager-label' }); private readonly pagerLabel = el('span', { class: 'people__pager-label' });
private readonly prevButton = el('button', { class: 'button button--small', type: 'button' }); private readonly prevButton = el('button', { class: 'button button--small', type: 'button' });
private readonly nextButton = el('button', { class: 'button button--small', type: 'button' }); private readonly nextButton = el('button', { class: 'button button--small', type: 'button' });
private readonly card = el('aside', { class: 'people__card' }); private readonly card = el('aside', { class: 'panel__body people__card' });
private schoolId: number | null = null; private schoolId: number | null = null;
private sort: PersonSort = 'surname'; private sort: PersonSort = 'surname';
@@ -63,7 +71,7 @@ export class PeoplePanel {
private token = 0; private token = 0;
private cardToken = 0; private cardToken = 0;
constructor() { constructor(private readonly options: PeoplePanelOptions) {
this.ageMinInput.min = '0'; this.ageMinInput.min = '0';
this.ageMaxInput.min = '0'; this.ageMaxInput.min = '0';
this.table.append(this.thead, this.tbody); this.table.append(this.thead, this.tbody);
@@ -86,43 +94,34 @@ export class PeoplePanel {
void this.reload(); void this.reload();
}); });
this.element = el( this.listElement = el(
'section', 'div',
{ class: 'panel panel--people' }, { class: 'people' },
this.title,
el( el(
'div', 'div',
{ class: 'panel__body people' }, { class: 'people__toolbar' },
el( field(this.roleLabel, this.roleSelect),
'div', field(this.yearLabel, this.yearSelect),
{ class: 'people__toolbar' }, field(this.letterLabel, this.letterSelect),
field(this.roleLabel, this.roleSelect), field(this.positionLabel, this.positionSelect),
field(this.yearLabel, this.yearSelect), field(this.sexLabel, this.sexSelect),
field(this.letterLabel, this.letterSelect), field(this.ageFromLabel, this.ageMinInput),
field(this.positionLabel, this.positionSelect), field(this.ageToLabel, this.ageMaxInput),
field(this.sexLabel, this.sexSelect), ),
field(this.ageFromLabel, this.ageMinInput), el(
field(this.ageToLabel, this.ageMaxInput), 'div',
), { class: 'people__list' },
el( el('div', { class: 'people__table-wrap' }, this.table, this.empty),
'div', el('div', { class: 'people__pager' }, this.prevButton, this.pagerLabel, this.nextButton),
{ class: 'people__main' },
el(
'div',
{ class: 'people__list' },
el('div', { class: 'people__table-wrap' }, this.table, this.empty),
el('div', { class: 'people__pager' }, this.prevButton, this.pagerLabel, this.nextButton),
),
this.card,
),
), ),
); );
this.cardElement = this.card;
this.localize(); this.localize();
} }
localize(): void { localize(): void {
this.title.textContent = t('peopleTitle');
this.roleLabel.textContent = t('peopleRole'); this.roleLabel.textContent = t('peopleRole');
this.yearLabel.textContent = t('peopleYear'); this.yearLabel.textContent = t('peopleYear');
this.letterLabel.textContent = t('peopleLetter'); this.letterLabel.textContent = t('peopleLetter');
@@ -296,6 +295,7 @@ export class PeoplePanel {
row.addEventListener('click', () => { row.addEventListener('click', () => {
this.selectedId = person.id; this.selectedId = person.id;
this.highlightSelection(); this.highlightSelection();
this.options.onSelect();
void this.openCard(person.id); void this.openCard(person.id);
}); });
row.append( row.append(
@@ -327,6 +327,9 @@ export class PeoplePanel {
return; return;
} }
// Also covers the links inside a card: opening a relative is picking somebody too.
this.options.onSelect();
const token = ++this.cardToken; const token = ++this.cardToken;
try { try {
const card = await fetchPerson(schoolId, personId, getLocale()); const card = await fetchPerson(schoolId, personId, getLocale());