Split SchoolWorker and the person card along existing seams without a second thread or public API.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,18 +1,15 @@
|
||||
import type {
|
||||
PersonCard,
|
||||
PersonListItem,
|
||||
PersonLogDir,
|
||||
PersonLogPage,
|
||||
PersonLogQuery,
|
||||
PersonOpinionLink,
|
||||
PersonRel,
|
||||
PersonRole,
|
||||
} from '../net/api.ts';
|
||||
import type { PersonCard, PersonListItem, PersonLogDir, PersonLogPage, PersonLogQuery, PersonRole } from '../net/api.ts';
|
||||
import { portraitUrl, type PortraitKind, type PortraitPrompt } from '../net/api.ts';
|
||||
import { formatGameTimeOfDay } from '../format/gameTime.ts';
|
||||
import { talkCircleText } from '../format/talkCircle.ts';
|
||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
import { el } from './dom.ts';
|
||||
import { fillApparel } from './personCardApparel.ts';
|
||||
import { fillCarry } from './personCardCarry.ts';
|
||||
import { fillConnections } from './personCardConnections.ts';
|
||||
import { fillNow } from './personCardNow.ts';
|
||||
import { fillOverview } from './personCardOverview.ts';
|
||||
import { fillPortrait } from './personCardPortrait.ts';
|
||||
|
||||
export { nowActivityText } from './personCardNow.ts';
|
||||
|
||||
const ROLE_KEYS: Record<PersonRole, MessageKey> = {
|
||||
student: 'peopleRoleStudent',
|
||||
@@ -72,28 +69,6 @@ export function placement(person: Pick<PersonListItem, 'classYear' | 'classLette
|
||||
return parts.length > 0 ? parts.join(' · ') : '—';
|
||||
}
|
||||
|
||||
export function nowActivityText(
|
||||
card: PersonCard,
|
||||
away: boolean,
|
||||
names: ReadonlyMap<string, string> = new Map(),
|
||||
): string {
|
||||
const talk = talkCircleText(
|
||||
card.id,
|
||||
card.talkCircleMemberIds,
|
||||
card.talkTopicId ?? '',
|
||||
names,
|
||||
);
|
||||
if (talk.length > 0) {
|
||||
return talk;
|
||||
}
|
||||
|
||||
if (away && (card.activityLabel === null || card.activityLabel.length === 0)) {
|
||||
return t('peopleAtHome');
|
||||
}
|
||||
|
||||
return card.activityLabel ?? '';
|
||||
}
|
||||
|
||||
export function renderPersonCard(
|
||||
parent: HTMLElement,
|
||||
card: PersonCard,
|
||||
@@ -186,459 +161,6 @@ export function renderPersonCard(
|
||||
parent.append(tabs, panels.overview, panels.apparel, panels.carry, panels.now, panels.connections, panels.portrait);
|
||||
}
|
||||
|
||||
function fillOverview(parent: HTMLElement, card: PersonCard, onRelative: (id: string) => void): void {
|
||||
appendPairs(parent, t('peopleBody'), card.body);
|
||||
appendPairs(parent, t('peopleSkills'), card.skills);
|
||||
appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label));
|
||||
appendNeeds(parent, t('peopleNeeds'), card.needs);
|
||||
|
||||
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);
|
||||
if (family.childElementCount > 1) {
|
||||
parent.append(family);
|
||||
}
|
||||
}
|
||||
|
||||
function fillApparel(parent: HTMLElement, card: PersonCard): void {
|
||||
parent.append(el('p', { class: 'people__fit', text: t('peopleApparelFit', { value: '—' }) }));
|
||||
if (card.worn.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = el('div', { class: 'people__garments' });
|
||||
for (const row of card.worn) {
|
||||
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' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pair' },
|
||||
el('dt', { text: layers.length > 0 ? layers : row.label }),
|
||||
el('dd', { 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);
|
||||
const caption = row.conditionLabel.length > 0 ? row.conditionLabel : '—';
|
||||
garment.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__wear' },
|
||||
el('span', { class: 'people__wear-label', text: caption }),
|
||||
el('span', { class: 'people__need-track' }, fill),
|
||||
),
|
||||
);
|
||||
|
||||
list.append(garment);
|
||||
}
|
||||
|
||||
parent.append(list);
|
||||
}
|
||||
|
||||
function fillCarry(parent: HTMLElement, card: PersonCard): void {
|
||||
parent.append(
|
||||
el('p', {
|
||||
class: 'people__carry-mass',
|
||||
text: t('peopleCarryMass', { held: formatMass(card.carryMass), cap: formatMass(card.carryCapacity) }),
|
||||
}),
|
||||
el('p', {
|
||||
class: 'people__locker',
|
||||
text: card.hasLocker ? t('peopleLockerYes') : t('peopleLockerNone'),
|
||||
}),
|
||||
el('p', {
|
||||
class: 'people__home-count',
|
||||
text: t('peopleHomeCount', { n: card.homeCount }),
|
||||
}),
|
||||
);
|
||||
if (card.carried.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = el('dl', { class: 'people__pairs' });
|
||||
for (const row of card.carried) {
|
||||
const extra = row.subjectLabel ?? row.colorLabel;
|
||||
const value = extra !== null && extra.length > 0 ? `${row.label} · ${extra}` : row.label;
|
||||
grid.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pair' },
|
||||
el('dt', { text: value }),
|
||||
el('dd', { text: formatMass(row.mass) }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(grid );
|
||||
}
|
||||
|
||||
function fillConnections(
|
||||
parent: HTMLElement,
|
||||
card: PersonCard,
|
||||
onRelative: (id: string) => void,
|
||||
options: RenderPersonCardOptions,
|
||||
): void {
|
||||
const connections = card.connections;
|
||||
if (connections === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (card.orientation != null) {
|
||||
const orientation = section(t('peopleOrientation'));
|
||||
orientation.append(el('p', { class: 'people__orientation', text: card.orientation.label }));
|
||||
parent.append(orientation);
|
||||
}
|
||||
|
||||
const family = section(t('peopleFamily'));
|
||||
appendRelativesWithOpinion(family, t('peopleParents'), connections.family.parents, onRelative);
|
||||
appendRelativesWithOpinion(family, t('peopleChildren'), connections.family.children, onRelative);
|
||||
appendRelativesWithOpinion(family, t('peopleSiblings'), connections.family.siblings, onRelative);
|
||||
appendRelativesWithOpinion(family, t('peoplePartners'), connections.family.partners, onRelative);
|
||||
if (family.childElementCount > 1) {
|
||||
parent.append(family);
|
||||
}
|
||||
|
||||
if (connections.pair != null) {
|
||||
appendRelativesWithOpinion(parent, t('peopleConnectionsPair'), [connections.pair], onRelative);
|
||||
}
|
||||
|
||||
const crushes = connections.crushes ?? [];
|
||||
const admirers = connections.admirers ?? [];
|
||||
if (card.orientation != null || crushes.length > 0 || admirers.length > 0) {
|
||||
appendOpinionLinks(parent, t('peopleConnectionsCrushes'), crushes, onRelative);
|
||||
appendOpinionLinks(parent, t('peopleConnectionsAdmirers'), admirers, onRelative);
|
||||
}
|
||||
|
||||
appendOpinionLinks(parent, t('peopleConnectionsFriends'), connections.friends, onRelative);
|
||||
appendOpinionLinks(parent, t('peopleConnectionsEnemies'), connections.enemies, onRelative);
|
||||
|
||||
const search = el('input', { class: 'input people__input', type: 'search' });
|
||||
search.placeholder = t('peopleConnectionsSearch');
|
||||
search.value = options.connectionsQuery ?? '';
|
||||
const list = el('div', { class: 'people__connections-list' });
|
||||
const renderMatches = (): void => {
|
||||
clear(list);
|
||||
const query = search.value.trim().toLocaleLowerCase();
|
||||
const matches = connections.others.filter((row) =>
|
||||
query.length === 0 ? true : row.fullName.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
list.append(el('p', { class: 'panel__empty', text: t('peopleConnectionsEmpty') }));
|
||||
return;
|
||||
}
|
||||
|
||||
appendOpinionLinks(list, '', matches, onRelative, false);
|
||||
};
|
||||
|
||||
search.addEventListener('input', () => {
|
||||
options.onConnectionsSearch?.(search.value);
|
||||
renderMatches();
|
||||
});
|
||||
parent.append(search, list);
|
||||
renderMatches();
|
||||
}
|
||||
|
||||
function fillNow(
|
||||
parent: HTMLElement,
|
||||
card: PersonCard,
|
||||
away: boolean,
|
||||
log: PersonLogPage | null | undefined,
|
||||
options: RenderPersonCardOptions,
|
||||
): void {
|
||||
const activity = nowActivityText(card, away, options.personNames);
|
||||
if (activity.length > 0) {
|
||||
parent.append(el('p', { class: 'people__now-activity', text: activity }));
|
||||
}
|
||||
|
||||
if (log === null || log === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const search = el('input', { class: 'input people__input', type: 'search' });
|
||||
search.placeholder = t('peopleLogSearch');
|
||||
search.value = options.logQuery?.q ?? '';
|
||||
search.addEventListener('change', () => options.onLogSearch?.(search.value));
|
||||
|
||||
const dir = el('select', { class: 'input people__input' });
|
||||
const desc = el('option', { text: t('peopleLogNewest') });
|
||||
desc.value = 'desc';
|
||||
const asc = el('option', { text: t('peopleLogOldest') });
|
||||
asc.value = 'asc';
|
||||
dir.append(desc, asc);
|
||||
dir.value = options.logQuery?.dir ?? 'desc';
|
||||
dir.addEventListener('change', () => options.onLogDir?.(dir.value === 'asc' ? 'asc' : 'desc'));
|
||||
|
||||
const table = el('table', { class: 'people__log' });
|
||||
const body = el('tbody');
|
||||
table.append(
|
||||
el(
|
||||
'thead',
|
||||
{},
|
||||
el(
|
||||
'tr',
|
||||
{},
|
||||
el('th', { text: t('peopleLogTime') }),
|
||||
el('th', { text: t('peopleLogEvent') }),
|
||||
),
|
||||
),
|
||||
body,
|
||||
);
|
||||
if (log.entries.length === 0) {
|
||||
parent.append(
|
||||
el('div', { class: 'people__log-tools' }, search, dir),
|
||||
el('p', { class: 'panel__empty', text: t('peopleLogEmpty') }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of log.entries) {
|
||||
body.append(
|
||||
el(
|
||||
'tr',
|
||||
{},
|
||||
el('td', { text: formatGameTimeOfDay(new Date(row.time)) }),
|
||||
el('td', { text: row.label }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const pages = Math.max(1, Math.ceil(log.total / log.pageSize));
|
||||
const prev = el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
text: t('peoplePrev'),
|
||||
disabled: log.page <= 1,
|
||||
onClick: () => options.onLogPage?.(log.page - 1),
|
||||
});
|
||||
const next = el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
text: t('peopleNext'),
|
||||
disabled: log.page >= pages,
|
||||
onClick: () => options.onLogPage?.(log.page + 1),
|
||||
});
|
||||
parent.append(
|
||||
el('div', { class: 'people__log-tools' }, search, dir),
|
||||
table,
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pager' },
|
||||
prev,
|
||||
el('span', { class: 'people__pager-label', text: t('peoplePager', { page: log.page, pages, total: log.total }) }),
|
||||
next,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPersonCardOptions): void {
|
||||
parent.append(swarmStatusLine(options));
|
||||
|
||||
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),
|
||||
}),
|
||||
);
|
||||
parent.append(portraitPromptView(variant.kind, options));
|
||||
}
|
||||
|
||||
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitCustom') }));
|
||||
parent.append(
|
||||
el(
|
||||
'label',
|
||||
{ class: 'people__field' },
|
||||
el('span', { class: 'people__label', text: t('peoplePortraitCustomHint') }),
|
||||
el('textarea', {
|
||||
class: 'input people__portrait-prompt-input',
|
||||
rows: 3,
|
||||
placeholder: t('peoplePortraitCustomPlaceholder'),
|
||||
value: options.customPrompt ?? card.customPortraitPrompt ?? '',
|
||||
disabled: (options.portraitBusy ?? null) !== null,
|
||||
onInput: (event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLTextAreaElement) {
|
||||
options.onCustomPromptChange?.(target.value);
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
parent.append(portraitCustomPreview(card, options));
|
||||
const customPrompt = (options.customPrompt ?? card.customPortraitPrompt ?? '').trim();
|
||||
parent.append(
|
||||
el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
dataset: { portraitAction: 'generate-custom' },
|
||||
text:
|
||||
options.portraitBusy === 'custom'
|
||||
? t('peoplePortraitGenerating')
|
||||
: card.hasCustom
|
||||
? t('peoplePortraitRegenerateCustom')
|
||||
: t('peoplePortraitGenerateCustom'),
|
||||
disabled:
|
||||
(options.portraitBusy ?? null) !== null ||
|
||||
!portraitGenerateEnabled(options) ||
|
||||
customPrompt.length === 0,
|
||||
onClick: () => options.onGeneratePortrait?.('custom'),
|
||||
}),
|
||||
);
|
||||
if (customPrompt.length > 0 || card.hasCustom) {
|
||||
parent.append(portraitPromptView('custom', options));
|
||||
}
|
||||
|
||||
if (options.swarmConfigured === false) {
|
||||
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
|
||||
} else if (options.swarmConfigured === true && options.swarmConnected === false) {
|
||||
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitSwarmOffline') }));
|
||||
}
|
||||
|
||||
const portraitError = options.portraitError;
|
||||
if (portraitError !== undefined && portraitError !== null && portraitError.length > 0) {
|
||||
parent.append(el('p', { class: 'panel__error', text: portraitError }));
|
||||
}
|
||||
|
||||
const promptError = options.portraitPromptError;
|
||||
if (promptError !== undefined && promptError !== null && promptError.length > 0) {
|
||||
parent.append(el('p', { class: 'panel__error', text: promptError }));
|
||||
}
|
||||
}
|
||||
|
||||
const PORTRAIT_VARIANTS: readonly {
|
||||
readonly kind: Extract<PortraitKind, 'avatar' | 'full'>;
|
||||
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: 'full',
|
||||
titleKey: 'peoplePortraitFull',
|
||||
generateKey: 'peoplePortraitGenerateFull',
|
||||
cssClass: 'people__portrait--full',
|
||||
hasImage: (card) => card.hasFullBody,
|
||||
},
|
||||
];
|
||||
|
||||
function portraitPromptView(
|
||||
kind: PortraitKind,
|
||||
options: RenderPersonCardOptions,
|
||||
): HTMLElement {
|
||||
const open = options.portraitPromptOpen === kind;
|
||||
const loading = options.portraitPromptLoading === kind;
|
||||
const prompt = options.portraitPrompts?.[kind];
|
||||
const toggle = el('button', {
|
||||
class: 'button button--small people__portrait-prompt-toggle',
|
||||
type: 'button',
|
||||
text: open ? t('peoplePortraitHidePrompt') : t('peoplePortraitShowPrompt'),
|
||||
disabled: (options.portraitPromptLoading ?? null) !== null && !loading,
|
||||
onClick: () => options.onShowPortraitPrompt?.(kind),
|
||||
});
|
||||
|
||||
if (!open && !loading) {
|
||||
return el('div', { class: 'people__portrait-prompt-wrap' }, toggle);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'people__portrait-prompt-wrap' },
|
||||
toggle,
|
||||
el('p', { class: 'people__portrait-prompt-loading', text: t('peoplePortraitPromptLoading') }),
|
||||
);
|
||||
}
|
||||
|
||||
if (prompt === undefined) {
|
||||
return el('div', { class: 'people__portrait-prompt-wrap' }, toggle);
|
||||
}
|
||||
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'people__portrait-prompt-wrap' },
|
||||
toggle,
|
||||
el('div', { class: 'people__portrait-prompt-view' },
|
||||
el('p', { class: 'people__portrait-prompt-meta', text: t('peoplePortraitPromptPreset', { label: prompt.presetLabel || prompt.presetId }) }),
|
||||
el('p', { class: 'people__label', text: t('peoplePortraitPromptPositive') }),
|
||||
el('pre', { class: 'people__portrait-prompt-text', text: prompt.positive }),
|
||||
el('p', { class: 'people__label', text: t('peoplePortraitPromptNegative') }),
|
||||
el('pre', { class: 'people__portrait-prompt-text people__portrait-prompt-text--muted', text: prompt.negative }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function portraitGenerateEnabled(options: RenderPersonCardOptions): boolean {
|
||||
if (options.swarmConfigured !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return options.swarmConnected === true;
|
||||
}
|
||||
|
||||
function swarmStatusLine(options: RenderPersonCardOptions): HTMLElement {
|
||||
if (options.swarmConfigured === false) {
|
||||
return el('p', { class: 'people__swarm-status people__swarm-status--off', text: t('peoplePortraitSwarmDisabled') });
|
||||
}
|
||||
|
||||
if (options.swarmConnected === true) {
|
||||
return el('p', { class: 'people__swarm-status people__swarm-status--ok', text: t('peoplePortraitSwarmConnected') });
|
||||
}
|
||||
|
||||
if (options.swarmConnected === false) {
|
||||
return el('p', { class: 'people__swarm-status people__swarm-status--bad', text: t('peoplePortraitSwarmDisconnected') });
|
||||
}
|
||||
|
||||
return el('p', { class: 'people__swarm-status people__swarm-status--pending', text: t('peoplePortraitSwarmChecking') });
|
||||
}
|
||||
|
||||
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: `people__portrait ${variant.cssClass}`,
|
||||
alt: card.fullName,
|
||||
src: portraitUrl(options.schoolId, card.id, variant.kind),
|
||||
});
|
||||
}
|
||||
|
||||
return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') });
|
||||
}
|
||||
|
||||
function portraitCustomPreview(card: PersonCard, options: RenderPersonCardOptions): HTMLElement {
|
||||
if (card.hasCustom && options.schoolId !== null && options.schoolId !== undefined) {
|
||||
return el('img', {
|
||||
class: 'people__portrait people__portrait--custom',
|
||||
alt: card.fullName,
|
||||
src: portraitUrl(options.schoolId, card.id, 'custom', options.customPortraitRevision),
|
||||
});
|
||||
}
|
||||
|
||||
return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') });
|
||||
}
|
||||
|
||||
function cardMeta(card: PersonCard): string {
|
||||
const bits = [
|
||||
roleLabels(card.roles),
|
||||
@@ -660,197 +182,3 @@ export function formatPersonPlace(kind: 'here' | 'walking' | 'away', nodeName =
|
||||
|
||||
return t('presenceAt', { name: nodeName });
|
||||
}
|
||||
|
||||
function section(title: string): HTMLElement {
|
||||
return el('div', { class: 'people__section' }, el('h4', { class: 'people__section-title', text: title }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Name and value in two columns, several columns per row. Fourteen skills as a bulleted list ran
|
||||
* the card past the fold while three quarters of its width sat empty.
|
||||
*/
|
||||
function appendPairs(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
rows: readonly { readonly label: string; readonly value: string }[],
|
||||
): void {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = el('dl', { class: 'people__pairs' });
|
||||
for (const row of rows) {
|
||||
grid.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pair' },
|
||||
el('dt', { text: row.label }),
|
||||
el('dd', { text: row.value }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(section(title), grid);
|
||||
}
|
||||
|
||||
function formatMass(value: number): string {
|
||||
return String(Math.round(value * 100) / 100);
|
||||
}
|
||||
|
||||
function appendTags(parent: HTMLElement, title: string, values: readonly string[]): void {
|
||||
if (values.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = el('div', { class: 'people__tags' });
|
||||
for (const value of values) {
|
||||
tags.append(el('span', { class: 'people__tag', text: value }));
|
||||
}
|
||||
|
||||
parent.append(section(title), tags);
|
||||
}
|
||||
|
||||
function appendNeeds(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
rows: readonly { readonly label: string; readonly value: number }[],
|
||||
): void {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = el('div', { class: 'people__needs' });
|
||||
for (const row of rows) {
|
||||
const share = Math.max(0, Math.min(1, row.value));
|
||||
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);
|
||||
|
||||
grid.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__need' },
|
||||
el('span', { class: 'people__need-label', text: row.label }),
|
||||
el('span', { class: 'people__need-value', text: `${Math.round(share * 100)}%` }),
|
||||
el('span', { class: 'people__need-track' }, fill),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(section(title), grid);
|
||||
}
|
||||
|
||||
function appendRelatives(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
relatives: readonly PersonRel[],
|
||||
open: (id: string) => void,
|
||||
): void {
|
||||
if (relatives.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = el('span', { class: 'people__rel-list' });
|
||||
for (const relative of relatives) {
|
||||
list.append(
|
||||
el('button', {
|
||||
class: 'people__link',
|
||||
type: 'button',
|
||||
text: relative.fullName,
|
||||
onClick: () => open(relative.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__rel' },
|
||||
el('span', { class: 'people__rel-title', text: title }),
|
||||
list,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function appendRelativesWithOpinion(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
relatives: readonly PersonRel[],
|
||||
open: (id: string) => void,
|
||||
): void {
|
||||
if (relatives.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = el('span', { class: 'people__rel-list' });
|
||||
for (const relative of relatives) {
|
||||
const caption =
|
||||
relative.opinion !== null &&
|
||||
relative.opinion !== undefined &&
|
||||
relative.opinionLabel !== null &&
|
||||
relative.opinionLabel !== undefined
|
||||
? t('peopleOpinionValue', { label: relative.opinionLabel, value: relative.opinion })
|
||||
: relative.fullName;
|
||||
list.append(
|
||||
el('button', {
|
||||
class: 'people__link',
|
||||
type: 'button',
|
||||
text: caption,
|
||||
title: relative.fullName,
|
||||
onClick: () => open(relative.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__rel' },
|
||||
el('span', { class: 'people__rel-title', text: title }),
|
||||
list,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function appendOpinionLinks(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
rows: readonly PersonOpinionLink[],
|
||||
open: (id: string) => void,
|
||||
titled = true,
|
||||
): void {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const block = el('div', { class: 'people__connections-block' });
|
||||
if (titled && title.length > 0) {
|
||||
block.append(el('h4', { class: 'people__section-title', text: title }));
|
||||
}
|
||||
|
||||
const list = el('dl', { class: 'people__pairs' });
|
||||
for (const row of rows) {
|
||||
list.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pair' },
|
||||
el(
|
||||
'dt',
|
||||
{},
|
||||
el('button', {
|
||||
class: 'people__link',
|
||||
type: 'button',
|
||||
text: row.fullName,
|
||||
onClick: () => open(row.id),
|
||||
}),
|
||||
),
|
||||
el('dd', {
|
||||
text: t('peopleOpinionValue', { label: row.opinionLabel, value: row.opinion }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
block.append(list);
|
||||
parent.append(block);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { PersonCard } from '../net/api.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
|
||||
export function fillApparel(parent: HTMLElement, card: PersonCard): void {
|
||||
parent.append(el('p', { class: 'people__fit', text: t('peopleApparelFit', { value: '—' }) }));
|
||||
if (card.worn.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = el('div', { class: 'people__garments' });
|
||||
for (const row of card.worn) {
|
||||
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' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pair' },
|
||||
el('dt', { text: layers.length > 0 ? layers : row.label }),
|
||||
el('dd', { 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);
|
||||
const caption = row.conditionLabel.length > 0 ? row.conditionLabel : '—';
|
||||
garment.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__wear' },
|
||||
el('span', { class: 'people__wear-label', text: caption }),
|
||||
el('span', { class: 'people__need-track' }, fill),
|
||||
),
|
||||
);
|
||||
|
||||
list.append(garment);
|
||||
}
|
||||
|
||||
parent.append(list);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { PersonCard } from '../net/api.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
import { formatMass } from './personCardDom.ts';
|
||||
|
||||
export function fillCarry(parent: HTMLElement, card: PersonCard): void {
|
||||
parent.append(
|
||||
el('p', {
|
||||
class: 'people__carry-mass',
|
||||
text: t('peopleCarryMass', { held: formatMass(card.carryMass), cap: formatMass(card.carryCapacity) }),
|
||||
}),
|
||||
el('p', {
|
||||
class: 'people__locker',
|
||||
text: card.hasLocker ? t('peopleLockerYes') : t('peopleLockerNone'),
|
||||
}),
|
||||
el('p', {
|
||||
class: 'people__home-count',
|
||||
text: t('peopleHomeCount', { n: card.homeCount }),
|
||||
}),
|
||||
);
|
||||
if (card.carried.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = el('dl', { class: 'people__pairs' });
|
||||
for (const row of card.carried) {
|
||||
const extra = row.subjectLabel ?? row.colorLabel;
|
||||
const value = extra !== null && extra.length > 0 ? `${row.label} · ${extra}` : row.label;
|
||||
grid.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pair' },
|
||||
el('dt', { text: value }),
|
||||
el('dd', { text: formatMass(row.mass) }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(grid);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { PersonCard } from '../net/api.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { clear, el } from './dom.ts';
|
||||
import { appendOpinionLinks, appendRelativesWithOpinion, section } from './personCardDom.ts';
|
||||
import type { RenderPersonCardOptions } from './personCard.ts';
|
||||
|
||||
export function fillConnections(
|
||||
parent: HTMLElement,
|
||||
card: PersonCard,
|
||||
onRelative: (id: string) => void,
|
||||
options: RenderPersonCardOptions,
|
||||
): void {
|
||||
const connections = card.connections;
|
||||
if (connections === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (card.orientation != null) {
|
||||
const orientation = section(t('peopleOrientation'));
|
||||
orientation.append(el('p', { class: 'people__orientation', text: card.orientation.label }));
|
||||
parent.append(orientation);
|
||||
}
|
||||
|
||||
const family = section(t('peopleFamily'));
|
||||
appendRelativesWithOpinion(family, t('peopleParents'), connections.family.parents, onRelative);
|
||||
appendRelativesWithOpinion(family, t('peopleChildren'), connections.family.children, onRelative);
|
||||
appendRelativesWithOpinion(family, t('peopleSiblings'), connections.family.siblings, onRelative);
|
||||
appendRelativesWithOpinion(family, t('peoplePartners'), connections.family.partners, onRelative);
|
||||
if (family.childElementCount > 1) {
|
||||
parent.append(family);
|
||||
}
|
||||
|
||||
if (connections.pair != null) {
|
||||
appendRelativesWithOpinion(parent, t('peopleConnectionsPair'), [connections.pair], onRelative);
|
||||
}
|
||||
|
||||
const crushes = connections.crushes ?? [];
|
||||
const admirers = connections.admirers ?? [];
|
||||
if (card.orientation != null || crushes.length > 0 || admirers.length > 0) {
|
||||
appendOpinionLinks(parent, t('peopleConnectionsCrushes'), crushes, onRelative);
|
||||
appendOpinionLinks(parent, t('peopleConnectionsAdmirers'), admirers, onRelative);
|
||||
}
|
||||
|
||||
appendOpinionLinks(parent, t('peopleConnectionsFriends'), connections.friends, onRelative);
|
||||
appendOpinionLinks(parent, t('peopleConnectionsEnemies'), connections.enemies, onRelative);
|
||||
|
||||
const search = el('input', { class: 'input people__input', type: 'search' });
|
||||
search.placeholder = t('peopleConnectionsSearch');
|
||||
search.value = options.connectionsQuery ?? '';
|
||||
const list = el('div', { class: 'people__connections-list' });
|
||||
const renderMatches = (): void => {
|
||||
clear(list);
|
||||
const query = search.value.trim().toLocaleLowerCase();
|
||||
const matches = connections.others.filter((row) =>
|
||||
query.length === 0 ? true : row.fullName.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
list.append(el('p', { class: 'panel__empty', text: t('peopleConnectionsEmpty') }));
|
||||
return;
|
||||
}
|
||||
|
||||
appendOpinionLinks(list, '', matches, onRelative, false);
|
||||
};
|
||||
|
||||
search.addEventListener('input', () => {
|
||||
options.onConnectionsSearch?.(search.value);
|
||||
renderMatches();
|
||||
});
|
||||
parent.append(search, list);
|
||||
renderMatches();
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { PersonOpinionLink, PersonRel } from '../net/api.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
|
||||
export function section(title: string): HTMLElement {
|
||||
return el('div', { class: 'people__section' }, el('h4', { class: 'people__section-title', text: title }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Name and value in two columns, several columns per row. Fourteen skills as a bulleted list ran
|
||||
* the card past the fold while three quarters of its width sat empty.
|
||||
*/
|
||||
export function appendPairs(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
rows: readonly { readonly label: string; readonly value: string }[],
|
||||
): void {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = el('dl', { class: 'people__pairs' });
|
||||
for (const row of rows) {
|
||||
grid.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pair' },
|
||||
el('dt', { text: row.label }),
|
||||
el('dd', { text: row.value }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(section(title), grid);
|
||||
}
|
||||
|
||||
export function formatMass(value: number): string {
|
||||
return String(Math.round(value * 100) / 100);
|
||||
}
|
||||
|
||||
export function appendTags(parent: HTMLElement, title: string, values: readonly string[]): void {
|
||||
if (values.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = el('div', { class: 'people__tags' });
|
||||
for (const value of values) {
|
||||
tags.append(el('span', { class: 'people__tag', text: value }));
|
||||
}
|
||||
|
||||
parent.append(section(title), tags);
|
||||
}
|
||||
|
||||
export function appendNeeds(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
rows: readonly { readonly label: string; readonly value: number }[],
|
||||
): void {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = el('div', { class: 'people__needs' });
|
||||
for (const row of rows) {
|
||||
const share = Math.max(0, Math.min(1, row.value));
|
||||
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);
|
||||
|
||||
grid.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__need' },
|
||||
el('span', { class: 'people__need-label', text: row.label }),
|
||||
el('span', { class: 'people__need-value', text: `${Math.round(share * 100)}%` }),
|
||||
el('span', { class: 'people__need-track' }, fill),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(section(title), grid);
|
||||
}
|
||||
|
||||
export function appendRelatives(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
relatives: readonly PersonRel[],
|
||||
open: (id: string) => void,
|
||||
): void {
|
||||
if (relatives.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = el('span', { class: 'people__rel-list' });
|
||||
for (const relative of relatives) {
|
||||
list.append(
|
||||
el('button', {
|
||||
class: 'people__link',
|
||||
type: 'button',
|
||||
text: relative.fullName,
|
||||
onClick: () => open(relative.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__rel' },
|
||||
el('span', { class: 'people__rel-title', text: title }),
|
||||
list,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function appendRelativesWithOpinion(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
relatives: readonly PersonRel[],
|
||||
open: (id: string) => void,
|
||||
): void {
|
||||
if (relatives.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = el('span', { class: 'people__rel-list' });
|
||||
for (const relative of relatives) {
|
||||
const caption =
|
||||
relative.opinion !== null &&
|
||||
relative.opinion !== undefined &&
|
||||
relative.opinionLabel !== null &&
|
||||
relative.opinionLabel !== undefined
|
||||
? t('peopleOpinionValue', { label: relative.opinionLabel, value: relative.opinion })
|
||||
: relative.fullName;
|
||||
list.append(
|
||||
el('button', {
|
||||
class: 'people__link',
|
||||
type: 'button',
|
||||
text: caption,
|
||||
title: relative.fullName,
|
||||
onClick: () => open(relative.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
parent.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__rel' },
|
||||
el('span', { class: 'people__rel-title', text: title }),
|
||||
list,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function appendOpinionLinks(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
rows: readonly PersonOpinionLink[],
|
||||
open: (id: string) => void,
|
||||
titled = true,
|
||||
): void {
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const block = el('div', { class: 'people__connections-block' });
|
||||
if (titled && title.length > 0) {
|
||||
block.append(el('h4', { class: 'people__section-title', text: title }));
|
||||
}
|
||||
|
||||
const list = el('dl', { class: 'people__pairs' });
|
||||
for (const row of rows) {
|
||||
list.append(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pair' },
|
||||
el(
|
||||
'dt',
|
||||
{},
|
||||
el('button', {
|
||||
class: 'people__link',
|
||||
type: 'button',
|
||||
text: row.fullName,
|
||||
onClick: () => open(row.id),
|
||||
}),
|
||||
),
|
||||
el('dd', {
|
||||
text: t('peopleOpinionValue', { label: row.opinionLabel, value: row.opinion }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
block.append(list);
|
||||
parent.append(block);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { PersonCard, PersonLogPage } from '../net/api.ts';
|
||||
import { formatGameTimeOfDay } from '../format/gameTime.ts';
|
||||
import { talkCircleText } from '../format/talkCircle.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
import type { RenderPersonCardOptions } from './personCard.ts';
|
||||
|
||||
export function nowActivityText(
|
||||
card: PersonCard,
|
||||
away: boolean,
|
||||
names: ReadonlyMap<string, string> = new Map(),
|
||||
): string {
|
||||
const talk = talkCircleText(
|
||||
card.id,
|
||||
card.talkCircleMemberIds,
|
||||
card.talkTopicId ?? '',
|
||||
names,
|
||||
);
|
||||
if (talk.length > 0) {
|
||||
return talk;
|
||||
}
|
||||
|
||||
if (away && (card.activityLabel === null || card.activityLabel.length === 0)) {
|
||||
return t('peopleAtHome');
|
||||
}
|
||||
|
||||
return card.activityLabel ?? '';
|
||||
}
|
||||
|
||||
export function fillNow(
|
||||
parent: HTMLElement,
|
||||
card: PersonCard,
|
||||
away: boolean,
|
||||
log: PersonLogPage | null | undefined,
|
||||
options: RenderPersonCardOptions,
|
||||
): void {
|
||||
const activity = nowActivityText(card, away, options.personNames);
|
||||
if (activity.length > 0) {
|
||||
parent.append(el('p', { class: 'people__now-activity', text: activity }));
|
||||
}
|
||||
|
||||
if (log === null || log === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const search = el('input', { class: 'input people__input', type: 'search' });
|
||||
search.placeholder = t('peopleLogSearch');
|
||||
search.value = options.logQuery?.q ?? '';
|
||||
search.addEventListener('change', () => options.onLogSearch?.(search.value));
|
||||
|
||||
const dir = el('select', { class: 'input people__input' });
|
||||
const desc = el('option', { text: t('peopleLogNewest') });
|
||||
desc.value = 'desc';
|
||||
const asc = el('option', { text: t('peopleLogOldest') });
|
||||
asc.value = 'asc';
|
||||
dir.append(desc, asc);
|
||||
dir.value = options.logQuery?.dir ?? 'desc';
|
||||
dir.addEventListener('change', () => options.onLogDir?.(dir.value === 'asc' ? 'asc' : 'desc'));
|
||||
|
||||
const table = el('table', { class: 'people__log' });
|
||||
const body = el('tbody');
|
||||
table.append(
|
||||
el(
|
||||
'thead',
|
||||
{},
|
||||
el(
|
||||
'tr',
|
||||
{},
|
||||
el('th', { text: t('peopleLogTime') }),
|
||||
el('th', { text: t('peopleLogEvent') }),
|
||||
),
|
||||
),
|
||||
body,
|
||||
);
|
||||
if (log.entries.length === 0) {
|
||||
parent.append(
|
||||
el('div', { class: 'people__log-tools' }, search, dir),
|
||||
el('p', { class: 'panel__empty', text: t('peopleLogEmpty') }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of log.entries) {
|
||||
body.append(
|
||||
el(
|
||||
'tr',
|
||||
{},
|
||||
el('td', { text: formatGameTimeOfDay(new Date(row.time)) }),
|
||||
el('td', { text: row.label }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const pages = Math.max(1, Math.ceil(log.total / log.pageSize));
|
||||
const prev = el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
text: t('peoplePrev'),
|
||||
disabled: log.page <= 1,
|
||||
onClick: () => options.onLogPage?.(log.page - 1),
|
||||
});
|
||||
const next = el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
text: t('peopleNext'),
|
||||
disabled: log.page >= pages,
|
||||
onClick: () => options.onLogPage?.(log.page + 1),
|
||||
});
|
||||
parent.append(
|
||||
el('div', { class: 'people__log-tools' }, search, dir),
|
||||
table,
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__pager' },
|
||||
prev,
|
||||
el('span', { class: 'people__pager-label', text: t('peoplePager', { page: log.page, pages, total: log.total }) }),
|
||||
next,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { PersonCard } from '../net/api.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { appendNeeds, appendPairs, appendRelatives, appendTags, section } from './personCardDom.ts';
|
||||
|
||||
export function fillOverview(parent: HTMLElement, card: PersonCard, onRelative: (id: string) => void): void {
|
||||
appendPairs(parent, t('peopleBody'), card.body);
|
||||
appendPairs(parent, t('peopleSkills'), card.skills);
|
||||
appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label));
|
||||
appendNeeds(parent, t('peopleNeeds'), card.needs);
|
||||
|
||||
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);
|
||||
if (family.childElementCount > 1) {
|
||||
parent.append(family);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { PersonCard } from '../net/api.ts';
|
||||
import { portraitUrl, type PortraitKind } from '../net/api.ts';
|
||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||
import { el } from './dom.ts';
|
||||
import type { RenderPersonCardOptions } from './personCard.ts';
|
||||
|
||||
const PORTRAIT_VARIANTS: readonly {
|
||||
readonly kind: Extract<PortraitKind, 'avatar' | 'full'>;
|
||||
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: 'full',
|
||||
titleKey: 'peoplePortraitFull',
|
||||
generateKey: 'peoplePortraitGenerateFull',
|
||||
cssClass: 'people__portrait--full',
|
||||
hasImage: (card) => card.hasFullBody,
|
||||
},
|
||||
];
|
||||
|
||||
export function fillPortrait(parent: HTMLElement, card: PersonCard, options: RenderPersonCardOptions): void {
|
||||
parent.append(swarmStatusLine(options));
|
||||
|
||||
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),
|
||||
}),
|
||||
);
|
||||
parent.append(portraitPromptView(variant.kind, options));
|
||||
}
|
||||
|
||||
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitCustom') }));
|
||||
parent.append(
|
||||
el(
|
||||
'label',
|
||||
{ class: 'people__field' },
|
||||
el('span', { class: 'people__label', text: t('peoplePortraitCustomHint') }),
|
||||
el('textarea', {
|
||||
class: 'input people__portrait-prompt-input',
|
||||
rows: 3,
|
||||
placeholder: t('peoplePortraitCustomPlaceholder'),
|
||||
value: options.customPrompt ?? card.customPortraitPrompt ?? '',
|
||||
disabled: (options.portraitBusy ?? null) !== null,
|
||||
onInput: (event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLTextAreaElement) {
|
||||
options.onCustomPromptChange?.(target.value);
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
parent.append(portraitCustomPreview(card, options));
|
||||
const customPrompt = (options.customPrompt ?? card.customPortraitPrompt ?? '').trim();
|
||||
parent.append(
|
||||
el('button', {
|
||||
class: 'button button--small',
|
||||
type: 'button',
|
||||
dataset: { portraitAction: 'generate-custom' },
|
||||
text:
|
||||
options.portraitBusy === 'custom'
|
||||
? t('peoplePortraitGenerating')
|
||||
: card.hasCustom
|
||||
? t('peoplePortraitRegenerateCustom')
|
||||
: t('peoplePortraitGenerateCustom'),
|
||||
disabled:
|
||||
(options.portraitBusy ?? null) !== null ||
|
||||
!portraitGenerateEnabled(options) ||
|
||||
customPrompt.length === 0,
|
||||
onClick: () => options.onGeneratePortrait?.('custom'),
|
||||
}),
|
||||
);
|
||||
if (customPrompt.length > 0 || card.hasCustom) {
|
||||
parent.append(portraitPromptView('custom', options));
|
||||
}
|
||||
|
||||
if (options.swarmConfigured === false) {
|
||||
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
|
||||
} else if (options.swarmConfigured === true && options.swarmConnected === false) {
|
||||
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitSwarmOffline') }));
|
||||
}
|
||||
|
||||
const portraitError = options.portraitError;
|
||||
if (portraitError !== undefined && portraitError !== null && portraitError.length > 0) {
|
||||
parent.append(el('p', { class: 'panel__error', text: portraitError }));
|
||||
}
|
||||
|
||||
const promptError = options.portraitPromptError;
|
||||
if (promptError !== undefined && promptError !== null && promptError.length > 0) {
|
||||
parent.append(el('p', { class: 'panel__error', text: promptError }));
|
||||
}
|
||||
}
|
||||
|
||||
function portraitPromptView(kind: PortraitKind, options: RenderPersonCardOptions): HTMLElement {
|
||||
const open = options.portraitPromptOpen === kind;
|
||||
const loading = options.portraitPromptLoading === kind;
|
||||
const prompt = options.portraitPrompts?.[kind];
|
||||
const toggle = el('button', {
|
||||
class: 'button button--small people__portrait-prompt-toggle',
|
||||
type: 'button',
|
||||
text: open ? t('peoplePortraitHidePrompt') : t('peoplePortraitShowPrompt'),
|
||||
disabled: (options.portraitPromptLoading ?? null) !== null && !loading,
|
||||
onClick: () => options.onShowPortraitPrompt?.(kind),
|
||||
});
|
||||
|
||||
if (!open && !loading) {
|
||||
return el('div', { class: 'people__portrait-prompt-wrap' }, toggle);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'people__portrait-prompt-wrap' },
|
||||
toggle,
|
||||
el('p', { class: 'people__portrait-prompt-loading', text: t('peoplePortraitPromptLoading') }),
|
||||
);
|
||||
}
|
||||
|
||||
if (prompt === undefined) {
|
||||
return el('div', { class: 'people__portrait-prompt-wrap' }, toggle);
|
||||
}
|
||||
|
||||
return el(
|
||||
'div',
|
||||
{ class: 'people__portrait-prompt-wrap' },
|
||||
toggle,
|
||||
el(
|
||||
'div',
|
||||
{ class: 'people__portrait-prompt-view' },
|
||||
el('p', {
|
||||
class: 'people__portrait-prompt-meta',
|
||||
text: t('peoplePortraitPromptPreset', { label: prompt.presetLabel || prompt.presetId }),
|
||||
}),
|
||||
el('p', { class: 'people__label', text: t('peoplePortraitPromptPositive') }),
|
||||
el('pre', { class: 'people__portrait-prompt-text', text: prompt.positive }),
|
||||
el('p', { class: 'people__label', text: t('peoplePortraitPromptNegative') }),
|
||||
el('pre', {
|
||||
class: 'people__portrait-prompt-text people__portrait-prompt-text--muted',
|
||||
text: prompt.negative,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function portraitGenerateEnabled(options: RenderPersonCardOptions): boolean {
|
||||
if (options.swarmConfigured !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return options.swarmConnected === true;
|
||||
}
|
||||
|
||||
function swarmStatusLine(options: RenderPersonCardOptions): HTMLElement {
|
||||
if (options.swarmConfigured === false) {
|
||||
return el('p', { class: 'people__swarm-status people__swarm-status--off', text: t('peoplePortraitSwarmDisabled') });
|
||||
}
|
||||
|
||||
if (options.swarmConnected === true) {
|
||||
return el('p', { class: 'people__swarm-status people__swarm-status--ok', text: t('peoplePortraitSwarmConnected') });
|
||||
}
|
||||
|
||||
if (options.swarmConnected === false) {
|
||||
return el('p', {
|
||||
class: 'people__swarm-status people__swarm-status--bad',
|
||||
text: t('peoplePortraitSwarmDisconnected'),
|
||||
});
|
||||
}
|
||||
|
||||
return el('p', {
|
||||
class: 'people__swarm-status people__swarm-status--pending',
|
||||
text: t('peoplePortraitSwarmChecking'),
|
||||
});
|
||||
}
|
||||
|
||||
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: `people__portrait ${variant.cssClass}`,
|
||||
alt: card.fullName,
|
||||
src: portraitUrl(options.schoolId, card.id, variant.kind),
|
||||
});
|
||||
}
|
||||
|
||||
return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') });
|
||||
}
|
||||
|
||||
function portraitCustomPreview(card: PersonCard, options: RenderPersonCardOptions): HTMLElement {
|
||||
if (card.hasCustom && options.schoolId !== null && options.schoolId !== undefined) {
|
||||
return el('img', {
|
||||
class: 'people__portrait people__portrait--custom',
|
||||
alt: card.fullName,
|
||||
src: portraitUrl(options.schoolId, card.id, 'custom', options.customPortraitRevision),
|
||||
});
|
||||
}
|
||||
|
||||
return el('p', { class: 'panel__empty', text: t('peoplePortraitMissing') });
|
||||
}
|
||||
Reference in New Issue
Block a user