Implement portrait generation and retrieval for people in the school system. Added new API endpoints for generating and fetching portraits, including support for avatar and full-body images. Updated the client-side to handle portrait states and display options. Enhanced the person card UI to include tabs for overview and portrait, with appropriate localization strings. Updated solution files to include new test projects. Adjusted server configuration for SwarmUI integration.
This commit is contained in:
@@ -137,6 +137,17 @@ const ru = {
|
||||
peopleSiblings: 'Братья и сёстры',
|
||||
peoplePartners: 'Супруг(а)',
|
||||
|
||||
peopleTabOverview: 'Обзор',
|
||||
peopleTabPortrait: 'Портрет',
|
||||
peoplePortraitAvatar: 'Аватар',
|
||||
peoplePortraitFull: 'В полный рост',
|
||||
peoplePortraitGenerateAvatar: 'Сгенерировать аватар',
|
||||
peoplePortraitGenerateFull: 'Сгенерировать в полный рост',
|
||||
peoplePortraitGenerating: 'Генерация…',
|
||||
peoplePortraitMissing: 'Ещё не сгенерировано.',
|
||||
peoplePortraitUnavailable: 'SwarmUI не настроен на сервере.',
|
||||
peoplePortraitFailed: 'Не удалось сгенерировать портрет.',
|
||||
|
||||
modeOverview: 'Обзор',
|
||||
modeManage: 'Управление',
|
||||
staffAllocated: 'Выделено',
|
||||
@@ -357,6 +368,17 @@ const en: Messages = {
|
||||
peopleSiblings: 'Siblings',
|
||||
peoplePartners: 'Spouse',
|
||||
|
||||
peopleTabOverview: 'Overview',
|
||||
peopleTabPortrait: 'Portrait',
|
||||
peoplePortraitAvatar: 'Avatar',
|
||||
peoplePortraitFull: 'Full body',
|
||||
peoplePortraitGenerateAvatar: 'Generate avatar',
|
||||
peoplePortraitGenerateFull: 'Generate full body',
|
||||
peoplePortraitGenerating: 'Generating…',
|
||||
peoplePortraitMissing: 'Not generated yet.',
|
||||
peoplePortraitUnavailable: 'SwarmUI is not configured on the server.',
|
||||
peoplePortraitFailed: 'Could not generate the portrait.',
|
||||
|
||||
modeOverview: 'Overview',
|
||||
modeManage: 'Management',
|
||||
staffAllocated: 'Allocated',
|
||||
|
||||
@@ -295,6 +295,8 @@ export interface PersonCard {
|
||||
readonly carried: readonly CarriedItem[];
|
||||
readonly carryMass: number;
|
||||
readonly carryCapacity: number;
|
||||
readonly hasAvatar: boolean;
|
||||
readonly hasFullBody: boolean;
|
||||
}
|
||||
|
||||
export interface WornItem {
|
||||
@@ -337,6 +339,42 @@ export async function fetchPerson(schoolId: number, personId: string, lang: stri
|
||||
);
|
||||
}
|
||||
|
||||
export interface GameStatus {
|
||||
readonly tick: number;
|
||||
readonly tickRate: number;
|
||||
readonly schools: number;
|
||||
readonly maxSchools: number;
|
||||
readonly connections: number;
|
||||
readonly swarmUiConfigured: boolean;
|
||||
}
|
||||
|
||||
export async function fetchGameStatus(): Promise<GameStatus> {
|
||||
return request<GameStatus>('/api/status');
|
||||
}
|
||||
|
||||
export interface PortraitResult {
|
||||
readonly kind: 'avatar' | 'full';
|
||||
readonly hasAvatar: boolean;
|
||||
readonly hasFullBody: boolean;
|
||||
}
|
||||
|
||||
export function portraitUrl(schoolId: number, personId: string, kind: 'avatar' | 'full'): string {
|
||||
const params = new URLSearchParams({ kind });
|
||||
return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function generatePortrait(
|
||||
schoolId: number,
|
||||
personId: string,
|
||||
kind: 'avatar' | 'full',
|
||||
): Promise<PortraitResult> {
|
||||
const params = new URLSearchParams({ kind });
|
||||
return request<PortraitResult>(
|
||||
`/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
}
|
||||
|
||||
export interface DirectoryPerson {
|
||||
readonly id: string;
|
||||
readonly fullName: string;
|
||||
|
||||
@@ -581,6 +581,62 @@ body {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.people__tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.people__tab-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.people__card-header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.people__card-titles {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.people__avatar {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.people__portrait {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: 0 0 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.people__portrait--avatar {
|
||||
width: 256px;
|
||||
height: 256px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.people__portrait--full {
|
||||
width: min(100%, 384px);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.people__portrait-panel .button {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.people__card-name {
|
||||
margin: 0 0 2px;
|
||||
font-size: 15px;
|
||||
|
||||
@@ -9,7 +9,8 @@ import { getLocale } from '../i18n/locale.ts';
|
||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
import { Modal } from './modal.ts';
|
||||
import { formatPersonPlace, renderPersonCard } from './personCard.ts';
|
||||
import { formatPersonPlace } from './personCard.ts';
|
||||
import { PersonCardHost } from './personCardHost.ts';
|
||||
import {
|
||||
actionError,
|
||||
fillSelect,
|
||||
@@ -68,11 +69,13 @@ export class ApplicantsDialog {
|
||||
private dir: 'asc' | 'desc' = 'asc';
|
||||
private cardToken = 0;
|
||||
private busy = false;
|
||||
private readonly cardHost = new PersonCardHost();
|
||||
private painted: PersonCard | null = null;
|
||||
|
||||
constructor(private readonly options: ApplicantsDialogOptions) {
|
||||
this.staffing = options.staffing;
|
||||
this.selectedId = options.selectedId ?? null;
|
||||
this.cardHost.attach(options.schoolId);
|
||||
this.error.hidden = true;
|
||||
this.ageMinInput.min = '0';
|
||||
this.ageMaxInput.min = '0';
|
||||
@@ -151,7 +154,7 @@ export class ApplicantsDialog {
|
||||
|
||||
this.paintList();
|
||||
if (this.selectedId !== null) {
|
||||
this.appendHireIfNeeded();
|
||||
void this.openCard(this.selectedId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,19 +313,26 @@ export class ApplicantsDialog {
|
||||
}
|
||||
|
||||
private paintCard(card: PersonCard | null): void {
|
||||
clear(this.card);
|
||||
this.painted = card;
|
||||
if (card === null) {
|
||||
clear(this.card);
|
||||
this.card.append(el('p', { class: 'panel__empty', text: t('staffApplicantsPickHint') }));
|
||||
return;
|
||||
}
|
||||
|
||||
renderPersonCard(this.card, card, (id) => this.openRelative(id), this.placeOf(card.id));
|
||||
this.appendHireIfNeeded();
|
||||
this.cardHost.paint(
|
||||
this.card,
|
||||
card,
|
||||
(id) => this.openRelative(id),
|
||||
this.placeOf(card.id),
|
||||
);
|
||||
this.appendHireIfNeeded(this.card);
|
||||
}
|
||||
|
||||
private appendHireIfNeeded(): void {
|
||||
const applicant = this.staffing.applicants.find((row) => row.id === this.painted?.id);
|
||||
private appendHireIfNeeded(parent: HTMLElement = this.card): void {
|
||||
const personId = this.selectedId ?? this.painted?.id;
|
||||
const applicant =
|
||||
personId === null ? undefined : this.staffing.applicants.find((row) => row.id === personId);
|
||||
if (applicant === undefined) {
|
||||
return;
|
||||
}
|
||||
@@ -349,7 +359,7 @@ export class ApplicantsDialog {
|
||||
onClick: () => void this.hire(applicant.id),
|
||||
}),
|
||||
);
|
||||
this.card.append(actions);
|
||||
parent.append(actions);
|
||||
}
|
||||
|
||||
private openRelative(personId: string): void {
|
||||
|
||||
@@ -8,6 +8,9 @@ interface ElementOptions {
|
||||
title?: string;
|
||||
type?: string;
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
dataset?: Record<string, string>;
|
||||
onClick?: (event: Event) => void;
|
||||
}
|
||||
@@ -24,6 +27,15 @@ export function el<K extends keyof HTMLElementTagNameMap>(
|
||||
if (options.title !== undefined) element.title = options.title;
|
||||
if (options.type !== undefined) element.setAttribute('type', options.type);
|
||||
if (options.disabled !== undefined) element.toggleAttribute('disabled', options.disabled);
|
||||
if (options.hidden !== undefined) element.toggleAttribute('hidden', options.hidden);
|
||||
if (options.src !== undefined && 'src' in element) {
|
||||
(element as HTMLImageElement).src = options.src;
|
||||
}
|
||||
|
||||
if (options.alt !== undefined && 'alt' in element) {
|
||||
(element as HTMLImageElement).alt = options.alt;
|
||||
}
|
||||
|
||||
if (options.onClick !== undefined) element.addEventListener('click', options.onClick);
|
||||
|
||||
for (const [key, value] of Object.entries(options.dataset ?? {})) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ApiError,
|
||||
fetchPerson,
|
||||
fetchGameStatus,
|
||||
fetchStaffing,
|
||||
fetchTimetable,
|
||||
hireStaff,
|
||||
@@ -24,6 +25,14 @@ vi.mock('../net/api.ts', async (importOriginal) => {
|
||||
fetchStaffing: vi.fn(),
|
||||
fetchTimetable: vi.fn(),
|
||||
fetchPerson: vi.fn(),
|
||||
fetchGameStatus: vi.fn().mockResolvedValue({
|
||||
tick: 0,
|
||||
tickRate: 20,
|
||||
schools: 1,
|
||||
maxSchools: 6,
|
||||
connections: 0,
|
||||
swarmUiConfigured: false,
|
||||
}),
|
||||
hireStaff: vi.fn(),
|
||||
assignSubject: vi.fn(),
|
||||
unassignSubject: vi.fn(),
|
||||
@@ -59,6 +68,8 @@ function personCard(): PersonCard {
|
||||
carried: [],
|
||||
carryMass: 0,
|
||||
carryCapacity: 0,
|
||||
hasAvatar: false,
|
||||
hasFullBody: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,7 +118,17 @@ describe('ManagementPanel payroll cap', () => {
|
||||
vi.mocked(hireStaff).mockReset();
|
||||
vi.mocked(fetchStaffing).mockResolvedValue(staffing());
|
||||
vi.mocked(fetchTimetable).mockResolvedValue(timetable());
|
||||
vi.mocked(fetchPerson).mockResolvedValue(personCard());
|
||||
vi.mocked(fetchPerson).mockImplementation((_schoolId, personId) =>
|
||||
Promise.resolve({ ...personCard(), id: personId }),
|
||||
);
|
||||
vi.mocked(fetchGameStatus).mockResolvedValue({
|
||||
tick: 0,
|
||||
tickRate: 20,
|
||||
schools: 1,
|
||||
maxSchools: 6,
|
||||
connections: 0,
|
||||
swarmUiConfigured: false,
|
||||
});
|
||||
vi.mocked(hireStaff).mockRejectedValue(
|
||||
new ApiError(409, 'payroll-exceeded', 'no', 10_000, 9_000, 1_000, 12_000),
|
||||
);
|
||||
@@ -147,10 +168,22 @@ describe('ManagementPanel payroll cap', () => {
|
||||
|
||||
row.click();
|
||||
await vi.waitFor(() => expect(fetchPerson).toHaveBeenCalled());
|
||||
const hire = [...dialog.querySelectorAll('button')].find((button) => button.textContent === t('staffHire'));
|
||||
if (!(hire instanceof HTMLButtonElement)) {
|
||||
throw new Error('hire button is missing');
|
||||
}
|
||||
await vi.waitFor(() =>
|
||||
expect(dialog.querySelector('.people__card-name')?.textContent).toContain('Sidorova'),
|
||||
);
|
||||
const hire = await vi.waitFor(() => {
|
||||
const actions = dialog.querySelector('.staffing__actions');
|
||||
if (actions === null) {
|
||||
throw new Error('staffing actions are missing');
|
||||
}
|
||||
|
||||
const button = actions.querySelector('button');
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
throw new Error('hire button is missing');
|
||||
}
|
||||
|
||||
return button;
|
||||
});
|
||||
|
||||
hire.click();
|
||||
const banner = dialog.querySelector('.staffing__error');
|
||||
|
||||
@@ -13,7 +13,8 @@ import { getLocale } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { ApplicantsDialog } from './applicantsDialog.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
import { formatPersonPlace, renderPersonCard } from './personCard.ts';
|
||||
import { formatPersonPlace } from './personCard.ts';
|
||||
import { PersonCardHost } from './personCardHost.ts';
|
||||
import { actionError, fillSelect, formatHours, formatMoney } from './staffingUi.ts';
|
||||
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
|
||||
|
||||
@@ -64,6 +65,7 @@ export class ManagementPanel {
|
||||
private locate: ((id: string) => string) | null = null;
|
||||
private painted: PersonCard | null = null;
|
||||
private poolDialog: ApplicantsDialog | null = null;
|
||||
private readonly cardHost = new PersonCardHost();
|
||||
|
||||
constructor() {
|
||||
this.error.hidden = true;
|
||||
@@ -136,12 +138,13 @@ export class ManagementPanel {
|
||||
}
|
||||
|
||||
this.timetableGrid.attach(schoolId);
|
||||
this.cardHost.attach(schoolId);
|
||||
void this.reload();
|
||||
}
|
||||
|
||||
setLocate(locate: (id: string) => string): void {
|
||||
this.locate = locate;
|
||||
this.relocate();
|
||||
this.cardHost.relocate((id) => this.placeOf(id));
|
||||
}
|
||||
|
||||
private async reload(): Promise<void> {
|
||||
@@ -399,39 +402,33 @@ export class ManagementPanel {
|
||||
}
|
||||
|
||||
private paintCard(card: PersonCard | null): void {
|
||||
clear(this.card);
|
||||
this.painted = card;
|
||||
if (card === null) {
|
||||
clear(this.card);
|
||||
this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') }));
|
||||
return;
|
||||
}
|
||||
|
||||
renderPersonCard(this.card, card, (id) => void this.openRelative(id), this.placeOf(card.id));
|
||||
this.mountPersonTimetable(card);
|
||||
this.appendActions(card.id);
|
||||
this.cardHost.paint(
|
||||
this.card,
|
||||
card,
|
||||
(id) => void this.openRelative(id),
|
||||
this.placeOf(card.id),
|
||||
(overview, painted) => {
|
||||
this.mountPersonTimetable(overview, painted);
|
||||
this.appendActions(overview, painted.id);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private relocate(): void {
|
||||
const line = this.card.querySelector('.people__card-place');
|
||||
if (!(line instanceof HTMLElement) || this.painted === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
line.textContent = this.placeOf(this.painted.id);
|
||||
}
|
||||
|
||||
private placeOf(id: string): string {
|
||||
return this.locate?.(id) ?? formatPersonPlace('away');
|
||||
}
|
||||
|
||||
private mountPersonTimetable(card: PersonCard): void {
|
||||
private mountPersonTimetable(overview: HTMLElement, card: PersonCard): void {
|
||||
const schoolId = this.schoolId;
|
||||
const query = personTimetableQuery(card);
|
||||
if (schoolId === null || query === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.card.append(this.personTimetableTitle, this.personGrid.element);
|
||||
overview.append(this.personTimetableTitle, this.personGrid.element);
|
||||
this.personGrid.attach(schoolId);
|
||||
const token = this.cardToken;
|
||||
void fetchTimetable(schoolId, getLocale(), query)
|
||||
@@ -451,6 +448,10 @@ export class ManagementPanel {
|
||||
});
|
||||
}
|
||||
|
||||
private placeOf(id: string): string {
|
||||
return this.locate?.(id) ?? formatPersonPlace('away');
|
||||
}
|
||||
|
||||
private async openRelative(personId: string): Promise<void> {
|
||||
const staffing = this.staffing;
|
||||
if (staffing !== null && staffing.staff.some((row) => row.id === personId)) {
|
||||
@@ -466,7 +467,7 @@ export class ManagementPanel {
|
||||
await this.select(personId);
|
||||
}
|
||||
|
||||
private appendActions(personId: string): void {
|
||||
private appendActions(parent: HTMLElement, personId: string): void {
|
||||
const staffing = this.staffing;
|
||||
if (staffing === null) {
|
||||
return;
|
||||
@@ -474,11 +475,11 @@ export class ManagementPanel {
|
||||
|
||||
const member = staffing.staff.find((row) => row.id === personId);
|
||||
if (member !== undefined && member.position === TEACHER) {
|
||||
this.appendSubjects(member);
|
||||
this.appendSubjects(parent, member);
|
||||
}
|
||||
}
|
||||
|
||||
private appendSubjects(member: StaffMember): void {
|
||||
private appendSubjects(parent: HTMLElement, member: StaffMember): void {
|
||||
const staffing = this.staffing;
|
||||
if (staffing === null) {
|
||||
return;
|
||||
@@ -530,7 +531,7 @@ export class ManagementPanel {
|
||||
);
|
||||
}
|
||||
|
||||
this.card.append(actions);
|
||||
parent.append(actions);
|
||||
}
|
||||
|
||||
private async assign(personId: string): Promise<void> {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { fetchPeople, fetchPerson, fetchTimetable, type PeoplePage, type PersonC
|
||||
import { getLocale } from '../i18n/locale.ts';
|
||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
import { formatPersonPlace, placement, renderPersonCard, roleLabels } from './personCard.ts';
|
||||
import { formatPersonPlace, placement, roleLabels } from './personCard.ts';
|
||||
import { PersonCardHost } from './personCardHost.ts';
|
||||
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
|
||||
|
||||
const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [
|
||||
@@ -62,6 +63,7 @@ export class PeoplePanel {
|
||||
private cardToken = 0;
|
||||
private locate: ((id: string) => string) | null = null;
|
||||
private painted: PersonCard | null = null;
|
||||
private readonly cardHost = new PersonCardHost();
|
||||
|
||||
constructor(private readonly options: PeoplePanelOptions) {
|
||||
this.ageMinInput.min = '0';
|
||||
@@ -156,6 +158,7 @@ export class PeoplePanel {
|
||||
|
||||
show(schoolId: number): void {
|
||||
this.schoolId = schoolId;
|
||||
this.cardHost.attach(schoolId);
|
||||
this.sort = 'surname';
|
||||
this.dir = 'asc';
|
||||
this.page = 1;
|
||||
@@ -179,7 +182,7 @@ export class PeoplePanel {
|
||||
|
||||
setLocate(locate: (id: string) => string): void {
|
||||
this.locate = locate;
|
||||
this.relocate();
|
||||
this.cardHost.relocate((id) => this.placeOf(id));
|
||||
}
|
||||
|
||||
private onFilterChange(): void {
|
||||
@@ -361,38 +364,30 @@ export class PeoplePanel {
|
||||
}
|
||||
|
||||
private paintCard(card: PersonCard | null): void {
|
||||
clear(this.card);
|
||||
this.painted = card;
|
||||
if (card === null) {
|
||||
clear(this.card);
|
||||
this.card.append(el('p', { class: 'panel__empty', text: t('peoplePickHint') }));
|
||||
return;
|
||||
}
|
||||
|
||||
renderPersonCard(this.card, card, (id) => void this.openCard(id), this.placeOf(card.id));
|
||||
this.mountPersonTimetable(card);
|
||||
this.cardHost.paint(
|
||||
this.card,
|
||||
card,
|
||||
(id) => void this.openCard(id),
|
||||
this.placeOf(card.id),
|
||||
(overview, painted) => this.mountPersonTimetable(overview, painted),
|
||||
);
|
||||
}
|
||||
|
||||
private relocate(): void {
|
||||
const line = this.card.querySelector('.people__card-place');
|
||||
if (!(line instanceof HTMLElement) || this.painted === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
line.textContent = this.placeOf(this.painted.id);
|
||||
}
|
||||
|
||||
private placeOf(id: string): string {
|
||||
return this.locate?.(id) ?? formatPersonPlace('away');
|
||||
}
|
||||
|
||||
private mountPersonTimetable(card: PersonCard): void {
|
||||
private mountPersonTimetable(overview: HTMLElement, card: PersonCard): void {
|
||||
const schoolId = this.schoolId;
|
||||
const query = personTimetableQuery(card);
|
||||
if (schoolId === null || query === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.card.append(this.personTimetableTitle, this.personGrid.element);
|
||||
overview.append(this.personTimetableTitle, this.personGrid.element);
|
||||
this.personGrid.attach(schoolId);
|
||||
const token = this.cardToken;
|
||||
void fetchTimetable(schoolId, getLocale(), query)
|
||||
@@ -411,6 +406,10 @@ export class PeoplePanel {
|
||||
this.personGrid.setTable(null);
|
||||
});
|
||||
}
|
||||
|
||||
private placeOf(id: string): string {
|
||||
return this.locate?.(id) ?? formatPersonPlace('away');
|
||||
}
|
||||
}
|
||||
|
||||
function field(label: HTMLElement, control: HTMLElement): HTMLLabelElement {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { PersonCard } from '../net/api.ts';
|
||||
import { getLocale, setLocale } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { renderPersonCard } from './personCard.ts';
|
||||
import { renderPersonCard, type PersonCardHandlers } from './personCard.ts';
|
||||
|
||||
const initial = getLocale();
|
||||
|
||||
@@ -58,15 +58,29 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
|
||||
],
|
||||
carryMass: 1.2,
|
||||
carryCapacity: 14,
|
||||
hasAvatar: false,
|
||||
hasFullBody: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function handlers(overrides: Partial<PersonCardHandlers> = {}): PersonCardHandlers {
|
||||
return {
|
||||
onRelative: () => {},
|
||||
schoolId: 1,
|
||||
tab: 'overview',
|
||||
onTabChange: () => {},
|
||||
portraitBusy: null,
|
||||
portraitError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('renderPersonCard', () => {
|
||||
it('shows worn layers with colour and carried mass in the same scroll', () => {
|
||||
it('shows worn layers with colour and carried mass on the overview tab', () => {
|
||||
setLocale('ru');
|
||||
const root = document.createElement('div');
|
||||
renderPersonCard(root, card(), () => {});
|
||||
renderPersonCard(root, card(), handlers());
|
||||
|
||||
expect(root.textContent).toContain(t('peopleApparel'));
|
||||
expect(root.textContent).toContain('Верх');
|
||||
@@ -74,7 +88,7 @@ describe('renderPersonCard', () => {
|
||||
expect(root.textContent).toContain(t('peopleCarry'));
|
||||
expect(root.textContent).toContain(t('peopleCarryMass', { held: '1.2', cap: '14' }));
|
||||
expect(root.textContent).toContain('Учебник · Математика');
|
||||
expect(root.querySelector('.people__tabs')).toBeNull();
|
||||
expect(root.querySelector('.people__tabs')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows the server condition caption, not a band inferred from the number', () => {
|
||||
@@ -95,7 +109,7 @@ describe('renderPersonCard', () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
() => {},
|
||||
handlers(),
|
||||
);
|
||||
|
||||
expect(root.textContent).toContain('целая');
|
||||
@@ -104,30 +118,46 @@ describe('renderPersonCard', () => {
|
||||
expect((root.querySelector('.people__need-fill') as HTMLElement | null)?.style.width).toBe('10%');
|
||||
});
|
||||
|
||||
it('changes the caption when the payload label crosses a threshold', () => {
|
||||
setLocale('ru');
|
||||
it('shows the avatar on overview only when hasAvatar is true', () => {
|
||||
const root = document.createElement('div');
|
||||
renderPersonCard(root, card(), () => {});
|
||||
expect(root.querySelector('.people__wear-label')?.textContent).toBe('целая');
|
||||
renderPersonCard(root, card({ hasAvatar: false }), handlers({ schoolId: 1 }));
|
||||
expect(root.querySelector('.people__avatar')).toBeNull();
|
||||
|
||||
root.replaceChildren();
|
||||
renderPersonCard(
|
||||
root,
|
||||
card({
|
||||
worn: [
|
||||
{
|
||||
defName: 'Shirt',
|
||||
label: 'Рубашка',
|
||||
color: 'White',
|
||||
colorLabel: 'Белый',
|
||||
layers: [{ defName: 'Top', label: 'Верх' }],
|
||||
condition: 0.1,
|
||||
conditionLabel: 'висит лохмотьями',
|
||||
},
|
||||
],
|
||||
}),
|
||||
() => {},
|
||||
);
|
||||
expect(root.querySelector('.people__wear-label')?.textContent).toBe('висит лохмотьями');
|
||||
renderPersonCard(root, card({ hasAvatar: true }), handlers({ schoolId: 1 }));
|
||||
const image = root.querySelector('.people__avatar') as HTMLImageElement | null;
|
||||
expect(image).not.toBeNull();
|
||||
expect(image?.getAttribute('src')).toContain('/portrait?kind=avatar');
|
||||
});
|
||||
|
||||
it('does not mount apparel on the portrait tab', () => {
|
||||
setLocale('ru');
|
||||
const root = document.createElement('div');
|
||||
renderPersonCard(root, card(), handlers({ tab: 'portrait' }));
|
||||
|
||||
const panel = root.querySelector('.people__portrait-panel');
|
||||
expect(panel).not.toBeNull();
|
||||
expect(panel?.textContent).toContain(t('peoplePortraitGenerateAvatar'));
|
||||
expect(panel?.textContent).not.toContain(t('peopleApparel'));
|
||||
});
|
||||
|
||||
it('disables generate while busy', () => {
|
||||
setLocale('en');
|
||||
const root = document.createElement('div');
|
||||
renderPersonCard(root, card(), handlers({ tab: 'portrait', portraitBusy: 'avatar' }));
|
||||
|
||||
const buttons = [...root.querySelectorAll('button')].filter((button) => button.textContent?.includes('Generating'));
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
expect(buttons.every((button) => button.disabled)).toBe(true);
|
||||
});
|
||||
|
||||
it('calls onTabChange when a tab is clicked', () => {
|
||||
const onTabChange = vi.fn();
|
||||
const root = document.createElement('div');
|
||||
renderPersonCard(root, card(), handlers({ onTabChange }));
|
||||
|
||||
const portraitTab = [...root.querySelectorAll('button')].find((button) => button.textContent === t('peopleTabPortrait'));
|
||||
portraitTab?.click();
|
||||
expect(onTabChange).toHaveBeenCalledWith('portrait');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PersonCard, PersonListItem, PersonRel, PersonRole } from '../net/api.ts';
|
||||
import { portraitUrl } from '../net/api.ts';
|
||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
|
||||
@@ -8,6 +9,24 @@ const ROLE_KEYS: Record<PersonRole, MessageKey> = {
|
||||
parent: 'peopleRoleParent',
|
||||
};
|
||||
|
||||
export type PersonCardTab = 'overview' | 'portrait';
|
||||
|
||||
export interface PersonCardHandlers {
|
||||
readonly onRelative: (id: string) => void;
|
||||
readonly place?: string;
|
||||
readonly schoolId: number | null;
|
||||
readonly tab: PersonCardTab;
|
||||
readonly onTabChange: (tab: PersonCardTab) => void;
|
||||
readonly onGeneratePortrait?: (kind: 'avatar' | 'full') => void;
|
||||
readonly portraitBusy?: 'avatar' | 'full' | null;
|
||||
readonly portraitError?: string | null;
|
||||
readonly swarmConfigured?: boolean;
|
||||
}
|
||||
|
||||
export interface PersonCardMount {
|
||||
readonly overviewPane: HTMLElement;
|
||||
}
|
||||
|
||||
export function roleLabels(roles: readonly string[]): string {
|
||||
return roles
|
||||
.map((role) => (role in ROLE_KEYS ? t(ROLE_KEYS[role as PersonRole]) : role))
|
||||
@@ -30,15 +49,57 @@ export function placement(person: Pick<PersonListItem, 'classYear' | 'classLette
|
||||
export function renderPersonCard(
|
||||
parent: HTMLElement,
|
||||
card: PersonCard,
|
||||
onRelative: (id: string) => void,
|
||||
place?: string,
|
||||
): void {
|
||||
parent.append(
|
||||
handlers: PersonCardHandlers,
|
||||
): PersonCardMount {
|
||||
const tabs = el('div', { class: 'people__tabs' });
|
||||
const overviewTab = tabButton(t('peopleTabOverview'), handlers.tab === 'overview', () => handlers.onTabChange('overview'));
|
||||
const portraitTab = tabButton(t('peopleTabPortrait'), handlers.tab === 'portrait', () => handlers.onTabChange('portrait'));
|
||||
tabs.append(overviewTab, portraitTab);
|
||||
|
||||
const overviewPane = el('div', { class: 'people__tab-panel' });
|
||||
const portraitPane = el('div', { class: 'people__tab-panel people__portrait-panel', hidden: handlers.tab !== 'portrait' });
|
||||
if (handlers.tab !== 'overview') {
|
||||
overviewPane.hidden = true;
|
||||
}
|
||||
|
||||
mountOverview(overviewPane, card, handlers);
|
||||
mountPortrait(portraitPane, card, handlers);
|
||||
|
||||
parent.append(tabs, overviewPane, portraitPane);
|
||||
return { overviewPane };
|
||||
}
|
||||
|
||||
function tabButton(label: string, active: boolean, onClick: () => void): HTMLButtonElement {
|
||||
return el('button', {
|
||||
class: `panel__tab${active ? ' panel__tab--active' : ''}`,
|
||||
type: 'button',
|
||||
text: label,
|
||||
onClick,
|
||||
});
|
||||
}
|
||||
|
||||
function mountOverview(parent: HTMLElement, card: PersonCard, handlers: PersonCardHandlers): void {
|
||||
const header = el('div', { class: 'people__card-header' });
|
||||
if (card.hasAvatar && handlers.schoolId !== null) {
|
||||
header.append(
|
||||
el('img', {
|
||||
class: 'people__avatar',
|
||||
alt: card.fullName,
|
||||
src: portraitUrl(handlers.schoolId, card.id, 'avatar'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const titles = el('div', { class: 'people__card-titles' });
|
||||
titles.append(
|
||||
el('h3', { class: 'people__card-name', text: card.fullName }),
|
||||
el('p', { class: 'people__card-meta', text: cardMeta(card) }),
|
||||
);
|
||||
if (place !== undefined && place.length > 0) {
|
||||
parent.append(el('p', { class: 'people__card-place', text: place }));
|
||||
header.append(titles);
|
||||
parent.append(header);
|
||||
|
||||
if (handlers.place !== undefined && handlers.place.length > 0) {
|
||||
parent.append(el('p', { class: 'people__card-place', text: handlers.place }));
|
||||
}
|
||||
if (card.activityLabel !== null && card.activityLabel.length > 0) {
|
||||
parent.append(el('p', { class: 'people__card-activity', text: card.activityLabel }));
|
||||
@@ -51,15 +112,62 @@ export function renderPersonCard(
|
||||
appendCarry(parent, card);
|
||||
|
||||
const family = section(t('peopleFamily'));
|
||||
appendRelatives(family, t('peopleParents'), card.family.parents, onRelative);
|
||||
appendRelatives(family, t('peopleChildren'), card.family.children, onRelative);
|
||||
appendRelatives(family, t('peopleSiblings'), card.family.siblings, onRelative);
|
||||
appendRelatives(family, t('peoplePartners'), card.family.partners, onRelative);
|
||||
appendRelatives(family, t('peopleParents'), card.family.parents, handlers.onRelative);
|
||||
appendRelatives(family, t('peopleChildren'), card.family.children, handlers.onRelative);
|
||||
appendRelatives(family, t('peopleSiblings'), card.family.siblings, handlers.onRelative);
|
||||
appendRelatives(family, t('peoplePartners'), card.family.partners, handlers.onRelative);
|
||||
if (family.childElementCount > 1) {
|
||||
parent.append(family);
|
||||
}
|
||||
}
|
||||
|
||||
function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCardHandlers): void {
|
||||
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitAvatar') }));
|
||||
parent.append(portraitPreview(card, handlers, 'avatar'));
|
||||
parent.append(
|
||||
el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
text: handlers.portraitBusy === 'avatar' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateAvatar'),
|
||||
disabled: handlers.portraitBusy !== null || handlers.swarmConfigured === false,
|
||||
onClick: () => handlers.onGeneratePortrait?.('avatar'),
|
||||
}),
|
||||
);
|
||||
|
||||
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitFull') }));
|
||||
parent.append(portraitPreview(card, handlers, 'full'));
|
||||
parent.append(
|
||||
el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
text: handlers.portraitBusy === 'full' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateFull'),
|
||||
disabled: handlers.portraitBusy !== null || handlers.swarmConfigured === false,
|
||||
onClick: () => handlers.onGeneratePortrait?.('full'),
|
||||
}),
|
||||
);
|
||||
|
||||
if (handlers.swarmConfigured === false) {
|
||||
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
|
||||
}
|
||||
|
||||
if ((handlers.portraitError?.length ?? 0) > 0) {
|
||||
parent.append(el('p', { class: 'panel__error', text: handlers.portraitError! }));
|
||||
}
|
||||
}
|
||||
|
||||
function portraitPreview(card: PersonCard, handlers: PersonCardHandlers, kind: 'avatar' | 'full'): HTMLElement {
|
||||
const hasImage = kind === 'avatar' ? card.hasAvatar : card.hasFullBody;
|
||||
if (hasImage && handlers.schoolId !== null) {
|
||||
return el('img', {
|
||||
class: kind === 'avatar' ? 'people__portrait people__portrait--avatar' : 'people__portrait people__portrait--full',
|
||||
alt: card.fullName,
|
||||
src: portraitUrl(handlers.schoolId, card.id, kind),
|
||||
});
|
||||
}
|
||||
|
||||
return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') });
|
||||
}
|
||||
|
||||
function cardMeta(card: PersonCard): string {
|
||||
const bits = [
|
||||
roleLabels(card.roles),
|
||||
@@ -119,36 +227,34 @@ function appendApparel(parent: HTMLElement, rows: PersonCard['worn']): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = el('div', { class: 'people__garments' });
|
||||
const block = section(t('peopleApparel'));
|
||||
const garments = el('div', { class: 'people__garments' });
|
||||
for (const row of rows) {
|
||||
const layers = row.layers.map((layer) => layer.label).join(', ');
|
||||
const color = row.colorLabel ?? row.color;
|
||||
const value = color !== null && color.length > 0 ? `${row.label} · ${color}` : row.label;
|
||||
const garment = el('div', { class: 'people__garment' });
|
||||
garment.append(el('span', { text: layers.length > 0 ? layers : value }));
|
||||
if (layers.length > 0) {
|
||||
garment.append(el('span', { text: value }));
|
||||
}
|
||||
|
||||
const share = Math.max(0, Math.min(1, row.condition));
|
||||
const fill = el('span', { class: 'people__need-fill' });
|
||||
fill.style.width = `${Math.round(share * 100)}%`;
|
||||
fill.classList.toggle('people__need-fill--low', share < 0.25);
|
||||
list.append(
|
||||
garment.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__garment' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pair' },
|
||||
el('dt', { text: layers.length > 0 ? layers : row.label }),
|
||||
el('dd', { text: value }),
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__wear' },
|
||||
el('span', { class: 'people__wear-label', text: row.conditionLabel }),
|
||||
el('span', { class: 'people__need-track' }, fill),
|
||||
),
|
||||
{ class: 'people__wear' },
|
||||
el('span', { class: 'people__wear-label', text: row.conditionLabel }),
|
||||
el('span', { class: 'people__need-track' }, fill),
|
||||
),
|
||||
);
|
||||
garments.append(garment);
|
||||
}
|
||||
|
||||
parent.append(section(t('peopleApparel')), list);
|
||||
block.append(garments);
|
||||
parent.append(block);
|
||||
}
|
||||
|
||||
function appendCarry(parent: HTMLElement, card: PersonCard): void {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { ApiError, fetchGameStatus, fetchPerson, generatePortrait, type PersonCard } from '../net/api.ts';
|
||||
import { getLocale } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { clear } from './dom.ts';
|
||||
import {
|
||||
renderPersonCard,
|
||||
type PersonCardHandlers,
|
||||
type PersonCardMount,
|
||||
type PersonCardTab,
|
||||
} from './personCard.ts';
|
||||
|
||||
/** Shared card tab state, portrait generation and Swarm availability for people-like panels. */
|
||||
export class PersonCardHost {
|
||||
private tab: PersonCardTab = 'overview';
|
||||
private portraitBusy: 'avatar' | 'full' | null = null;
|
||||
private portraitError: string | null = null;
|
||||
private swarmConfigured = false;
|
||||
private schoolId: number | null = null;
|
||||
private painted: PersonCard | null = null;
|
||||
private mount: PersonCardMount | null = null;
|
||||
private container: HTMLElement | null = null;
|
||||
private onRelative: (id: string) => void = () => {};
|
||||
private placeOf: (id: string) => string = () => '';
|
||||
private onOverviewMounted?: (overview: HTMLElement, card: PersonCard) => void;
|
||||
|
||||
attach(schoolId: number): void {
|
||||
this.schoolId = schoolId;
|
||||
void fetchGameStatus()
|
||||
.then((status) => {
|
||||
this.swarmConfigured = status.swarmUiConfigured;
|
||||
})
|
||||
.catch(() => {
|
||||
this.swarmConfigured = false;
|
||||
});
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.schoolId = null;
|
||||
this.painted = null;
|
||||
this.mount = null;
|
||||
this.container = null;
|
||||
this.onOverviewMounted = undefined;
|
||||
this.tab = 'overview';
|
||||
this.portraitBusy = null;
|
||||
this.portraitError = null;
|
||||
}
|
||||
|
||||
paint(
|
||||
container: HTMLElement,
|
||||
card: PersonCard | null,
|
||||
onRelative: (id: string) => void,
|
||||
placeOf: (id: string) => string,
|
||||
onOverviewMounted?: (overview: HTMLElement, card: PersonCard) => void,
|
||||
): void {
|
||||
this.container = container;
|
||||
this.onRelative = onRelative;
|
||||
this.placeOf = placeOf;
|
||||
this.onOverviewMounted = onOverviewMounted;
|
||||
clear(container);
|
||||
this.painted = card;
|
||||
this.mount = null;
|
||||
|
||||
if (card === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.repaint(container, card);
|
||||
}
|
||||
|
||||
private refreshPainted(): void {
|
||||
if (this.painted !== null && this.container !== null) {
|
||||
this.repaint(this.container, this.painted);
|
||||
}
|
||||
}
|
||||
|
||||
private repaint(container: HTMLElement, card: PersonCard): void {
|
||||
clear(container);
|
||||
this.mount = renderPersonCard(container, card, this.handlers(this.onRelative, this.placeOf(card.id)));
|
||||
this.onOverviewMounted?.(this.mount.overviewPane, card);
|
||||
}
|
||||
|
||||
private handlers(onRelative: (id: string) => void, place: string): PersonCardHandlers {
|
||||
return {
|
||||
onRelative,
|
||||
place,
|
||||
schoolId: this.schoolId,
|
||||
tab: this.tab,
|
||||
onTabChange: (tab) => {
|
||||
this.tab = tab;
|
||||
this.refreshPainted();
|
||||
},
|
||||
onGeneratePortrait: (kind) => void this.generate(kind),
|
||||
portraitBusy: this.portraitBusy,
|
||||
portraitError: this.portraitError,
|
||||
swarmConfigured: this.swarmConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
private async generate(kind: 'avatar' | 'full'): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
const personId = this.painted?.id;
|
||||
if (schoolId === null || personId === undefined || this.portraitBusy !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.portraitBusy = kind;
|
||||
this.portraitError = null;
|
||||
this.refreshPainted();
|
||||
|
||||
try {
|
||||
const result = await generatePortrait(schoolId, personId, kind);
|
||||
const card = await fetchPerson(schoolId, personId, getLocale());
|
||||
this.painted = {
|
||||
...card,
|
||||
hasAvatar: result.hasAvatar,
|
||||
hasFullBody: result.hasFullBody,
|
||||
};
|
||||
} catch (error) {
|
||||
this.portraitError =
|
||||
error instanceof ApiError && error.code === 'swarmui-not-configured'
|
||||
? t('peoplePortraitUnavailable')
|
||||
: t('peoplePortraitFailed');
|
||||
} finally {
|
||||
this.portraitBusy = null;
|
||||
this.refreshPainted();
|
||||
}
|
||||
}
|
||||
|
||||
relocate(placeOf: (id: string) => string): void {
|
||||
this.placeOf = placeOf;
|
||||
const line = this.container?.querySelector('.people__card-place');
|
||||
if (!(line instanceof HTMLElement) || this.painted === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
line.textContent = placeOf(this.painted.id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user