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: 'События',
eventsEmpty: 'Пока ничего не происходит.',
locationName: 'Локация',
personTitle: 'Человек',
locationItems: 'Предметы',
locationCharacters: 'Персонажи',
locationActivities: 'Сейчас',
@@ -201,6 +202,7 @@ const en: Messages = {
eventsTitle: 'Events',
eventsEmpty: 'Nothing is happening yet.',
locationName: 'Location',
personTitle: 'Person',
locationItems: 'Items',
locationCharacters: 'People',
locationActivities: 'Now',
+65 -32
View File
@@ -18,6 +18,14 @@
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 {
margin: 0;
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
* 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 {
display: flex;
min-height: 100dvh;
height: 100dvh;
padding: 24px 24px 52px;
}
@@ -244,21 +256,18 @@ body {
/* Manager */
.shell {
display: flex;
flex-direction: column;
/*
* Two panels, both full height. Left is a list — the map tree or the people table, whichever tab
* 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;
min-height: 0;
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 {
@@ -283,6 +292,36 @@ body {
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. */
.panel__body {
flex: 1;
@@ -365,18 +404,19 @@ body {
color: var(--accent);
}
.panel--people {
flex: 0 1 38vh;
min-height: 220px;
max-height: 42vh;
/* The table scrolls, not the panel body around it — otherwise the pager scrolls away with it. */
.panel__body--fill {
display: flex;
overflow: hidden;
}
.people {
display: flex;
flex-direction: column;
flex: 1;
gap: 10px;
min-width: 0;
min-height: 0;
height: 100%;
}
.people__toolbar {
@@ -403,17 +443,10 @@ body {
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 {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
}
@@ -530,7 +563,8 @@ body {
/* Below three columns the panels stop competing for height and the page scrolls instead. */
@media (max-width: 900px) {
#app {
min-height: 0;
height: auto;
min-height: 100dvh;
}
.manager {
@@ -543,13 +577,12 @@ body {
overflow: visible;
}
.panel--people {
flex: 0 0 auto;
max-height: none;
.panel__body--fill {
overflow: visible;
}
.people__main {
grid-template-columns: 1fr;
.people__table-wrap {
overflow-x: auto;
}
}
+50 -31
View File
@@ -28,11 +28,13 @@ export class GameScreen {
private readonly playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' });
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 eventsTitle = el('h2', { class: 'panel__title' });
private readonly eventsEmpty = el('p', { class: 'panel__empty' });
private readonly locationTitle = el('h2', { class: 'panel__title' });
private readonly mapBody = el('div', { class: 'panel__body' });
private readonly peopleBody = el('div', { class: 'panel__body panel__body--fill' });
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 itemsHeading = el('h3', { class: 'panel__section-title' });
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 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 nodes: readonly MapSnapshotNode[] = [];
@@ -55,6 +57,7 @@ export class GameScreen {
private running = false;
private lastGameTime: Date | null = null;
private lastSpeedIndex = 0;
private inspected: 'location' | 'person' = 'location';
constructor(options: GameScreenOptions) {
this.speedButtons = CLOCK_SPEEDS.map((_, index) =>
@@ -82,43 +85,35 @@ export class GameScreen {
),
el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons),
),
el(
'div',
{ class: 'shell' },
el(
'div',
{ class: 'manager' },
el(
'section',
{ class: 'panel' },
this.mapTitle,
el('div', { class: 'panel__body' }, this.tree),
el('div', { class: 'panel__tabs' }, this.mapTab, this.peopleTab),
this.mapBody,
this.peopleBody,
),
el(
'section',
{ class: 'panel' },
this.eventsTitle,
el('div', { class: 'panel__body' }, this.eventsEmpty),
el('section', { class: 'panel' }, this.inspectTitle, this.locationBody, this.people.cardElement),
),
el(
'section',
{ class: 'panel' },
this.locationTitle,
el(
'div',
{ class: 'panel__body' },
);
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.people.element,
),
);
this.mapTab.addEventListener('click', () => this.showTab('map'));
this.peopleTab.addEventListener('click', () => this.showTab('people'));
this.showTab('map');
this.inspect('location');
this.localize();
}
@@ -128,10 +123,9 @@ export class GameScreen {
localize(): void {
this.backButton.textContent = t('backToMenu');
this.mapTitle.textContent = t('mapTitle');
this.eventsTitle.textContent = t('eventsTitle');
this.eventsEmpty.textContent = t('eventsEmpty');
this.locationTitle.textContent = t('locationName');
this.mapTab.textContent = t('mapTitle');
this.peopleTab.textContent = t('peopleTitle');
this.paintInspectTitle();
this.itemsHeading.textContent = t('locationItems');
this.itemsEmpty.textContent = t('itemsEmpty');
this.charactersHeading.textContent = t('locationCharacters');
@@ -160,6 +154,8 @@ export class GameScreen {
this.rebuildTree();
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex);
this.people.show(school.id);
this.showTab('map');
this.inspect('location');
}
applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void {
@@ -211,9 +207,32 @@ export class GameScreen {
private select(id: string): void {
this.selectedId = id;
this.inspect('location');
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 {
for (const [id, button] of this.treeButtons) {
button.classList.toggle('tree__button--active', id === this.selectedId);
+22 -19
View File
@@ -24,14 +24,22 @@ const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [
{ 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.
* Fetches over HTTP; never ticks locally.
* The people list and the card of whoever is picked. They live in two different panels — the list
* 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 {
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 yearSelect = 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 prevButton = 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 sort: PersonSort = 'surname';
@@ -63,7 +71,7 @@ export class PeoplePanel {
private token = 0;
private cardToken = 0;
constructor() {
constructor(private readonly options: PeoplePanelOptions) {
this.ageMinInput.min = '0';
this.ageMaxInput.min = '0';
this.table.append(this.thead, this.tbody);
@@ -86,13 +94,9 @@ export class PeoplePanel {
void this.reload();
});
this.element = el(
'section',
{ class: 'panel panel--people' },
this.title,
el(
this.listElement = el(
'div',
{ class: 'panel__body people' },
{ class: 'people' },
el(
'div',
{ class: 'people__toolbar' },
@@ -104,25 +108,20 @@ export class PeoplePanel {
field(this.ageFromLabel, this.ageMinInput),
field(this.ageToLabel, this.ageMaxInput),
),
el(
'div',
{ 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();
}
localize(): void {
this.title.textContent = t('peopleTitle');
this.roleLabel.textContent = t('peopleRole');
this.yearLabel.textContent = t('peopleYear');
this.letterLabel.textContent = t('peopleLetter');
@@ -296,6 +295,7 @@ export class PeoplePanel {
row.addEventListener('click', () => {
this.selectedId = person.id;
this.highlightSelection();
this.options.onSelect();
void this.openCard(person.id);
});
row.append(
@@ -327,6 +327,9 @@ export class PeoplePanel {
return;
}
// Also covers the links inside a card: opening a relative is picking somebody too.
this.options.onSelect();
const token = ++this.cardToken;
try {
const card = await fetchPerson(schoolId, personId, getLocale());