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:
Leonid Pershin
2026-08-20 13:51:39 +03:00
co-authored by Cursor
parent 8d07b3606d
commit b48bbbcf7c
18 changed files with 2307 additions and 2193 deletions
+10 -682
View File
@@ -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();
}
+197
View File
@@ -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);
}
+120
View File
@@ -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') });
}