From d8f8db6a480a1611d80923bdedbd8396883186a9 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 18 Aug 2026 21:31:12 +0300 Subject: [PATCH] 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. --- src/HSchool.Client/src/i18n/strings.ts | 2 + src/HSchool.Client/src/style.css | 97 ++++++++++++++++-------- src/HSchool.Client/src/ui/gameScreen.ts | 97 ++++++++++++++---------- src/HSchool.Client/src/ui/peoplePanel.ts | 71 ++++++++--------- 4 files changed, 162 insertions(+), 105 deletions(-) diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index c4c37ba..3be4310 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -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', diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 16b47d5..9ff9dfd 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -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; } } diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index dd9afb2..538d8c1 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -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(); 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) => @@ -84,41 +87,33 @@ export class GameScreen { ), el( 'div', - { class: 'shell' }, + { class: 'manager' }, el( - 'div', - { class: 'manager' }, - el( - 'section', - { class: 'panel' }, - 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), - ), - ), + 'section', + { class: 'panel' }, + el('div', { class: 'panel__tabs' }, this.mapTab, this.peopleTab), + this.mapBody, + this.peopleBody, ), - 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(); } @@ -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); diff --git a/src/HSchool.Client/src/ui/peoplePanel.ts b/src/HSchool.Client/src/ui/peoplePanel.ts index 9e3db6c..e54aedf 100644 --- a/src/HSchool.Client/src/ui/peoplePanel.ts +++ b/src/HSchool.Client/src/ui/peoplePanel.ts @@ -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,43 +94,34 @@ export class PeoplePanel { void this.reload(); }); - this.element = el( - 'section', - { class: 'panel panel--people' }, - this.title, + this.listElement = el( + 'div', + { class: 'people' }, el( 'div', - { class: 'panel__body people' }, - el( - 'div', - { class: 'people__toolbar' }, - field(this.roleLabel, this.roleSelect), - field(this.yearLabel, this.yearSelect), - field(this.letterLabel, this.letterSelect), - field(this.positionLabel, this.positionSelect), - field(this.sexLabel, this.sexSelect), - 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, - ), + { class: 'people__toolbar' }, + field(this.roleLabel, this.roleSelect), + field(this.yearLabel, this.yearSelect), + field(this.letterLabel, this.letterSelect), + field(this.positionLabel, this.positionSelect), + field(this.sexLabel, this.sexSelect), + field(this.ageFromLabel, this.ageMinInput), + field(this.ageToLabel, this.ageMaxInput), + ), + 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.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());