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.
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user