Merge main into review/slice-7bc-climate-wear; journal 32-37.

Integrate stage A review from main; resolve reviewed.md; fix dressRulesPanel unused import, managementPanel test, PortraitApiTests URL.
This commit is contained in:
Leonid Pershin
2026-08-20 06:46:36 +03:00
44 changed files with 3009 additions and 232 deletions
+130 -4
View File
@@ -11,6 +11,44 @@ const ru = {
schoolsTitle: 'Школы',
createSchool: 'Создать школу',
settings: 'Настройки',
save: 'Сохранить',
settingsTitle: 'SwarmUI — пресеты портретов',
settingsActivePreset: 'Пресет по умолчанию',
settingsEditPreset: 'Редактируемый пресет',
settingsAddPreset: 'Добавить пресет',
settingsPresetLabel: 'Название пресета',
settingsModel: 'Модель',
settingsSteps: 'Steps',
settingsCfg: 'CFG scale',
settingsClipSkip: 'CLIP skip',
settingsSampler: 'Sampler',
settingsScheduler: 'Scheduler',
settingsSeed: 'Seed',
settingsPositive: 'Базовый positive',
settingsNegative: 'Negative',
settingsPositiveLoras: 'Positive LoRA',
settingsNegativeLoras: 'Negative LoRA',
settingsAddLora: 'Добавить LoRA',
settingsAvatarPreset: 'Аватар',
settingsCustomPreset: 'Свой промпт (размеры)',
settingsFullBodyPreset: 'В полный рост',
settingsWidth: 'Ширина',
settingsHeight: 'Высота',
settingsKindPositive: 'Positive для вида',
settingsAgeRules: 'Правила по возрасту',
settingsAgeMin: 'От',
settingsAgeMax: 'До',
settingsAgePreset: 'Пресет',
settingsAddAgeRule: 'Добавить правило',
settingsRemoveRule: 'Удалить',
settingsRefreshLists: 'Обновить списки из SwarmUI',
settingsSwarmReady: 'SwarmUI подключён — списки моделей и семплеров доступны.',
settingsSwarmOffline: 'SwarmUI недоступен — поля можно заполнить вручную.',
settingsSwarmNotConfigured: 'SwarmUI не настроен на сервере (SwarmUi:BaseUrl).',
settingsLoadFailed: 'Не удалось загрузить настройки.',
settingsSaveFailed: 'Не удалось сохранить настройки.',
settingsDiscoveryFailed: 'Не удалось получить списки из SwarmUI.',
emptySchools: 'Пока ни одной школы. Создайте первую.',
loadSchoolsFailed: 'Не удалось загрузить список школ. Проверьте соединение с сервером.',
createDisabledTitle: 'Удалите одну из школ, чтобы создать новую',
@@ -140,6 +178,7 @@ const ru = {
peopleTabApparel: 'Одежда',
peopleTabCarry: 'Ноша',
peopleTabNow: 'Сейчас',
peopleTabPortrait: 'Портрет',
peopleAtHome: 'дома',
peopleApparelFit: 'Уместность: {value}',
peopleLockerYes: 'Есть шкафчик',
@@ -152,8 +191,6 @@ const ru = {
peopleLogEvent: 'Событие',
peopleLogEmpty: 'Сегодня записей нет.',
peopleTabOverview: 'Обзор',
peopleTabPortrait: 'Портрет',
peoplePortraitAvatar: 'Аватар',
peoplePortraitFull: 'В полный рост',
peoplePortraitCustom: 'Свой промпт',
@@ -178,6 +215,7 @@ const ru = {
peoplePortraitPromptNegative: 'Negative',
peoplePortraitPromptLoading: 'Сборка промпта…',
peoplePortraitPromptFailed: 'Не удалось получить промпт.',
peoplePortraitPromptPreset: 'Пресет: {label}',
modeOverview: 'Обзор',
modeManage: 'Управление',
@@ -232,6 +270,31 @@ const ru = {
staffErrorAssigned: 'Этот предмет уже назначен.',
staffErrorSubject: 'Такого предмета нет.',
manageTabStaff: 'Штат',
manageTabRules: 'Правила',
rulesCurrentTitle: 'Сегодня',
rulesPendingTitle: 'С завтра',
rulesPendingHint: 'Изменения вступают в силу на следующее рабочее утро.',
rulesStudentsRole: 'Ученики',
rulesStaffRole: 'Сотрудники',
rulesStudentsForm: 'Форма учеников',
rulesStudentsColor: 'Цвет учеников',
rulesStaffForm: 'Форма сотрудников',
rulesStaffColor: 'Цвет сотрудников',
rulesRoleSummary: '{role}: {form}, {color}',
rulesApply: 'Применить',
rulesLoadFailed: 'Не удалось загрузить правила одежды.',
rulesSaveFailed: 'Не удалось сохранить правила.',
rulesPickHint: 'Правила действуют на всю школу.',
dressFormRegular: 'обычная',
dressFormShort: 'короткая',
dressFormStrict: 'строгая',
dressColorFree: 'свободно',
dressColorNoBright: 'без яркого',
dressColorWhiteTopBlackBottom: 'белый верх, чёрный низ',
dressErrorUnknownForm: 'Такой формы нет.',
dressErrorUnknownColor: 'Такой цветовой политики нет.',
mapOccupancy: '{name} ({activity})',
mapHeadcount: '{name} ({count})',
mapHeadcountActivity: '{name} ({count} · {activity})',
@@ -277,6 +340,44 @@ const en: Messages = {
schoolsTitle: 'Schools',
createSchool: 'Create school',
settings: 'Settings',
save: 'Save',
settingsTitle: 'SwarmUI portrait presets',
settingsActivePreset: 'Default preset',
settingsEditPreset: 'Preset to edit',
settingsAddPreset: 'Add preset',
settingsPresetLabel: 'Preset name',
settingsModel: 'Model',
settingsSteps: 'Steps',
settingsCfg: 'CFG scale',
settingsClipSkip: 'CLIP skip',
settingsSampler: 'Sampler',
settingsScheduler: 'Scheduler',
settingsSeed: 'Seed',
settingsPositive: 'Base positive',
settingsNegative: 'Negative',
settingsPositiveLoras: 'Positive LoRA',
settingsNegativeLoras: 'Negative LoRA',
settingsAddLora: 'Add LoRA',
settingsAvatarPreset: 'Avatar',
settingsCustomPreset: 'Custom prompt (size)',
settingsFullBodyPreset: 'Full body',
settingsWidth: 'Width',
settingsHeight: 'Height',
settingsKindPositive: 'Kind positive',
settingsAgeRules: 'Age rules',
settingsAgeMin: 'From',
settingsAgeMax: 'To',
settingsAgePreset: 'Preset',
settingsAddAgeRule: 'Add rule',
settingsRemoveRule: 'Remove',
settingsRefreshLists: 'Refresh lists from SwarmUI',
settingsSwarmReady: 'SwarmUI connected — model and sampler lists are available.',
settingsSwarmOffline: 'SwarmUI unreachable — you can still edit fields manually.',
settingsSwarmNotConfigured: 'SwarmUI is not configured on the server (SwarmUi:BaseUrl).',
settingsLoadFailed: 'Could not load settings.',
settingsSaveFailed: 'Could not save settings.',
settingsDiscoveryFailed: 'Could not fetch lists from SwarmUI.',
emptySchools: 'No schools yet. Create the first one.',
loadSchoolsFailed: 'Could not load the school list. Check the connection to the server.',
createDisabledTitle: 'Delete a school to create a new one',
@@ -406,6 +507,7 @@ const en: Messages = {
peopleTabApparel: 'Clothes',
peopleTabCarry: 'Carried',
peopleTabNow: 'Now',
peopleTabPortrait: 'Portrait',
peopleAtHome: 'home',
peopleApparelFit: 'Fit: {value}',
peopleLockerYes: 'Has a locker',
@@ -418,8 +520,6 @@ const en: Messages = {
peopleLogEvent: 'Event',
peopleLogEmpty: 'Nothing logged today.',
peopleTabOverview: 'Overview',
peopleTabPortrait: 'Portrait',
peoplePortraitAvatar: 'Avatar',
peoplePortraitFull: 'Full body',
peoplePortraitCustom: 'Custom prompt',
@@ -444,6 +544,7 @@ const en: Messages = {
peoplePortraitPromptNegative: 'Negative',
peoplePortraitPromptLoading: 'Building prompt…',
peoplePortraitPromptFailed: 'Could not load the prompt.',
peoplePortraitPromptPreset: 'Preset: {label}',
modeOverview: 'Overview',
modeManage: 'Management',
@@ -498,6 +599,31 @@ const en: Messages = {
staffErrorAssigned: 'That subject is already assigned.',
staffErrorSubject: 'That subject is not in the catalog.',
manageTabStaff: 'Staff',
manageTabRules: 'Rules',
rulesCurrentTitle: 'Today',
rulesPendingTitle: 'From tomorrow',
rulesPendingHint: 'Changes take effect on the next work morning.',
rulesStudentsRole: 'Students',
rulesStaffRole: 'Staff',
rulesStudentsForm: 'Student form',
rulesStudentsColor: 'Student colours',
rulesStaffForm: 'Staff form',
rulesStaffColor: 'Staff colours',
rulesRoleSummary: '{role}: {form}, {color}',
rulesApply: 'Apply',
rulesLoadFailed: 'Could not load dress rules.',
rulesSaveFailed: 'Could not save the rules.',
rulesPickHint: 'Rules apply to the whole school.',
dressFormRegular: 'regular',
dressFormShort: 'short',
dressFormStrict: 'strict',
dressColorFree: 'free colours',
dressColorNoBright: 'no bright colours',
dressColorWhiteTopBlackBottom: 'white top, black bottom',
dressErrorUnknownForm: 'That form policy is not in the catalog.',
dressErrorUnknownColor: 'That colour policy is not in the catalog.',
mapOccupancy: '{name} ({activity})',
mapHeadcount: '{name} ({count})',
mapHeadcountActivity: '{name} ({count} · {activity})',
+95
View File
@@ -402,6 +402,72 @@ export async function fetchGameStatus(): Promise<GameStatus> {
return request<GameStatus>('/api/status');
}
export interface SwarmUiKindPreset {
width: number;
height: number;
positive: string;
}
export interface SwarmUiLoraEntry {
name: string;
weight: number;
}
export interface SwarmUiPresetDefinition {
id: string;
label: string;
model: string;
steps: number;
cfgScale: number;
clipSkip: number;
sampler: string;
scheduler: string;
seed: number;
positive: string;
negative: string;
positiveLoras: SwarmUiLoraEntry[];
negativeLoras: SwarmUiLoraEntry[];
avatar: SwarmUiKindPreset;
custom: SwarmUiKindPreset;
fullBody: SwarmUiKindPreset;
}
export interface SwarmUiAgeRule {
minAge: number;
maxAge: number;
presetId: string;
}
export interface SwarmUiSettingsFile {
activePresetId: string;
presets: SwarmUiPresetDefinition[];
ageRules: SwarmUiAgeRule[];
}
export interface SwarmUiDiscovery {
connected: boolean;
models: readonly string[];
loras: readonly string[];
samplers: readonly string[];
schedulers: readonly string[];
}
export async function fetchSwarmUiSettings(): Promise<SwarmUiSettingsFile> {
return request<SwarmUiSettingsFile>('/api/settings/swarmui');
}
export async function saveSwarmUiSettings(settings: SwarmUiSettingsFile): Promise<SwarmUiSettingsFile> {
return request<SwarmUiSettingsFile>('/api/settings/swarmui', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
});
}
export async function fetchSwarmUiDiscovery(): Promise<SwarmUiDiscovery> {
return request<SwarmUiDiscovery>('/api/settings/swarmui/discovery');
}
export type PortraitKind = 'avatar' | 'full' | 'custom';
export interface PortraitResult {
@@ -430,6 +496,8 @@ export interface PortraitPrompt {
readonly positive: string;
readonly negative: string;
readonly promptExtra: string | null;
readonly presetId: string;
readonly presetLabel: string;
}
export async function fetchPortraitPrompt(
@@ -575,6 +643,33 @@ export async function unassignSubject(
);
}
export interface DressRulePair {
readonly form: string;
readonly color: string;
}
export interface DressRules {
readonly students: DressRulePair;
readonly staff: DressRulePair;
readonly pendingStudents: DressRulePair | null;
readonly pendingStaff: DressRulePair | null;
}
export async function fetchDressRules(schoolId: number): Promise<DressRules> {
return request<DressRules>(`/api/schools/${schoolId}/dress-rules`);
}
export async function setDressRules(
schoolId: number,
body: { readonly students?: DressRulePair; readonly staff?: DressRulePair },
): Promise<DressRules> {
return request<DressRules>(`/api/schools/${schoolId}/dress-rules`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}
export interface TimetableLesson {
readonly classId: string;
readonly classYear: number;
+51 -1
View File
@@ -683,10 +683,45 @@ body {
overflow-x: auto;
}
.people__portrait-prompt-text--muted {
.form--settings {
max-height: min(90vh, 960px);
overflow: auto;
}
.settings-section-title {
margin: 20px 0 8px;
font-size: 15px;
}
.settings-subtitle {
margin: 16px 0 8px;
font-size: 14px;
}
.settings-age-rule {
align-items: end;
margin-bottom: 8px;
}
.settings-lora-row {
align-items: end;
margin-bottom: 8px;
}
.settings-lora-row .field {
flex: 1;
}
.people__portrait-prompt-meta {
margin: 0 0 8px;
font-size: 13px;
color: var(--text-muted, #666);
}
.field--grow {
flex: 1;
}
.people__swarm-status {
margin: 0 0 12px;
font-size: 13px;
@@ -1012,6 +1047,21 @@ body {
font-size: 13px;
}
.rules__summary {
margin: 0 0 8px;
font-size: 14px;
}
.rules__fieldset {
margin: 0 0 16px;
padding: 0;
border: 0;
}
.rules__fieldset .people__field + .people__field {
margin-top: 8px;
}
.staffing__badge {
margin-left: 8px;
padding: 1px 6px;
@@ -0,0 +1,91 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ApiError,
fetchDressRules,
setDressRules,
type DressRules,
} from '../net/api.ts';
import { getLocale, setLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
import { DressRulesPanel } from './dressRulesPanel.ts';
vi.mock('../net/api.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../net/api.ts')>();
return {
...actual,
fetchDressRules: vi.fn(),
setDressRules: vi.fn(),
};
});
const initialLocale = getLocale();
function rules(): DressRules {
return {
students: { form: 'regular', color: 'noBright' },
staff: { form: 'regular', color: 'noBright' },
pendingStudents: null,
pendingStaff: null,
};
}
describe('DressRulesPanel', () => {
beforeEach(() => {
setLocale('en');
vi.mocked(fetchDressRules).mockReset();
vi.mocked(setDressRules).mockReset();
vi.mocked(fetchDressRules).mockResolvedValue(rules());
});
afterEach(() => {
document.body.replaceChildren();
setLocale(initialLocale);
});
it('does not call save when only a combobox changes', async () => {
const panel = new DressRulesPanel();
document.body.append(panel.element);
panel.show(3);
await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled());
const colorSelect = panel.element.querySelector('select.input');
if (!(colorSelect instanceof HTMLSelectElement)) {
throw new Error('student colour select is missing');
}
colorSelect.value = 'free';
colorSelect.dispatchEvent(new Event('change', { bubbles: true }));
expect(setDressRules).not.toHaveBeenCalled();
});
it('shows an API error as text after apply', async () => {
vi.mocked(setDressRules).mockRejectedValue(new ApiError(400, 'unknown-form', 'bad form'));
const panel = new DressRulesPanel();
document.body.append(panel.element);
panel.show(3);
await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled());
const formSelect = panel.element.querySelector('fieldset select.input');
if (!(formSelect instanceof HTMLSelectElement)) {
throw new Error('student form select is missing');
}
formSelect.value = 'strict';
const apply = panel.element.querySelector('button.button');
if (!(apply instanceof HTMLButtonElement)) {
throw new Error('apply button is missing');
}
apply.click();
const banner = panel.element.querySelector('.staffing__error');
if (!(banner instanceof HTMLElement)) {
throw new Error('error banner is missing');
}
await vi.waitFor(() => expect(banner.hidden).toBe(false));
expect(banner.textContent).toBe(t('dressErrorUnknownForm'));
});
});
@@ -0,0 +1,239 @@
import { fetchDressRules, setDressRules, type DressRulePair, type DressRules } from '../net/api.ts';
import { t } from '../i18n/strings.ts';
import { el } from './dom.ts';
import {
dressColorLabel,
dressColorOptions,
dressFormLabel,
dressFormOptions,
dressRulesError,
} from './dressRulesUi.ts';
import { fillSelect } from './staffingUi.ts';
/**
* School-wide dress code. Edits queue for the next work morning; today's rules stay visible.
*/
export class DressRulesPanel {
readonly element: HTMLElement;
private readonly error = el('p', { class: 'staffing__error' });
private readonly currentTitle = el('h3', { class: 'panel__section-title' });
private readonly currentStudents = el('p', { class: 'rules__summary' });
private readonly currentStaff = el('p', { class: 'rules__summary' });
private readonly pendingTitle = el('h3', { class: 'panel__section-title' });
private readonly pendingHint = el('p', { class: 'panel__meta' });
private readonly studentsFormLabel = el('span', { class: 'people__label' });
private readonly studentsFormSelect = el('select', { class: 'input people__input' });
private readonly studentsColorLabel = el('span', { class: 'people__label' });
private readonly studentsColorSelect = el('select', { class: 'input people__input' });
private readonly staffFormLabel = el('span', { class: 'people__label' });
private readonly staffFormSelect = el('select', { class: 'input people__input' });
private readonly staffColorLabel = el('span', { class: 'people__label' });
private readonly staffColorSelect = el('select', { class: 'input people__input' });
private readonly applyButton = el('button', { class: 'button', type: 'button' });
private readonly studentsLegend: HTMLElement;
private readonly staffLegend: HTMLElement;
private schoolId: number | null = null;
private rules: DressRules | null = null;
private loadToken = 0;
private busy = false;
constructor() {
this.error.hidden = true;
this.applyButton.addEventListener('click', () => void this.apply());
this.element = el(
'div',
{ class: 'panel__body rules', hidden: true },
this.error,
el('div', { class: 'panel__section' }, this.currentTitle, this.currentStudents, this.currentStaff),
el(
'div',
{ class: 'panel__section' },
this.pendingTitle,
this.pendingHint,
el(
'fieldset',
{ class: 'rules__fieldset' },
el('legend', { class: 'people__section-title', text: '' }),
el(
'label',
{ class: 'people__field' },
this.studentsFormLabel,
this.studentsFormSelect,
),
el(
'label',
{ class: 'people__field' },
this.studentsColorLabel,
this.studentsColorSelect,
),
),
el(
'fieldset',
{ class: 'rules__fieldset' },
el('legend', { class: 'people__section-title', text: '' }),
el('label', { class: 'people__field' }, this.staffFormLabel, this.staffFormSelect),
el('label', { class: 'people__field' }, this.staffColorLabel, this.staffColorSelect),
),
this.applyButton,
),
);
const legends = this.element.querySelectorAll('fieldset.rules__fieldset > legend');
const studentsLegend = legends.item(0);
const staffLegend = legends.item(1);
if (!(studentsLegend instanceof HTMLElement) || !(staffLegend instanceof HTMLElement)) {
throw new Error('rules fieldset legends are missing');
}
this.studentsLegend = studentsLegend;
this.staffLegend = staffLegend;
this.localize();
}
localize(): void {
this.currentTitle.textContent = t('rulesCurrentTitle');
this.pendingTitle.textContent = t('rulesPendingTitle');
this.pendingHint.textContent = t('rulesPendingHint');
this.studentsFormLabel.textContent = t('rulesStudentsForm');
this.studentsColorLabel.textContent = t('rulesStudentsColor');
this.staffFormLabel.textContent = t('rulesStaffForm');
this.staffColorLabel.textContent = t('rulesStaffColor');
this.applyButton.textContent = t('rulesApply');
this.studentsLegend.textContent = t('rulesStudentsRole');
this.staffLegend.textContent = t('rulesStaffRole');
fillSelect(this.studentsFormSelect, dressFormOptions(), this.studentsFormSelect.value);
fillSelect(this.studentsColorSelect, dressColorOptions(), this.studentsColorSelect.value);
fillSelect(this.staffFormSelect, dressFormOptions(), this.staffFormSelect.value);
fillSelect(this.staffColorSelect, dressColorOptions(), this.staffColorSelect.value);
this.paint();
}
show(schoolId: number): void {
if (this.schoolId !== schoolId) {
this.schoolId = schoolId;
this.rules = null;
this.clearError();
}
void this.reload();
}
hide(): void {
this.element.hidden = true;
}
reveal(): void {
this.element.hidden = false;
}
private async reload(): Promise<void> {
const schoolId = this.schoolId;
if (schoolId === null) {
return;
}
const token = ++this.loadToken;
try {
const rules = await fetchDressRules(schoolId);
if (token !== this.loadToken) {
return;
}
this.rules = rules;
this.paint();
} catch {
if (token !== this.loadToken) {
return;
}
this.showError(t('rulesLoadFailed'));
}
}
private paint(): void {
const rules = this.rules;
this.applyButton.disabled = this.busy || rules === null;
if (rules === null) {
this.currentStudents.textContent = '—';
this.currentStaff.textContent = '—';
return;
}
this.currentStudents.textContent = t('rulesRoleSummary', {
role: t('rulesStudentsRole'),
form: dressFormLabel(rules.students.form),
color: dressColorLabel(rules.students.color),
});
this.currentStaff.textContent = t('rulesRoleSummary', {
role: t('rulesStaffRole'),
form: dressFormLabel(rules.staff.form),
color: dressColorLabel(rules.staff.color),
});
const pendingStudents = rules.pendingStudents ?? rules.students;
const pendingStaff = rules.pendingStaff ?? rules.staff;
fillSelect(this.studentsFormSelect, dressFormOptions(), pendingStudents.form);
fillSelect(this.studentsColorSelect, dressColorOptions(), pendingStudents.color);
fillSelect(this.staffFormSelect, dressFormOptions(), pendingStaff.form);
fillSelect(this.staffColorSelect, dressColorOptions(), pendingStaff.color);
}
private async apply(): Promise<void> {
const schoolId = this.schoolId;
const rules = this.rules;
if (schoolId === null || rules === null || this.busy) {
return;
}
const students = this.pair(this.studentsFormSelect, this.studentsColorSelect);
const staff = this.pair(this.staffFormSelect, this.staffColorSelect);
const body: { students?: DressRulePair; staff?: DressRulePair } = {};
if (!samePair(students, rules.pendingStudents ?? rules.students)) {
body.students = students;
}
if (!samePair(staff, rules.pendingStaff ?? rules.staff)) {
body.staff = staff;
}
if (body.students === undefined && body.staff === undefined) {
return;
}
this.busy = true;
this.clearError();
this.paint();
try {
this.rules = await setDressRules(schoolId, body);
this.paint();
} catch (error) {
this.showError(dressRulesError(error));
} finally {
this.busy = false;
this.paint();
}
}
private pair(formSelect: HTMLSelectElement, colorSelect: HTMLSelectElement): DressRulePair {
return { form: formSelect.value, color: colorSelect.value };
}
private showError(message: string): void {
this.error.hidden = false;
this.error.textContent = message;
}
private clearError(): void {
this.error.hidden = true;
this.error.textContent = '';
}
}
function samePair(left: DressRulePair, right: DressRulePair): boolean {
return left.form === right.form && left.color === right.color;
}
+57
View File
@@ -0,0 +1,57 @@
import { ApiError } from '../net/api.ts';
import { t } from '../i18n/strings.ts';
export const DRESS_FORMS = ['regular', 'short', 'strict'] as const;
export const DRESS_COLORS = ['free', 'noBright', 'whiteTopBlackBottom'] as const;
export type DressForm = (typeof DRESS_FORMS)[number];
export type DressColor = (typeof DRESS_COLORS)[number];
export function dressFormOptions(): readonly { value: DressForm; label: string }[] {
return DRESS_FORMS.map((value) => ({ value, label: dressFormLabel(value) }));
}
export function dressColorOptions(): readonly { value: DressColor; label: string }[] {
return DRESS_COLORS.map((value) => ({ value, label: dressColorLabel(value) }));
}
export function dressFormLabel(form: string): string {
switch (form) {
case 'regular':
return t('dressFormRegular');
case 'short':
return t('dressFormShort');
case 'strict':
return t('dressFormStrict');
default:
return form;
}
}
export function dressColorLabel(color: string): string {
switch (color) {
case 'free':
return t('dressColorFree');
case 'noBright':
return t('dressColorNoBright');
case 'whiteTopBlackBottom':
return t('dressColorWhiteTopBlackBottom');
default:
return color;
}
}
export function dressRulesError(error: unknown): string {
if (!(error instanceof ApiError)) {
return t('rulesSaveFailed');
}
switch (error.code) {
case 'unknown-form':
return t('dressErrorUnknownForm');
case 'unknown-color':
return t('dressErrorUnknownColor');
default:
return error.message.length > 0 ? error.message : t('rulesSaveFailed');
}
}
+8 -1
View File
@@ -11,6 +11,7 @@ import { schoolWord, t } from '../i18n/strings.ts';
import { el } from './dom.ts';
import { confirmDialog } from './confirmDialog.ts';
import { createSchoolDialog } from './createSchoolDialog.ts';
import { swarmUiSettingsDialog } from './swarmUiSettingsDialog.ts';
import { SchoolCard } from './schoolCard.ts';
interface MainMenuOptions {
@@ -35,6 +36,10 @@ export class MainMenu {
class: 'button button--primary',
type: 'button',
});
private readonly settingsButton = el('button', {
class: 'button',
type: 'button',
});
private readonly limitHint = el('p', { class: 'hint' });
private readonly status = el('p', { class: 'hint hint--error' });
@@ -47,6 +52,7 @@ export class MainMenu {
constructor(private readonly options: MainMenuOptions) {
this.createButton.addEventListener('click', () => void this.openCreateDialog());
this.settingsButton.addEventListener('click', () => void swarmUiSettingsDialog());
this.status.hidden = true;
this.root.append(
@@ -54,7 +60,7 @@ export class MainMenu {
'header',
{ class: 'screen__header' },
this.title,
el('div', { class: 'screen__actions' }, this.createButton),
el('div', { class: 'screen__actions' }, this.settingsButton, this.createButton),
),
this.limitHint,
this.status,
@@ -73,6 +79,7 @@ export class MainMenu {
localize(): void {
this.title.textContent = t('schoolsTitle');
this.createButton.textContent = t('createSchool');
this.settingsButton.textContent = t('settings');
this.emptyHint.textContent = t('emptySchools');
for (const card of this.cards.values()) {
@@ -6,6 +6,7 @@ import {
ApiError,
fetchPerson,
fetchGameStatus,
fetchDressRules,
fetchStaffing,
fetchTimetable,
hireStaff,
@@ -24,6 +25,7 @@ vi.mock('../net/api.ts', async (importOriginal) => {
...actual,
fetchStaffing: vi.fn(),
fetchTimetable: vi.fn(),
fetchDressRules: vi.fn(),
fetchPerson: vi.fn(),
fetchGameStatus: vi.fn().mockResolvedValue({
tick: 0,
@@ -215,6 +217,7 @@ describe('ManagementPanel uncovered', () => {
setLocale('en');
vi.mocked(fetchStaffing).mockReset();
vi.mocked(fetchTimetable).mockReset();
vi.mocked(fetchDressRules).mockReset();
vi.mocked(fetchStaffing).mockResolvedValue({
...staffing(),
applicants: [],
@@ -230,6 +233,12 @@ describe('ManagementPanel uncovered', () => {
],
});
vi.mocked(fetchTimetable).mockResolvedValue(timetable());
vi.mocked(fetchDressRules).mockResolvedValue({
students: { form: 'regular', color: 'noBright' },
staff: { form: 'regular', color: 'noBright' },
pendingStudents: null,
pendingStaff: null,
});
});
afterEach(() => {
@@ -248,3 +257,56 @@ describe('ManagementPanel uncovered', () => {
);
});
});
describe('ManagementPanel rules tab', () => {
beforeEach(() => {
setLocale('en');
vi.mocked(fetchStaffing).mockReset();
vi.mocked(fetchTimetable).mockReset();
vi.mocked(fetchDressRules).mockReset();
vi.mocked(fetchStaffing).mockResolvedValue(staffing());
vi.mocked(fetchTimetable).mockResolvedValue(timetable());
vi.mocked(fetchDressRules).mockResolvedValue({
students: { form: 'regular', color: 'noBright' },
staff: { form: 'regular', color: 'noBright' },
pendingStudents: null,
pendingStaff: null,
});
});
afterEach(() => {
document.body.replaceChildren();
setLocale(initialLocale);
});
it('keeps staffing and the timetable grid after visiting rules', async () => {
const panel = new ManagementPanel();
document.body.append(panel.listElement, panel.cardElement);
panel.show(2);
await vi.waitFor(() => expect(fetchStaffing).toHaveBeenCalled());
const rulesTab = [...panel.listElement.querySelectorAll('.panel__tab')].find(
(button) => button.textContent === t('manageTabRules'),
);
if (!(rulesTab instanceof HTMLButtonElement)) {
throw new Error('rules tab is missing');
}
rulesTab.click();
expect(panel.listElement.querySelector('.staffing')?.hasAttribute('hidden')).toBe(true);
expect(panel.listElement.querySelector('.rules')?.hasAttribute('hidden')).toBe(false);
const staffTab = [...panel.listElement.querySelectorAll('.panel__tab')].find(
(button) => button.textContent === t('manageTabStaff'),
);
if (!(staffTab instanceof HTMLButtonElement)) {
throw new Error('staff tab is missing');
}
staffTab.click();
await vi.waitFor(() => expect(panel.listElement.querySelector('.staffing')).not.toBeNull());
expect(panel.listElement.textContent).toContain(t('staffHired'));
expect(panel.listElement.textContent).toContain(t('timetableTitle'));
expect(panel.listElement.querySelector('.timetable__grid')).not.toBeNull();
});
});
+57 -3
View File
@@ -12,6 +12,7 @@ import {
import { getLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
import { ApplicantsDialog } from './applicantsDialog.ts';
import { DressRulesPanel } from './dressRulesPanel.ts';
import { clear, el } from './dom.ts';
import { formatPersonPlace } from './personCard.ts';
import { PersonCardHost } from './personCardHost.ts';
@@ -28,6 +29,10 @@ export class ManagementPanel {
readonly listElement: HTMLElement;
readonly cardElement: HTMLElement;
private readonly staffTab = el('button', { class: 'panel__tab', type: 'button' });
private readonly rulesTab = el('button', { class: 'panel__tab', type: 'button' });
private readonly staffPane = el('div', { class: 'panel__body staffing' });
private readonly rulesPanel = new DressRulesPanel();
private readonly error = el('p', { class: 'staffing__error' });
private readonly money = el('dl', { class: 'staffing__money' });
private readonly uncoveredTitle = el('h3', { class: 'panel__section-title' });
@@ -65,13 +70,14 @@ export class ManagementPanel {
private locate: ((id: string) => string) | null = null;
private poolDialog: ApplicantsDialog | null = null;
private readonly cardHost = new PersonCardHost();
private manageTab: 'staff' | 'rules' = 'staff';
constructor() {
this.error.hidden = true;
this.applicantsButton.addEventListener('click', () => void this.openApplicants());
this.listElement = el(
'div',
{ class: 'panel__body staffing' },
this.staffTab.addEventListener('click', () => this.showManageTab('staff'));
this.rulesTab.addEventListener('click', () => this.showManageTab('rules'));
this.staffPane.append(
this.error,
this.money,
el('div', { class: 'panel__section' }, this.uncoveredTitle, this.uncoveredEmpty, this.uncovered),
@@ -96,6 +102,13 @@ export class ManagementPanel {
this.timetableGrid.element,
),
);
this.listElement = el(
'div',
{},
el('div', { class: 'panel__tabs' }, this.staffTab, this.rulesTab),
this.staffPane,
this.rulesPanel.element,
);
this.cardElement = this.card;
this.classSelect.addEventListener('change', () => {
this.classId = this.classSelect.value || null;
@@ -105,6 +118,8 @@ export class ManagementPanel {
}
localize(): void {
this.staffTab.textContent = t('manageTabStaff');
this.rulesTab.textContent = t('manageTabRules');
this.uncoveredTitle.textContent = t('staffUncovered');
this.applicantsTitle.textContent = t('staffApplicants');
this.applicantsButton.textContent = t('staffApplicantsOpen');
@@ -116,6 +131,8 @@ export class ManagementPanel {
this.timetableGrid.localize();
this.personGrid.localize();
this.personTimetableTitle.textContent = t('timetableTitle');
this.rulesPanel.localize();
this.paintManageTabs();
this.paint();
this.poolDialog?.localize();
if (this.selectedId !== null) {
@@ -133,12 +150,43 @@ export class ManagementPanel {
this.staffing = null;
this.timetable = null;
this.classId = null;
this.manageTab = 'staff';
this.clearError();
}
this.paintManageTabs();
this.timetableGrid.attach(schoolId);
this.cardHost.attach(schoolId);
void this.reload();
if (this.manageTab === 'rules') {
this.rulesPanel.show(schoolId);
}
}
private showManageTab(tab: 'staff' | 'rules'): void {
this.manageTab = tab;
this.paintManageTabs();
const schoolId = this.schoolId;
if (tab === 'rules' && schoolId !== null) {
this.rulesPanel.show(schoolId);
this.paintCard(null);
} else if (this.selectedId !== null) {
void this.openCard(this.selectedId);
} else {
this.paintCard(null);
}
}
private paintManageTabs(): void {
this.staffTab.classList.toggle('panel__tab--active', this.manageTab === 'staff');
this.rulesTab.classList.toggle('panel__tab--active', this.manageTab === 'rules');
this.staffPane.hidden = this.manageTab !== 'staff';
if (this.manageTab === 'rules') {
this.rulesPanel.reveal();
} else {
this.rulesPanel.hide();
}
}
setLocate(locate: (id: string) => string): void {
@@ -402,6 +450,12 @@ export class ManagementPanel {
}
private paintCard(card: PersonCard | null): void {
if (this.manageTab === 'rules') {
clear(this.card);
this.card.append(el('p', { class: 'panel__empty', text: t('rulesPickHint') }));
return;
}
if (card === null) {
clear(this.card);
this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') }));
+1
View File
@@ -489,6 +489,7 @@ function portraitPromptView(
{ 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') }),
@@ -0,0 +1,435 @@
import {
ApiError,
fetchGameStatus,
fetchSwarmUiDiscovery,
fetchSwarmUiSettings,
saveSwarmUiSettings,
type SwarmUiDiscovery,
type SwarmUiLoraEntry,
type SwarmUiPresetDefinition,
type SwarmUiSettingsFile,
} from '../net/api.ts';
import { t } from '../i18n/strings.ts';
import { el } from './dom.ts';
import { Modal } from './modal.ts';
export function swarmUiSettingsDialog(): Promise<boolean> {
const modal = new Modal<boolean>(false);
const error = el('p', { class: 'dialog__error' });
error.hidden = true;
const statusLine = el('p', { class: 'hint' });
const editPresetSelect = el('select', { class: 'input' });
const activePresetSelect = el('select', { class: 'input' });
const formHost = el('div', { class: 'settings-form' });
const ageRulesHost = el('div', { class: 'settings-age-rules' });
let config: SwarmUiSettingsFile | null = null;
let discovery: SwarmUiDiscovery = { connected: false, models: [], loras: [], samplers: [], schedulers: [] };
let editingPresetId = '';
const cancelButton = el('button', { class: 'button', type: 'button', onClick: () => modal.close(false) });
const saveButton = el('button', { class: 'button button--primary', type: 'submit' });
const addPresetButton = el('button', { class: 'button button--small', type: 'button' });
const addAgeRuleButton = el('button', { class: 'button button--small', type: 'button' });
const refreshDiscoveryButton = el('button', { class: 'button button--small', type: 'button' });
const form = el(
'form',
{ class: 'form form--settings' },
el('h2', { class: 'dialog__title' }),
statusLine,
el('div', { class: 'field__row' }, refreshDiscoveryButton),
el('label', { class: 'field' }, el('span', { class: 'field__label' }), activePresetSelect),
el(
'div',
{ class: 'field field__row' },
el('label', { class: 'field field--grow' }, el('span', { class: 'field__label' }), editPresetSelect),
addPresetButton,
),
formHost,
el('h3', { class: 'settings-section-title' }),
ageRulesHost,
el('div', { class: 'field__row' }, addAgeRuleButton),
error,
el('div', { class: 'dialog__actions' }, cancelButton, saveButton),
);
form.addEventListener('submit', (event) => {
event.preventDefault();
void submit();
});
addPresetButton.addEventListener('click', () => {
if (config === null) {
return;
}
const id = `preset-${config.presets.length + 1}`;
const source = currentPreset() ?? config.presets[0];
config.presets.push({
...structuredClone(source),
id,
label: id,
});
editingPresetId = id;
paintPresetSelectors();
paintPresetForm();
paintAgeRules();
});
addAgeRuleButton.addEventListener('click', () => {
if (config === null) {
return;
}
config.ageRules.push({
minAge: 6,
maxAge: 11,
presetId: config.activePresetId,
});
paintAgeRules();
});
refreshDiscoveryButton.addEventListener('click', () => void reloadDiscovery());
editPresetSelect.addEventListener('change', () => {
editingPresetId = editPresetSelect.value;
paintPresetForm();
});
activePresetSelect.addEventListener('change', () => {
if (config !== null) {
config.activePresetId = activePresetSelect.value;
}
paintAgeRules();
});
const dialog = modal.element;
dialog.classList.add('dialog--screen');
dialog.append(form);
void init();
return modal.open(saveButton);
async function init(): Promise<void> {
localize();
try {
const [settings, gameStatus, lists] = await Promise.all([
fetchSwarmUiSettings(),
fetchGameStatus(),
fetchSwarmUiDiscovery(),
]);
config = settings;
discovery = lists;
editingPresetId = settings.presets[0]?.id ?? '';
paintStatus(gameStatus.swarmUiConfigured, gameStatus.swarmUiConnected, lists.connected);
paintPresetSelectors();
paintPresetForm();
paintAgeRules();
} catch {
showError(t('settingsLoadFailed'));
}
}
async function reloadDiscovery(): Promise<void> {
refreshDiscoveryButton.disabled = true;
try {
discovery = await fetchSwarmUiDiscovery();
paintStatus(true, discovery.connected, discovery.connected);
paintPresetForm();
} catch {
showError(t('settingsDiscoveryFailed'));
} finally {
refreshDiscoveryButton.disabled = false;
}
}
async function submit(): Promise<void> {
if (config === null) {
return;
}
syncActivePresetFromForm();
saveButton.disabled = true;
error.hidden = true;
try {
await saveSwarmUiSettings(config);
modal.close(true);
} catch (err) {
showError(err instanceof ApiError ? err.message : t('settingsSaveFailed'));
} finally {
saveButton.disabled = false;
}
}
function showError(message: string): void {
error.textContent = message;
error.hidden = false;
}
function paintStatus(configured: boolean, connected: boolean | null, discoveryConnected: boolean): void {
if (!configured) {
statusLine.textContent = t('settingsSwarmNotConfigured');
return;
}
if (connected === true && discoveryConnected) {
statusLine.textContent = t('settingsSwarmReady');
return;
}
statusLine.textContent = t('settingsSwarmOffline');
}
function paintPresetSelectors(): void {
if (config === null) {
return;
}
editPresetSelect.replaceChildren(
...config.presets.map((preset) => el('option', { value: preset.id, text: preset.label || preset.id })),
);
editPresetSelect.value = editingPresetId;
activePresetSelect.replaceChildren(
...config.presets.map((preset) => el('option', { value: preset.id, text: preset.label || preset.id })),
);
activePresetSelect.value = config.activePresetId;
}
function currentPreset(): SwarmUiPresetDefinition | undefined {
return config?.presets.find((preset) => preset.id === editingPresetId);
}
function paintPresetForm(): void {
formHost.replaceChildren();
const preset = currentPreset();
if (preset === null || preset === undefined) {
return;
}
formHost.append(
textField(t('settingsPresetLabel'), preset.label, (value) => {
preset.label = value;
paintPresetSelectors();
}),
choiceField(t('settingsModel'), preset.model, discovery.models, (value) => {
preset.model = value;
}),
numberField(t('settingsSteps'), preset.steps, (value) => {
preset.steps = value;
}),
numberField(t('settingsCfg'), preset.cfgScale, (value) => {
preset.cfgScale = value;
}, 0.1),
numberField(t('settingsClipSkip'), preset.clipSkip, (value) => {
preset.clipSkip = value;
}),
choiceField(t('settingsSampler'), preset.sampler, discovery.samplers, (value) => {
preset.sampler = value;
}),
choiceField(t('settingsScheduler'), preset.scheduler, discovery.schedulers, (value) => {
preset.scheduler = value;
}),
numberField(t('settingsSeed'), preset.seed, (value) => {
preset.seed = value;
}),
textareaField(t('settingsPositive'), preset.positive, (value) => {
preset.positive = value;
}),
textareaField(t('settingsNegative'), preset.negative, (value) => {
preset.negative = value;
}),
loraSection(t('settingsPositiveLoras'), preset.positiveLoras),
loraSection(t('settingsNegativeLoras'), preset.negativeLoras),
kindSection(t('settingsAvatarPreset'), preset.avatar),
kindSection(t('settingsCustomPreset'), preset.custom),
kindSection(t('settingsFullBodyPreset'), preset.fullBody),
);
}
function syncActivePresetFromForm(): void {
// Values are bound live through closures; nothing extra to scrape from DOM.
}
function paintAgeRules(): void {
ageRulesHost.replaceChildren();
if (config === null) {
return;
}
for (const rule of config.ageRules) {
const minInput = el('input', { class: 'input', type: 'number', value: String(rule.minAge) });
minInput.min = '0';
minInput.max = '120';
minInput.addEventListener('input', () => {
rule.minAge = Number(minInput.value);
});
const maxInput = el('input', { class: 'input', type: 'number', value: String(rule.maxAge) });
maxInput.min = '0';
maxInput.max = '120';
maxInput.addEventListener('input', () => {
rule.maxAge = Number(maxInput.value);
});
const presetSelect = el(
'select',
{ class: 'input' },
...config.presets.map((preset) => el('option', { value: preset.id, text: preset.label || preset.id })),
);
presetSelect.value = rule.presetId;
presetSelect.addEventListener('change', () => {
rule.presetId = presetSelect.value;
});
const removeButton = el('button', {
class: 'button button--small button--danger',
type: 'button',
text: t('settingsRemoveRule'),
onClick: () => {
config!.ageRules = config!.ageRules.filter((entry) => entry !== rule);
paintAgeRules();
},
});
ageRulesHost.append(
el(
'div',
{ class: 'settings-age-rule field__row' },
el('label', { class: 'field' }, el('span', { class: 'field__label', text: t('settingsAgeMin') }), minInput),
el('label', { class: 'field' }, el('span', { class: 'field__label', text: t('settingsAgeMax') }), maxInput),
el('label', { class: 'field field--grow' }, el('span', { class: 'field__label', text: t('settingsAgePreset') }), presetSelect),
removeButton,
),
);
}
}
function loraSection(title: string, loras: SwarmUiLoraEntry[]): HTMLElement {
const host = el('div', { class: 'settings-loras' });
const heading = el('h4', { class: 'settings-subtitle', text: title });
const rows = el('div', { class: 'settings-lora-rows' });
const paintRows = (): void => {
rows.replaceChildren();
for (const lora of loras) {
const nameField = choiceField('', lora.name, discovery.loras, (value) => {
lora.name = value;
});
const weightInput = el('input', { class: 'input', type: 'number', value: String(lora.weight) });
weightInput.step = '0.05';
weightInput.min = '-4';
weightInput.max = '4';
weightInput.addEventListener('input', () => {
lora.weight = Number(weightInput.value);
});
const remove = el('button', {
class: 'button button--small button--danger',
type: 'button',
text: '×',
onClick: () => {
const index = loras.indexOf(lora);
if (index >= 0) {
loras.splice(index, 1);
}
paintRows();
},
});
rows.append(el('div', { class: 'field__row settings-lora-row' }, nameField, weightInput, remove));
}
};
paintRows();
const add = el('button', {
class: 'button button--small',
type: 'button',
text: t('settingsAddLora'),
onClick: () => {
loras.push({ name: discovery.loras[0] ?? '', weight: 1 });
paintRows();
},
});
host.append(heading, rows, add);
return host;
}
function kindSection(title: string, preset: SwarmUiPresetDefinition['avatar']): HTMLElement {
return el(
'div',
{ class: 'settings-kind' },
el('h4', { class: 'settings-subtitle', text: title }),
numberField(t('settingsWidth'), preset.width, (value) => {
preset.width = value;
}),
numberField(t('settingsHeight'), preset.height, (value) => {
preset.height = value;
}),
textareaField(t('settingsKindPositive'), preset.positive, (value) => {
preset.positive = value;
}),
);
}
function localize(): void {
form.querySelector('.dialog__title')!.textContent = t('settingsTitle');
form.querySelectorAll('.field__label')[0]!.textContent = t('settingsActivePreset');
form.querySelectorAll('.field__label')[1]!.textContent = t('settingsEditPreset');
form.querySelector('.settings-section-title')!.textContent = t('settingsAgeRules');
cancelButton.textContent = t('cancel');
saveButton.textContent = t('save');
addPresetButton.textContent = t('settingsAddPreset');
addAgeRuleButton.textContent = t('settingsAddAgeRule');
refreshDiscoveryButton.textContent = t('settingsRefreshLists');
}
}
function textField(label: string, value: string, onChange: (value: string) => void): HTMLElement {
const input = el('input', { class: 'input', type: 'text', value });
input.addEventListener('input', () => onChange(input.value));
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input);
}
function numberField(
label: string,
value: number,
onChange: (value: number) => void,
step = 1,
): HTMLElement {
const input = el('input', { class: 'input', type: 'number', value: String(value) });
input.step = String(step);
input.addEventListener('input', () => onChange(Number(input.value)));
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input);
}
function textareaField(label: string, value: string, onChange: (value: string) => void): HTMLElement {
const input = el('textarea', { class: 'input', rows: 3, value });
input.addEventListener('input', () => onChange(input.value));
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input);
}
function choiceField(
label: string,
value: string,
options: readonly string[],
onChange: (value: string) => void,
): HTMLElement {
const values = options.includes(value) ? options : value.length > 0 ? [value, ...options] : options;
if (values.length === 0) {
const input = el('input', { class: 'input', type: 'text', value });
input.addEventListener('input', () => onChange(input.value));
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), input);
}
const select = el(
'select',
{ class: 'input' },
...values.map((entry) => el('option', { value: entry, text: entry })),
);
select.value = value;
select.addEventListener('change', () => onChange(select.value));
return el('label', { class: 'field' }, el('span', { class: 'field__label', text: label }), select);
}