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: 'Моды',
coreModLocked: '{id} (всегда включён)',
mapEditorTitle: 'Карта',
editMap: 'Редактировать карту',
mapDefaultHint: 'Будет использована карта по умолчанию.',
mapEditedHint: 'Карта изменена.',
resetMap: 'Сбросить к умолчанию',
done: 'Готово',
editorSlots: 'Слоты',
editorLinks: 'Проходы',
addLink: 'Связать',
@@ -121,7 +125,11 @@ const en: Messages = {
modsTitle: 'Mods',
coreModLocked: '{id} (always on)',
mapEditorTitle: 'Map',
editMap: 'Edit map',
mapDefaultHint: 'The default map will be used.',
mapEditedHint: 'The map has been edited.',
resetMap: 'Reset to default',
done: 'Done',
editorSlots: 'Slots',
editorLinks: 'Passages',
addLink: 'Link',
+41 -15
View File
@@ -369,10 +369,44 @@ body {
color: var(--text);
}
.dialog--wide {
min-width: 640px;
max-width: 860px;
width: min(860px, calc(100vw - 32px));
.dialog--screen[open] {
display: flex;
flex-direction: column;
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 {
@@ -419,9 +453,9 @@ body {
color: var(--text-muted);
}
.field__row {
display: flex;
gap: 8px;
.field .hint {
margin: 0;
font-size: 13px;
}
.mod-list {
@@ -437,14 +471,6 @@ body {
font-size: 14px;
}
.map-editor-host {
max-height: 360px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px;
}
.map-editor {
display: grid;
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 { t } from '../i18n/strings.ts';
import { el } from './dom.ts';
import { mapEditor } from './mapEditor.ts';
import { mapEditorDialog } from './mapEditorDialog.ts';
import { Modal } from './modal.ts';
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> {
const modal = new Modal<School | null>(null);
modal.element.classList.add('dialog--wide');
const defaults = toDateAndTimeInputs(options.defaultStartDate);
const extraModIds = new Set<string>();
let catalog: CatalogResponse | null = null;
let currentMap: MapLayout | null = null;
let editor: ReturnType<typeof mapEditor> | null = null;
const modsField = el('div', { class: 'field' });
const modsLabel = el('span', { class: 'field__label' });
@@ -40,9 +39,9 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
modsField.append(modsLabel, modsList);
const mapLabel = el('span', { class: 'field__label' });
const resetButton = el('button', { class: 'button', type: 'button' });
const editorHost = el('div', { class: 'map-editor-host' });
const mapField = el('div', { class: 'field' }, mapLabel, el('div', { class: 'field__row' }, resetButton), editorHost);
const editMapButton = el('button', { class: 'button', type: 'button' });
const mapHint = el('p', { class: 'hint' });
const mapField = el('div', { class: 'field' }, mapLabel, editMapButton, mapHint);
const nameInput = el('input', { class: 'input', type: 'text' });
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 cancelButton = el('button', { class: 'button', type: 'button', onClick: () => modal.close(null) });
const title = el('h2', { class: 'dialog__title' });
const nameLabel = el('span', { class: 'field__label' });
const startLabel = el('span', { class: 'field__label' });
const form = el(
'form',
{ class: 'form' },
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,
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,
el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
);
@@ -89,9 +80,10 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const setBusy = (value: boolean): void => {
busy = value;
submitButton.toggleAttribute('disabled', value || catalog === null);
const waiting = value || catalog === null;
submitButton.toggleAttribute('disabled', waiting);
randomButton.toggleAttribute('disabled', value);
resetButton.toggleAttribute('disabled', value || catalog === null);
editMapButton.toggleAttribute('disabled', waiting);
};
const showError = (message: string): void => {
@@ -99,22 +91,21 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
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 => {
catalog = next;
currentMap = structuredClone(next.defaultMap);
if (editor === null) {
editor = mapEditor({
catalog: next,
map: currentMap,
onChange: (map) => {
currentMap = map;
},
});
editorHost.append(editor.element);
} else {
editor.setCatalog(next, currentMap);
}
paintMapHint();
setBusy(busy);
};
@@ -167,29 +158,35 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
await reloadCatalog();
};
resetButton.addEventListener('click', () => {
if (catalog === null) {
return;
}
applyCatalog(catalog);
});
const localize = (): void => {
title.textContent = t('newSchool');
modsLabel.textContent = t('modsTitle');
nameLabel.textContent = t('schoolName');
startLabel.textContent = t('gameStart');
mapLabel.textContent = t('mapEditorTitle');
resetButton.textContent = t('resetMap');
editMapButton.textContent = t('editMap');
nameInput.placeholder = t('schoolNamePlaceholder');
randomButton.textContent = t('randomName');
randomButton.title = t('randomNameTitle');
submitButton.textContent = t('create');
cancelButton.textContent = t('cancel');
editor?.localize();
paintMapHint();
};
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', () => {
if (busy) {
return;
@@ -237,6 +234,10 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
return modal.open(nameInput);
}
function mapsEqual(left: MapLayout, right: MapLayout): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function describe(reason: unknown): string {
if (!(reason instanceof ApiError)) {
return t('serverUnavailable');
+2
View File
@@ -15,6 +15,7 @@ interface MapEditorOptions {
export function mapEditor(options: MapEditorOptions): {
readonly element: HTMLElement;
setCatalog: (catalog: CatalogResponse, map: MapLayout) => void;
getMap: () => MapLayout;
localize: () => void;
} {
let catalog = options.catalog;
@@ -227,6 +228,7 @@ export function mapEditor(options: MapEditorOptions): {
selectedId = map.territory.id;
render();
},
getMap: () => cloneMap(map),
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);
}