Refactor map editing and school creation UI components. Update styles for the dialog and map editor, enhancing layout and responsiveness. Introduce new localization strings for map editing features. Replace the inline map editor with a separate dialog for improved usability. Update event handling for map editing actions and hints.
ci / server (push) Failing after 3m31s
ci / client (push) Successful in 13s

This commit is contained in:
Leonid Pershin
2026-08-18 16:16:05 +03:00
parent 30cc937069
commit 3068d10409
5 changed files with 151 additions and 59 deletions
+8
View File
@@ -46,7 +46,11 @@ const ru = {
modsTitle: 'Моды', modsTitle: 'Моды',
coreModLocked: '{id} (всегда включён)', coreModLocked: '{id} (всегда включён)',
mapEditorTitle: 'Карта', mapEditorTitle: 'Карта',
editMap: 'Редактировать карту',
mapDefaultHint: 'Будет использована карта по умолчанию.',
mapEditedHint: 'Карта изменена.',
resetMap: 'Сбросить к умолчанию', resetMap: 'Сбросить к умолчанию',
done: 'Готово',
editorSlots: 'Слоты', editorSlots: 'Слоты',
editorLinks: 'Проходы', editorLinks: 'Проходы',
addLink: 'Связать', addLink: 'Связать',
@@ -121,7 +125,11 @@ const en: Messages = {
modsTitle: 'Mods', modsTitle: 'Mods',
coreModLocked: '{id} (always on)', coreModLocked: '{id} (always on)',
mapEditorTitle: 'Map', mapEditorTitle: 'Map',
editMap: 'Edit map',
mapDefaultHint: 'The default map will be used.',
mapEditedHint: 'The map has been edited.',
resetMap: 'Reset to default', resetMap: 'Reset to default',
done: 'Done',
editorSlots: 'Slots', editorSlots: 'Slots',
editorLinks: 'Passages', editorLinks: 'Passages',
addLink: 'Link', addLink: 'Link',
+41 -15
View File
@@ -369,10 +369,44 @@ body {
color: var(--text); color: var(--text);
} }
.dialog--wide { .dialog--screen[open] {
min-width: 640px; display: flex;
max-width: 860px; flex-direction: column;
width: min(860px, calc(100vw - 32px)); min-width: unset;
max-width: none;
width: min(1080px, calc(100vw - 24px));
height: min(800px, calc(100vh - 24px));
padding: 16px 20px 20px;
}
.dialog--screen .screen__header {
flex: 0 0 auto;
margin-bottom: 12px;
}
.dialog--screen .screen__title {
font-size: 19px;
}
.dialog--screen .map-editor {
flex: 1;
min-height: 0;
grid-template-columns: minmax(220px, 0.9fr) minmax(280px, 1.3fr);
gap: 16px;
}
.dialog--screen .map-editor__pane,
.dialog--screen .map-editor__details {
min-height: 0;
overflow: auto;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
}
.dialog--screen .dialog__actions {
flex: 0 0 auto;
margin-top: 12px;
} }
.dialog::backdrop { .dialog::backdrop {
@@ -419,9 +453,9 @@ body {
color: var(--text-muted); color: var(--text-muted);
} }
.field__row { .field .hint {
display: flex; margin: 0;
gap: 8px; font-size: 13px;
} }
.mod-list { .mod-list {
@@ -437,14 +471,6 @@ body {
font-size: 14px; font-size: 14px;
} }
.map-editor-host {
max-height: 360px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px;
}
.map-editor { .map-editor {
display: grid; display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(240px, 1.4fr); grid-template-columns: minmax(180px, 1fr) minmax(240px, 1.4fr);
+45 -44
View File
@@ -11,7 +11,7 @@ import { fromDateAndTimeInputs, toDateAndTimeInputs } from '../format/gameTime.t
import { getLocale } from '../i18n/locale.ts'; import { getLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts'; import { t } from '../i18n/strings.ts';
import { el } from './dom.ts'; import { el } from './dom.ts';
import { mapEditor } from './mapEditor.ts'; import { mapEditorDialog } from './mapEditorDialog.ts';
import { Modal } from './modal.ts'; import { Modal } from './modal.ts';
interface CreateSchoolOptions { interface CreateSchoolOptions {
@@ -22,17 +22,16 @@ interface CreateSchoolOptions {
} }
/** /**
* Creation form: mods, a map (edit or reset to the pack default), then name and start date. * Creation form: mods, name and start date. The map editor is a separate dialog — this one
* stays short so the list behind it remains usable.
*/ */
export function createSchoolDialog(options: CreateSchoolOptions): Promise<School | null> { export function createSchoolDialog(options: CreateSchoolOptions): Promise<School | null> {
const modal = new Modal<School | null>(null); const modal = new Modal<School | null>(null);
modal.element.classList.add('dialog--wide');
const defaults = toDateAndTimeInputs(options.defaultStartDate); const defaults = toDateAndTimeInputs(options.defaultStartDate);
const extraModIds = new Set<string>(); const extraModIds = new Set<string>();
let catalog: CatalogResponse | null = null; let catalog: CatalogResponse | null = null;
let currentMap: MapLayout | null = null; let currentMap: MapLayout | null = null;
let editor: ReturnType<typeof mapEditor> | null = null;
const modsField = el('div', { class: 'field' }); const modsField = el('div', { class: 'field' });
const modsLabel = el('span', { class: 'field__label' }); const modsLabel = el('span', { class: 'field__label' });
@@ -40,9 +39,9 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
modsField.append(modsLabel, modsList); modsField.append(modsLabel, modsList);
const mapLabel = el('span', { class: 'field__label' }); const mapLabel = el('span', { class: 'field__label' });
const resetButton = el('button', { class: 'button', type: 'button' }); const editMapButton = el('button', { class: 'button', type: 'button' });
const editorHost = el('div', { class: 'map-editor-host' }); const mapHint = el('p', { class: 'hint' });
const mapField = el('div', { class: 'field' }, mapLabel, el('div', { class: 'field__row' }, resetButton), editorHost); const mapField = el('div', { class: 'field' }, mapLabel, editMapButton, mapHint);
const nameInput = el('input', { class: 'input', type: 'text' }); const nameInput = el('input', { class: 'input', type: 'text' });
nameInput.maxLength = 40; nameInput.maxLength = 40;
@@ -63,24 +62,16 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const submitButton = el('button', { class: 'button button--primary', type: 'submit' }); const submitButton = el('button', { class: 'button button--primary', type: 'submit' });
const cancelButton = el('button', { class: 'button', type: 'button', onClick: () => modal.close(null) }); const cancelButton = el('button', { class: 'button', type: 'button', onClick: () => modal.close(null) });
const title = el('h2', { class: 'dialog__title' }); const title = el('h2', { class: 'dialog__title' });
const nameLabel = el('span', { class: 'field__label' });
const startLabel = el('span', { class: 'field__label' });
const form = el( const form = el(
'form', 'form',
{ class: 'form' }, { class: 'form' },
modsField, modsField,
el('label', { class: 'field' }, nameLabel, el('div', { class: 'field__row' }, nameInput, randomButton)),
el('div', { class: 'field' }, startLabel, el('div', { class: 'field__row' }, dateInput, timeInput)),
mapField, mapField,
el(
'label',
{ class: 'field' },
el('span', { class: 'field__label', text: t('schoolName') }),
el('div', { class: 'field__row' }, nameInput, randomButton),
),
el(
'div',
{ class: 'field' },
el('span', { class: 'field__label', text: t('gameStart') }),
el('div', { class: 'field__row' }, dateInput, timeInput),
),
error, error,
el('div', { class: 'dialog__actions' }, cancelButton, submitButton), el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
); );
@@ -89,9 +80,10 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const setBusy = (value: boolean): void => { const setBusy = (value: boolean): void => {
busy = value; busy = value;
submitButton.toggleAttribute('disabled', value || catalog === null); const waiting = value || catalog === null;
submitButton.toggleAttribute('disabled', waiting);
randomButton.toggleAttribute('disabled', value); randomButton.toggleAttribute('disabled', value);
resetButton.toggleAttribute('disabled', value || catalog === null); editMapButton.toggleAttribute('disabled', waiting);
}; };
const showError = (message: string): void => { const showError = (message: string): void => {
@@ -99,22 +91,21 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
error.hidden = false; error.hidden = false;
}; };
const paintMapHint = (): void => {
if (catalog === null || currentMap === null) {
mapHint.textContent = '';
return;
}
mapHint.textContent = mapsEqual(currentMap, catalog.defaultMap)
? t('mapDefaultHint')
: t('mapEditedHint');
};
const applyCatalog = (next: CatalogResponse): void => { const applyCatalog = (next: CatalogResponse): void => {
catalog = next; catalog = next;
currentMap = structuredClone(next.defaultMap); currentMap = structuredClone(next.defaultMap);
if (editor === null) { paintMapHint();
editor = mapEditor({
catalog: next,
map: currentMap,
onChange: (map) => {
currentMap = map;
},
});
editorHost.append(editor.element);
} else {
editor.setCatalog(next, currentMap);
}
setBusy(busy); setBusy(busy);
}; };
@@ -167,29 +158,35 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
await reloadCatalog(); await reloadCatalog();
}; };
resetButton.addEventListener('click', () => {
if (catalog === null) {
return;
}
applyCatalog(catalog);
});
const localize = (): void => { const localize = (): void => {
title.textContent = t('newSchool'); title.textContent = t('newSchool');
modsLabel.textContent = t('modsTitle'); modsLabel.textContent = t('modsTitle');
nameLabel.textContent = t('schoolName');
startLabel.textContent = t('gameStart');
mapLabel.textContent = t('mapEditorTitle'); mapLabel.textContent = t('mapEditorTitle');
resetButton.textContent = t('resetMap'); editMapButton.textContent = t('editMap');
nameInput.placeholder = t('schoolNamePlaceholder'); nameInput.placeholder = t('schoolNamePlaceholder');
randomButton.textContent = t('randomName'); randomButton.textContent = t('randomName');
randomButton.title = t('randomNameTitle'); randomButton.title = t('randomNameTitle');
submitButton.textContent = t('create'); submitButton.textContent = t('create');
cancelButton.textContent = t('cancel'); cancelButton.textContent = t('cancel');
editor?.localize(); paintMapHint();
}; };
localize(); localize();
editMapButton.addEventListener('click', () => {
if (busy || catalog === null || currentMap === null) {
return;
}
void mapEditorDialog({ catalog, map: currentMap }).then((map) => {
currentMap = map;
paintMapHint();
editMapButton.focus();
});
});
randomButton.addEventListener('click', () => { randomButton.addEventListener('click', () => {
if (busy) { if (busy) {
return; return;
@@ -237,6 +234,10 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
return modal.open(nameInput); return modal.open(nameInput);
} }
function mapsEqual(left: MapLayout, right: MapLayout): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function describe(reason: unknown): string { function describe(reason: unknown): string {
if (!(reason instanceof ApiError)) { if (!(reason instanceof ApiError)) {
return t('serverUnavailable'); return t('serverUnavailable');
+2
View File
@@ -15,6 +15,7 @@ interface MapEditorOptions {
export function mapEditor(options: MapEditorOptions): { export function mapEditor(options: MapEditorOptions): {
readonly element: HTMLElement; readonly element: HTMLElement;
setCatalog: (catalog: CatalogResponse, map: MapLayout) => void; setCatalog: (catalog: CatalogResponse, map: MapLayout) => void;
getMap: () => MapLayout;
localize: () => void; localize: () => void;
} { } {
let catalog = options.catalog; let catalog = options.catalog;
@@ -227,6 +228,7 @@ export function mapEditor(options: MapEditorOptions): {
selectedId = map.territory.id; selectedId = map.territory.id;
render(); render();
}, },
getMap: () => cloneMap(map),
localize: () => render(), localize: () => render(),
}; };
} }
@@ -0,0 +1,55 @@
import type { CatalogResponse, MapLayout } from '../net/api.ts';
import { t } from '../i18n/strings.ts';
import { el } from './dom.ts';
import { mapEditor } from './mapEditor.ts';
import { Modal } from './modal.ts';
/**
* Full-size create-only map editor. Lives in its own dialog so the school form stays short.
* Escape and "Done" both keep the current layout — reset is the explicit undo.
*/
export function mapEditorDialog(options: {
catalog: CatalogResponse;
map: MapLayout;
}): Promise<MapLayout> {
const session = { map: structuredClone(options.map) };
const modal = new Modal(session);
const editor = mapEditor({
catalog: options.catalog,
map: session.map,
onChange: (map) => {
session.map = map;
},
});
const title = el('h2', { class: 'screen__title', text: t('mapEditorTitle') });
const resetButton = el('button', {
class: 'button',
type: 'button',
text: t('resetMap'),
onClick: () => {
const next = structuredClone(options.catalog.defaultMap);
session.map = next;
editor.setCatalog(options.catalog, next);
},
});
const doneButton = el('button', {
class: 'button button--primary',
type: 'button',
text: t('done'),
onClick: () => {
session.map = editor.getMap();
modal.close(session);
},
});
modal.element.classList.add('dialog--screen');
modal.element.append(
el('header', { class: 'screen__header' }, title, el('div', { class: 'screen__actions' }, resetButton)),
editor.element,
el('div', { class: 'dialog__actions' }, doneButton),
);
return modal.open(doneButton).then((value) => value.map);
}