mapdash/frontend/src/appearance.ts
aaverbitskiy aeb4651f6f polish: appearance menu + clean status names everywhere (v0.2.1)
Appearance ("Оформление") menu:
- add missing status "РК. Предварительное бронирование" (8 statuses total)
- strip the systemic "РК. " prefix from status names in the UI (menu,
  tooltip, status filter dropdown, active-filter chips); raw values stay
  intact for filtering/URLs
- fixed-position menu: opens 25px below the header, ends >=30px above the
  window bottom, scrolls inside — never overflows the viewport
- drop the "Прочие статусы" row
- per-status grey reset glyph (circular arrows) restoring its default colour

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 15:37:03 +00:00

267 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Appearance settings: per-status bar colours, row/bar sizing, font sizes.
// Persisted in localStorage; applied via CSS variables + per-item inline styles.
export interface Appearance {
/** Colour per task_status (hex #rrggbb). Unknown statuses fall back to defaultColor. */
colors: Record<string, string>;
/** Colour for bars flagged as a date-collision (overrides the status colour). */
collisionColor: string;
/** Fallback colour for statuses without an explicit entry. */
defaultColor: string;
/** Height of a board row (container), in pixels. */
rowHeightPx: number;
/** Bar height as a percentage of the row height. */
barHeightPct: number;
/** Font size of the text inside bars, in pixels. */
barFontPx: number;
/** Font size of the address/code column (first column), in pixels. */
labelFontPx: number;
}
const LS_KEY = 'mapdash.appearance.v1';
/** Sensible out-of-the-box colours for the known statuses (lifecycle order). */
export const DEFAULT_STATUS_COLORS: Record<string, string> = {
'РК. Новая': '#4f46e5',
'РК. Предварительное бронирование': '#4f46e5',
'РК. Макет РИМ согласован': '#4f46e5',
'РК. Размещено': '#4f46e5',
'РК. Ожидает монтаж': '#4f46e5',
'РК. Ожидает демонтаж': '#4f46e5',
'РК. Ожидает перемонтаж': '#4f46e5',
'РК. Архив': '#4f46e5',
};
/** Display name for a status: strip the systemic "РК. " prefix. */
export function statusLabel(status: string): string {
return status.replace(/^РК\.\s+/, '');
}
export function defaultAppearance(): Appearance {
return {
colors: { ...DEFAULT_STATUS_COLORS },
collisionColor: '#e8590c',
defaultColor: '#4f46e5',
rowHeightPx: 30,
barHeightPct: 62,
barFontPx: 11,
labelFontPx: 12,
};
}
export function loadAppearance(): Appearance {
const base = defaultAppearance();
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return base;
const saved = JSON.parse(raw) as Partial<Appearance>;
return {
...base,
...saved,
colors: { ...base.colors, ...(saved.colors ?? {}) },
};
} catch {
return base;
}
}
export function saveAppearance(a: Appearance): void {
try {
localStorage.setItem(LS_KEY, JSON.stringify(a));
} catch {
/* storage unavailable — settings simply won't persist */
}
}
export function barHeightPx(a: Appearance): number {
return Math.max(1, Math.round((a.rowHeightPx * a.barHeightPct) / 100));
}
/** Symmetric top/bottom gap that centres the bar and yields rowHeightPx total. */
export function marginVertical(a: Appearance): number {
return Math.max(0, Math.round((a.rowHeightPx - barHeightPx(a)) / 2));
}
export function colorForStatus(a: Appearance, status: string): string {
return a.colors[status] || a.defaultColor;
}
/** Push size/font settings into CSS custom properties on :root. */
export function applyCssVars(a: Appearance): void {
const root = document.documentElement.style;
root.setProperty('--row-h', a.rowHeightPx + 'px');
root.setProperty('--bar-h', barHeightPx(a) + 'px');
root.setProperty('--bar-font', a.barFontPx + 'px');
root.setProperty('--label-font', a.labelFontPx + 'px');
}
// ---- settings menu UI ----------------------------------------------------
function numberRow(labelText: string, value: number, min: number, max: number, onInput: (v: number) => void): HTMLElement {
const row = document.createElement('label');
row.className = 'decor-row';
const span = document.createElement('span');
span.textContent = labelText;
const input = document.createElement('input');
input.type = 'number';
input.min = String(min);
input.max = String(max);
input.value = String(value);
input.addEventListener('input', () => {
const v = parseInt(input.value, 10);
if (!Number.isNaN(v)) onInput(Math.max(min, Math.min(max, v)));
});
row.appendChild(span);
row.appendChild(input);
return row;
}
function colorRow(labelText: string, value: string, onInput: (v: string) => void): HTMLElement {
const row = document.createElement('label');
row.className = 'decor-row';
const span = document.createElement('span');
span.textContent = labelText;
const input = document.createElement('input');
input.type = 'color';
input.value = value;
input.addEventListener('input', () => onInput(input.value));
row.appendChild(span);
row.appendChild(input);
return row;
}
// Circular two-arrows "reset" glyph (Feather refresh-cw).
const RESET_ICON =
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>';
/**
* A status colour row: clean status name, a grey reset glyph that restores this
* status's default colour, then the colour picker.
*/
function statusColorRow(status: string, value: string, defColor: string, onInput: (v: string) => void): HTMLElement {
const row = document.createElement('div');
row.className = 'decor-row';
const span = document.createElement('span');
span.textContent = statusLabel(status);
const wrap = document.createElement('div');
wrap.className = 'decor-color-wrap';
const reset = document.createElement('button');
reset.type = 'button';
reset.className = 'decor-color-reset';
reset.title = 'Сбросить цвет статуса';
reset.setAttribute('aria-label', 'Сбросить цвет статуса');
reset.innerHTML = RESET_ICON;
const input = document.createElement('input');
input.type = 'color';
input.value = value;
input.addEventListener('input', () => onInput(input.value));
reset.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
input.value = defColor;
onInput(defColor);
});
wrap.appendChild(reset);
wrap.appendChild(input);
row.appendChild(span);
row.appendChild(wrap);
return row;
}
function section(title: string): HTMLElement {
const s = document.createElement('div');
s.className = 'decor-section';
const h = document.createElement('div');
h.className = 'decor-section-title';
h.textContent = title;
s.appendChild(h);
return s;
}
/**
* (Re)build the appearance menu contents into `panel`.
* `statuses` — union of statuses to expose colour pickers for.
* `onChange` — called after every edit (already persisted).
*/
export function buildAppearanceMenu(
panel: HTMLElement,
statuses: string[],
a: Appearance,
onChange: () => void,
): void {
panel.innerHTML = '';
// 1) Colours
const colours = section('Цвета баров');
const known = Object.keys(DEFAULT_STATUS_COLORS);
const all = Array.from(new Set([...known, ...statuses]));
for (const st of all) {
colours.appendChild(
statusColorRow(st, colorForStatus(a, st), DEFAULT_STATUS_COLORS[st] || a.defaultColor, (v) => {
a.colors[st] = v;
saveAppearance(a);
onChange();
}),
);
}
colours.appendChild(
colorRow('Коллизия', a.collisionColor, (v) => {
a.collisionColor = v;
saveAppearance(a);
onChange();
}),
);
panel.appendChild(colours);
// 2) Sizes
const sizes = section('Размеры строк и баров');
sizes.appendChild(
numberRow('Высота строки, px', a.rowHeightPx, 8, 120, (v) => {
a.rowHeightPx = v;
saveAppearance(a);
onChange();
}),
);
sizes.appendChild(
numberRow('Высота бара, % от строки', a.barHeightPct, 10, 100, (v) => {
a.barHeightPct = v;
saveAppearance(a);
onChange();
}),
);
panel.appendChild(sizes);
// 3) Fonts
const fonts = section('Размер шрифта');
fonts.appendChild(
numberRow('В барах, px', a.barFontPx, 6, 32, (v) => {
a.barFontPx = v;
saveAppearance(a);
onChange();
}),
);
fonts.appendChild(
numberRow('В колонке адресов, px', a.labelFontPx, 6, 32, (v) => {
a.labelFontPx = v;
saveAppearance(a);
onChange();
}),
);
panel.appendChild(fonts);
// Reset
const reset = document.createElement('button');
reset.type = 'button';
reset.className = 'decor-reset';
reset.textContent = 'Сбросить к значениям по умолчанию';
reset.addEventListener('click', () => {
const d = defaultAppearance();
Object.assign(a, d);
a.colors = { ...d.colors };
saveAppearance(a);
buildAppearanceMenu(panel, statuses, a, onChange);
onChange();
});
panel.appendChild(reset);
}