Add half-body portrait support: update API, UI, and localization for new portrait type
This commit is contained in:
@@ -155,8 +155,10 @@ const ru = {
|
||||
peopleTabOverview: 'Обзор',
|
||||
peopleTabPortrait: 'Портрет',
|
||||
peoplePortraitAvatar: 'Аватар',
|
||||
peoplePortraitHalf: 'По пояс',
|
||||
peoplePortraitFull: 'В полный рост',
|
||||
peoplePortraitGenerateAvatar: 'Сгенерировать аватар',
|
||||
peoplePortraitGenerateHalf: 'Сгенерировать по пояс',
|
||||
peoplePortraitGenerateFull: 'Сгенерировать в полный рост',
|
||||
peoplePortraitGenerating: 'Генерация…',
|
||||
peoplePortraitMissing: 'Ещё не сгенерировано.',
|
||||
@@ -410,8 +412,10 @@ const en: Messages = {
|
||||
peopleTabOverview: 'Overview',
|
||||
peopleTabPortrait: 'Portrait',
|
||||
peoplePortraitAvatar: 'Avatar',
|
||||
peoplePortraitHalf: 'Waist up',
|
||||
peoplePortraitFull: 'Full body',
|
||||
peoplePortraitGenerateAvatar: 'Generate avatar',
|
||||
peoplePortraitGenerateHalf: 'Generate waist up',
|
||||
peoplePortraitGenerateFull: 'Generate full body',
|
||||
peoplePortraitGenerating: 'Generating…',
|
||||
peoplePortraitMissing: 'Not generated yet.',
|
||||
|
||||
@@ -298,6 +298,7 @@ export interface PersonCard {
|
||||
readonly hasLocker: boolean;
|
||||
readonly homeCount: number;
|
||||
readonly hasAvatar: boolean;
|
||||
readonly hasHalfBody: boolean;
|
||||
readonly hasFullBody: boolean;
|
||||
}
|
||||
|
||||
@@ -400,13 +401,16 @@ export async function fetchGameStatus(): Promise<GameStatus> {
|
||||
return request<GameStatus>('/api/status');
|
||||
}
|
||||
|
||||
export type PortraitKind = 'avatar' | 'half' | 'full';
|
||||
|
||||
export interface PortraitResult {
|
||||
readonly kind: 'avatar' | 'full';
|
||||
readonly kind: PortraitKind;
|
||||
readonly hasAvatar: boolean;
|
||||
readonly hasHalfBody: boolean;
|
||||
readonly hasFullBody: boolean;
|
||||
}
|
||||
|
||||
export function portraitUrl(schoolId: number, personId: string, kind: 'avatar' | 'full'): string {
|
||||
export function portraitUrl(schoolId: number, personId: string, kind: PortraitKind): string {
|
||||
const params = new URLSearchParams({ kind });
|
||||
return `/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/portrait?${params.toString()}`;
|
||||
}
|
||||
@@ -414,7 +418,7 @@ export function portraitUrl(schoolId: number, personId: string, kind: 'avatar' |
|
||||
export async function generatePortrait(
|
||||
schoolId: number,
|
||||
personId: string,
|
||||
kind: 'avatar' | 'full',
|
||||
kind: PortraitKind,
|
||||
): Promise<PortraitResult> {
|
||||
const params = new URLSearchParams({ kind });
|
||||
return request<PortraitResult>(
|
||||
|
||||
@@ -634,7 +634,8 @@ body {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.people__portrait--full {
|
||||
.people__portrait--full,
|
||||
.people__portrait--half {
|
||||
width: min(100%, 384px);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ function personCard(): PersonCard {
|
||||
hasLocker: false,
|
||||
homeCount: 0,
|
||||
hasAvatar: false,
|
||||
hasHalfBody: false,
|
||||
hasFullBody: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
|
||||
hasLocker: false,
|
||||
homeCount: 2,
|
||||
hasAvatar: false,
|
||||
hasHalfBody: false,
|
||||
hasFullBody: false,
|
||||
...overrides,
|
||||
};
|
||||
@@ -171,6 +172,8 @@ describe('renderPersonCard', () => {
|
||||
const panel = root.querySelector('.people__portrait-panel');
|
||||
expect(panel).not.toBeNull();
|
||||
expect(panel?.textContent).toContain(t('peoplePortraitGenerateAvatar'));
|
||||
expect(panel?.textContent).toContain(t('peoplePortraitGenerateHalf'));
|
||||
expect(panel?.textContent).toContain(t('peoplePortraitGenerateFull'));
|
||||
expect(panel?.hasAttribute('hidden')).toBe(false);
|
||||
expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
PersonRel,
|
||||
PersonRole,
|
||||
} from '../net/api.ts';
|
||||
import { portraitUrl } from '../net/api.ts';
|
||||
import { portraitUrl, type PortraitKind } from '../net/api.ts';
|
||||
import { formatGameTimeOfDay } from '../format/gameTime.ts';
|
||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
@@ -31,8 +31,8 @@ export interface RenderPersonCardOptions {
|
||||
readonly onLogDir?: (dir: PersonLogDir) => void;
|
||||
readonly onLogPage?: (page: number) => void;
|
||||
readonly schoolId?: number | null;
|
||||
readonly onGeneratePortrait?: (kind: 'avatar' | 'full') => void;
|
||||
readonly portraitBusy?: 'avatar' | 'full' | null;
|
||||
readonly onGeneratePortrait?: (kind: PortraitKind) => void;
|
||||
readonly portraitBusy?: PortraitKind | null;
|
||||
readonly portraitError?: string | null;
|
||||
readonly swarmConfigured?: boolean;
|
||||
/** null while checking or when SwarmUI is not configured. */
|
||||
@@ -343,29 +343,19 @@ function fillNow(
|
||||
function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPersonCardOptions): void {
|
||||
parent.append(swarmStatusLine(options));
|
||||
|
||||
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitAvatar') }));
|
||||
parent.append(portraitPreview(card, options, 'avatar'));
|
||||
parent.append(
|
||||
el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
text: options.portraitBusy === 'avatar' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateAvatar'),
|
||||
disabled: (options.portraitBusy ?? null) !== null || !portraitGenerateEnabled(options),
|
||||
onClick: () => options.onGeneratePortrait?.('avatar'),
|
||||
}),
|
||||
);
|
||||
|
||||
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitFull') }));
|
||||
parent.append(portraitPreview(card, options, 'full'));
|
||||
parent.append(
|
||||
el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
text: options.portraitBusy === 'full' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateFull'),
|
||||
disabled: (options.portraitBusy ?? null) !== null || !portraitGenerateEnabled(options),
|
||||
onClick: () => options.onGeneratePortrait?.('full'),
|
||||
}),
|
||||
);
|
||||
for (const variant of PORTRAIT_VARIANTS) {
|
||||
parent.append(el('h4', { class: 'people__section-title', text: t(variant.titleKey) }));
|
||||
parent.append(portraitPreview(card, options, variant));
|
||||
parent.append(
|
||||
el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
text: options.portraitBusy === variant.kind ? t('peoplePortraitGenerating') : t(variant.generateKey),
|
||||
disabled: (options.portraitBusy ?? null) !== null || !portraitGenerateEnabled(options),
|
||||
onClick: () => options.onGeneratePortrait?.(variant.kind),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.swarmConfigured === false) {
|
||||
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
|
||||
@@ -379,6 +369,36 @@ function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPers
|
||||
}
|
||||
}
|
||||
|
||||
const PORTRAIT_VARIANTS: readonly {
|
||||
readonly kind: PortraitKind;
|
||||
readonly titleKey: MessageKey;
|
||||
readonly generateKey: MessageKey;
|
||||
readonly cssClass: string;
|
||||
readonly hasImage: (card: PersonCard) => boolean;
|
||||
}[] = [
|
||||
{
|
||||
kind: 'avatar',
|
||||
titleKey: 'peoplePortraitAvatar',
|
||||
generateKey: 'peoplePortraitGenerateAvatar',
|
||||
cssClass: 'people__portrait--avatar',
|
||||
hasImage: (card) => card.hasAvatar,
|
||||
},
|
||||
{
|
||||
kind: 'half',
|
||||
titleKey: 'peoplePortraitHalf',
|
||||
generateKey: 'peoplePortraitGenerateHalf',
|
||||
cssClass: 'people__portrait--half',
|
||||
hasImage: (card) => card.hasHalfBody,
|
||||
},
|
||||
{
|
||||
kind: 'full',
|
||||
titleKey: 'peoplePortraitFull',
|
||||
generateKey: 'peoplePortraitGenerateFull',
|
||||
cssClass: 'people__portrait--full',
|
||||
hasImage: (card) => card.hasFullBody,
|
||||
},
|
||||
];
|
||||
|
||||
function portraitGenerateEnabled(options: RenderPersonCardOptions): boolean {
|
||||
if (options.swarmConfigured !== true) {
|
||||
return false;
|
||||
@@ -403,13 +423,16 @@ function swarmStatusLine(options: RenderPersonCardOptions): HTMLElement {
|
||||
return el('p', { class: 'people__swarm-status people__swarm-status--pending', text: t('peoplePortraitSwarmChecking') });
|
||||
}
|
||||
|
||||
function portraitPreview(card: PersonCard, options: RenderPersonCardOptions, kind: 'avatar' | 'full'): HTMLElement {
|
||||
const hasImage = kind === 'avatar' ? card.hasAvatar : card.hasFullBody;
|
||||
if (hasImage && options.schoolId !== null && options.schoolId !== undefined) {
|
||||
function portraitPreview(
|
||||
card: PersonCard,
|
||||
options: RenderPersonCardOptions,
|
||||
variant: (typeof PORTRAIT_VARIANTS)[number],
|
||||
): HTMLElement {
|
||||
if (variant.hasImage(card) && options.schoolId !== null && options.schoolId !== undefined) {
|
||||
return el('img', {
|
||||
class: kind === 'avatar' ? 'people__portrait people__portrait--avatar' : 'people__portrait people__portrait--full',
|
||||
class: `people__portrait ${variant.cssClass}`,
|
||||
alt: card.fullName,
|
||||
src: portraitUrl(options.schoolId, card.id, kind),
|
||||
src: portraitUrl(options.schoolId, card.id, variant.kind),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type PersonLogDir,
|
||||
type PersonLogPage,
|
||||
type PersonLogQuery,
|
||||
type PortraitKind,
|
||||
} from '../net/api.ts';
|
||||
import { getLocale } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
/** Shared card tab state, portrait generation, Swarm availability and the day log. */
|
||||
export class PersonCardHost {
|
||||
private tab: PersonCardTab = 'overview';
|
||||
private portraitBusy: 'avatar' | 'full' | null = null;
|
||||
private portraitBusy: PortraitKind | null = null;
|
||||
private portraitError: string | null = null;
|
||||
private swarmConfigured = false;
|
||||
private swarmConnected: boolean | null = null;
|
||||
@@ -168,7 +169,7 @@ export class PersonCardHost {
|
||||
}
|
||||
}
|
||||
|
||||
private async generate(kind: 'avatar' | 'full'): Promise<void> {
|
||||
private async generate(kind: PortraitKind): Promise<void> {
|
||||
const schoolId = this.schoolId;
|
||||
const personId = this.painted?.id;
|
||||
if (schoolId === null || personId === undefined || this.portraitBusy !== null) {
|
||||
@@ -185,6 +186,7 @@ export class PersonCardHost {
|
||||
this.painted = {
|
||||
...card,
|
||||
hasAvatar: result.hasAvatar,
|
||||
hasHalfBody: result.hasHalfBody,
|
||||
hasFullBody: result.hasFullBody,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user