From e9eee60dab76dcf7d13e8d08159a3414fef695ec Mon Sep 17 00:00:00 2001 From: aaverbitskiy Date: Sat, 15 Aug 2026 19:02:45 +0000 Subject: [PATCH] ui: toasts, icon segmented mode switch, steppers, date-range picker (v0.2.13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second modernization batch (reference: easystudy ui_elements): - Toast/snackbar notifications (dark, green check) for actions — copy link. - Mode switch: icons (horizontal bars / pin / columns) + a sliding active indicator; fixed width so the bold active label never resizes the block. - Appearance number fields became steppers: an editable field with centred −/+ buttons, ~20% narrower. - Date filter: the two native date inputs are replaced by a custom range picker — one field "Период (даты)" + a calendar popup with month nav and range selection (circle endpoints, light band between). The native inputs stay hidden as the source of truth so all filter/URL/chip wiring is unchanged. Co-Authored-By: Claude Opus 4.8 --- frontend/index.html | 26 ++++--- frontend/package.json | 2 +- frontend/src/appearance.ts | 38 +++++++++- frontend/src/datepicker.ts | 144 +++++++++++++++++++++++++++++++++++++ frontend/src/main.ts | 36 ++++++++-- frontend/src/styles.css | 91 +++++++++++++++++++++-- frontend/src/viewmode.ts | 11 +++ 7 files changed, 324 insertions(+), 24 deletions(-) create mode 100644 frontend/src/datepicker.ts diff --git a/frontend/index.html b/frontend/index.html index 232c297..26f3ebe 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -47,9 +47,10 @@
- - - + + + +
@@ -96,13 +97,18 @@ -
- - -
-
- - +
+ +
+
+ + Период + +
+ + + +
diff --git a/frontend/package.json b/frontend/package.json index c0e7f1d..109009f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mapdash-frontend", "private": true, - "version": "0.2.12", + "version": "0.2.13", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/appearance.ts b/frontend/src/appearance.ts index a385015..597bf06 100644 --- a/frontend/src/appearance.ts +++ b/frontend/src/appearance.ts @@ -98,21 +98,53 @@ export function applyCssVars(a: Appearance): void { // ---- settings menu UI ---------------------------------------------------- function numberRow(labelText: string, value: number, min: number, max: number, onInput: (v: number) => void): HTMLElement { - const row = document.createElement('label'); + // A stepper: −/+ buttons around an editable number field (type a value directly). + const row = document.createElement('div'); row.className = 'decor-row'; const span = document.createElement('span'); span.textContent = labelText; + + const stepper = document.createElement('div'); + stepper.className = 'decor-stepper'; const input = document.createElement('input'); input.type = 'number'; + input.className = 'ds-input'; input.min = String(min); input.max = String(max); input.value = String(value); + const dec = document.createElement('button'); + dec.type = 'button'; + dec.className = 'ds-btn'; + dec.textContent = '−'; + dec.setAttribute('aria-label', 'Уменьшить'); + const inc = document.createElement('button'); + inc.type = 'button'; + inc.className = 'ds-btn'; + inc.textContent = '+'; + inc.setAttribute('aria-label', 'Увеличить'); + + const clamp = (v: number): number => Math.max(min, Math.min(max, v)); + // While typing: apply the clamped value live but leave the field as-is so + // multi-digit input isn't fought (e.g. typing "30" when min is 8). input.addEventListener('input', () => { const v = parseInt(input.value, 10); - if (!Number.isNaN(v)) onInput(Math.max(min, Math.min(max, v))); + if (!Number.isNaN(v)) onInput(clamp(v)); }); + // On blur/Enter and on the buttons: normalise the field to the clamped value. + const commit = (v: number): void => { + const c = clamp(Number.isNaN(v) ? value : v); + input.value = String(c); + onInput(c); + }; + input.addEventListener('change', () => commit(parseInt(input.value, 10))); + dec.addEventListener('click', () => commit((parseInt(input.value, 10) || min) - 1)); + inc.addEventListener('click', () => commit((parseInt(input.value, 10) || min) + 1)); + + stepper.appendChild(dec); + stepper.appendChild(input); + stepper.appendChild(inc); row.appendChild(span); - row.appendChild(input); + row.appendChild(stepper); return row; } diff --git a/frontend/src/datepicker.ts b/frontend/src/datepicker.ts new file mode 100644 index 0000000..74bb989 --- /dev/null +++ b/frontend/src/datepicker.ts @@ -0,0 +1,144 @@ +// Custom date-range picker. The two native (#date-start / +// #date-end) stay in the DOM (hidden) as the source of truth — all filter code +// keeps reading/writing them — while this renders the field + calendar popup and +// dispatches `change` on them so the existing reload wiring fires. + +const MONTHS = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; +const WEEKDAYS = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс']; + +function parseISO(iso: string): Date | null { + return iso ? new Date(iso + 'T00:00:00') : null; +} +function toISO(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} +function fmt(iso: string): string { + const p = iso.split('-'); + return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso; +} + +export interface DateRange { + /** Refresh the field text (+ open calendar) from the hidden inputs. */ + sync(): void; +} + +export function createDateRange(): DateRange { + const startInput = document.getElementById('date-start') as HTMLInputElement; + const endInput = document.getElementById('date-end') as HTMLInputElement; + const field = document.getElementById('dr-field') as HTMLElement; + const text = document.getElementById('dr-text') as HTMLElement; + const clearBtn = document.getElementById('dr-clear') as HTMLElement; + const cal = document.getElementById('dr-cal') as HTMLElement; + + let viewY = new Date().getFullYear(); + let viewM = new Date().getMonth(); + + function updateText(): void { + const s = startInput.value; + const e = endInput.value; + if (s && e) text.textContent = `${fmt(s)} — ${fmt(e)}`; + else if (s) text.textContent = `с ${fmt(s)}`; + else if (e) text.textContent = `по ${fmt(e)}`; + else text.textContent = 'Период'; + clearBtn.style.display = s || e ? '' : 'none'; + field.classList.toggle('has-value', !!(s || e)); + } + + function render(): void { + const s = parseISO(startInput.value); + const e = parseISO(endInput.value); + const parts: string[] = [ + `
` + + `${MONTHS[viewM]} ${viewY}` + + `
`, + '
', + ]; + for (const w of WEEKDAYS) parts.push(`${w}`); + const lead = (new Date(viewY, viewM, 1).getDay() + 6) % 7; // Monday-first + const days = new Date(viewY, viewM + 1, 0).getDate(); + for (let i = 0; i < lead; i++) parts.push(''); + for (let d = 1; d <= days; d++) { + const cur = new Date(viewY, viewM, d); + const iso = toISO(cur); + let cls = 'dr-day'; + if (iso === startInput.value) cls += ' s'; + if (iso === endInput.value) cls += ' e'; + if (s && e && cur > s && cur < e) cls += ' rng'; + parts.push(``); + } + parts.push('
'); + cal.innerHTML = parts.join(''); + } + + function commit(): void { + startInput.dispatchEvent(new Event('change', { bubbles: true })); + endInput.dispatchEvent(new Event('change', { bubbles: true })); + updateText(); + } + + function pick(iso: string): void { + const s = startInput.value; + const e = endInput.value; + if (!s || (s && e)) { + // start a fresh range + startInput.value = iso; + endInput.value = ''; + } else if (iso >= s) { + endInput.value = iso; // complete the range + } else { + startInput.value = iso; // clicked before the start — move the start + } + commit(); + render(); + } + + function open(): void { + const base = parseISO(startInput.value) || new Date(); + viewY = base.getFullYear(); + viewM = base.getMonth(); + render(); + cal.hidden = false; + } + function close(): void { + cal.hidden = true; + } + + field.addEventListener('click', (ev) => { + if ((ev.target as HTMLElement).closest('.dr-clear')) return; + if (cal.hidden) open(); + else close(); + }); + field.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); if (cal.hidden) open(); else close(); } + }); + clearBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + startInput.value = ''; + endInput.value = ''; + commit(); + close(); + }); + cal.addEventListener('click', (ev) => { + const target = ev.target as HTMLElement; + const nav = target.closest('.dr-nav'); + if (nav) { + viewM += Number(nav.dataset.nav); + if (viewM < 0) { viewM = 11; viewY--; } else if (viewM > 11) { viewM = 0; viewY++; } + render(); + return; + } + const day = target.closest('.dr-day'); + if (day && day.dataset.iso) pick(day.dataset.iso); + }); + document.addEventListener('click', (ev) => { + if (!(ev.target as HTMLElement).closest('#daterange')) close(); + }); + + updateText(); + return { + sync(): void { + updateText(); + if (!cal.hidden) render(); + }, + }; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 64f81eb..f77a363 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -14,6 +14,7 @@ import 'vis-timeline/styles/vis-timeline-graph2d.min.css'; import { api } from './api'; import type { Board, Booking, Filters } from './types'; import { createDropdown } from './dropdown'; +import { createDateRange } from './datepicker'; import { createTimelineView, zoomSteps, type RenderFlags } from './timeline'; import { loadAppearance, applyCssVars, buildAppearanceMenu, statusLabel } from './appearance'; import { createMapView } from './map'; @@ -113,6 +114,7 @@ const brandDropdown = createDropdown('brand'); const dimensionDropdown = createDropdown('dimension'); const managerDropdown = createDropdown('manager'); const statusDropdown = createDropdown('status', statusLabel); +const dateRange = createDateRange(); const view = createTimelineView( { timelineEl: el.timelineEl, @@ -230,6 +232,32 @@ const fmtD = (iso: string): string => { return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso; }; +// ---- toast notifications (transient action feedback) ---- +let toastHost: HTMLElement | null = null; +function showToast(message: string): void { + if (!toastHost) { + toastHost = document.createElement('div'); + toastHost.className = 'toasts'; + document.body.appendChild(toastHost); + } + const t = document.createElement('div'); + t.className = 'toast'; + const ico = document.createElement('span'); + ico.className = 'toast-ico'; + ico.innerHTML = ''; + const txt = document.createElement('span'); + txt.textContent = message; + t.appendChild(ico); + t.appendChild(txt); + toastHost.appendChild(t); + void t.offsetWidth; // reflow so the enter transition runs (rAF is paused in bg tabs) + t.classList.add('show'); + window.setTimeout(() => { + t.classList.remove('show'); + window.setTimeout(() => t.remove(), 250); + }, 2500); +} + // ---- shareable filter links: current filters <-> URL query ---- function filtersToUrl(): void { const f = currentFilters(); @@ -288,11 +316,8 @@ function renderChips(): void { share.addEventListener('click', () => { navigator.clipboard ?.writeText(location.href) - .then(() => { - share.textContent = 'Ссылка скопирована'; - window.setTimeout(() => { share.textContent = 'Скопировать ссылку'; }, 1600); - }) - .catch(() => { /* clipboard unavailable */ }); + .then(() => showToast('Ссылка скопирована')) + .catch(() => showToast('Не удалось скопировать')); }); el.filterChips.appendChild(share); const divider = document.createElement('span'); @@ -341,6 +366,7 @@ async function loadData(fit: boolean): Promise { el.status.innerHTML = `Поверхности${boards.length}`; rebuildDecorMenu(); // status list may have changed renderChips(); + dateRange.sync(); // keep the date-range field in step with reset / chip-remove / URL filtersToUrl(); // Keep the map in sync when it is visible (facets filter it; dates do not). if (mapVisible()) void refreshMap(); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a9c6dba..e5098d0 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -121,6 +121,45 @@ header { @media (max-width: 760px) { .hgroup-fields { grid-template-columns: repeat(2, minmax(120px, 1fr)); } } /* Fields group: top-align so the date input sits UNDER Город/Бренд. */ .hgroup-fields { align-items: flex-start; } + +/* ---- date-range field + calendar popup ---- */ +.field-dates { grid-column: span 2; } +.dr-native { display: none; } +.daterange { position: relative; } +.dr-field { + display: flex; align-items: center; gap: 8px; width: 100%; box-sizing: border-box; + height: 36px; padding: 0 10px; cursor: pointer; font-size: 13px; color: var(--text-muted); + border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); + transition: border-color var(--transition); +} +.dr-field:hover { border-color: var(--border-strong); } +.dr-field.has-value { color: var(--text); } +.dr-ico { width: 16px; height: 16px; flex: 0 0 auto; color: var(--accent); } +.dr-text { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; } +.dr-clear { border: none; background: none; color: var(--text-muted); cursor: pointer; font-size: 16px; line-height: 1; padding: 0 2px; flex: 0 0 auto; } +.dr-clear:hover { color: var(--text); } +.dr-cal { + position: absolute; top: calc(100% + 4px); left: 0; z-index: 40; width: 252px; + background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: var(--shadow-md); padding: 10px; font-variant-numeric: tabular-nums; +} +.dr-cal[hidden] { display: none; } +.dr-cal-h { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.dr-cal-title { font-size: 13px; font-weight: 600; color: var(--text); } +.dr-nav { border: none; background: none; color: var(--text-muted); cursor: pointer; font-size: 18px; line-height: 1; width: 26px; height: 26px; border-radius: var(--radius-sm); } +.dr-nav:hover { background: var(--surface); color: var(--text); } +.dr-grid { display: grid; grid-template-columns: repeat(7, 1fr); } +.dr-wd { text-align: center; font-size: 11px; color: var(--text-muted); padding-bottom: 4px; } +.dr-day { position: relative; border: none; background: none; padding: 2px 0; cursor: pointer; display: flex; align-items: center; justify-content: center; height: 30px; } +.dr-day::before { content: ""; position: absolute; inset: 0; z-index: 0; } +.dr-day.rng::before { background: #eef0fe; } +.dr-day.s::before { background: linear-gradient(to right, transparent 50%, #eef0fe 50%); } +.dr-day.e::before { background: linear-gradient(to left, transparent 50%, #eef0fe 50%); } +.dr-day.s.e::before { background: transparent; } +.dr-num { position: relative; z-index: 1; display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; font-size: 12px; color: var(--text); } +.dr-day:hover .dr-num { background: var(--surface-2); } +.dr-day.s .dr-num, .dr-day.e .dr-num { background: var(--accent); color: #fff; font-weight: 600; } +.dr-day.s:hover .dr-num, .dr-day.e:hover .dr-num { background: var(--accent); } .fcol { display: flex; flex-direction: column; gap: 8px; } input[type=date], @@ -328,6 +367,24 @@ input::placeholder { color: var(--text-muted); } width: 84px; padding: 5px 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 13px; background: var(--bg); color: var(--text); } +/* Stepper: −/+ buttons around an editable number field. */ +.decor-stepper { display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; } +.decor-stepper .ds-btn { + display: flex; align-items: center; justify-content: center; padding: 0; + width: 22px; height: 28px; border: none; background: var(--surface); + color: var(--accent); font-size: 16px; line-height: 1; cursor: pointer; + transition: background var(--transition); +} +.decor-stepper .ds-btn:hover { background: var(--surface-2); } +.decor-stepper .ds-input { + width: 38px; height: 28px; text-align: center; padding: 0 2px; + border: none; border-left: 1px solid var(--border); border-right: 1px solid var(--border); + border-radius: 0; font-size: 13px; background: var(--bg); color: var(--text); + font-variant-numeric: tabular-nums; -moz-appearance: textfield; +} +.decor-stepper .ds-input::-webkit-outer-spin-button, +.decor-stepper .ds-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +.decor-stepper .ds-input:focus { outline: none; } .decor-row input[type=color] { width: 42px; height: 28px; padding: 0; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); cursor: pointer; @@ -351,18 +408,42 @@ input::placeholder { color: var(--text-muted); } /* ---- view modes: segmented control ---- */ .mode-switch { display: inline-flex; flex-direction: column; gap: 5px; - background: var(--surface-2); border: none; + background: var(--surface-2); border: none; position: relative; border-radius: var(--radius); padding: 6px; height: 100%; /* fill the header row so the bottom lines up with the date fields */ + width: 160px; /* fixed so the bold active label doesn't resize the block */ +} +/* Sliding active-mode indicator (JS sets top/height to the active button). */ +.mode-ind { + position: absolute; left: 6px; right: 6px; top: 6px; height: 0; z-index: 0; + background: var(--bg); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); + transition: top .2s var(--ease-3), height .2s var(--ease-3); pointer-events: none; } .mode-btn { + position: relative; z-index: 1; border: none; background: transparent; color: var(--text-muted); cursor: pointer; - padding: 6px 13px; font-size: 13px; white-space: nowrap; text-align: left; width: 100%; - border-radius: var(--radius-sm); transition: background var(--transition), color var(--transition); - flex: 1; display: flex; align-items: center; /* even heights, vertically centred */ + padding: 6px 12px; font-size: 13px; white-space: nowrap; text-align: left; width: 100%; + border-radius: var(--radius-sm); transition: color var(--transition); + flex: 1; display: flex; align-items: center; gap: 8px; /* icon + label */ } +.mode-ico { width: 16px; height: 16px; flex: 0 0 auto; } .mode-btn:hover { color: var(--text); } -.mode-btn.active { background: var(--bg); color: var(--accent); font-weight: 600; box-shadow: var(--shadow-sm); } +.mode-btn.active { color: var(--accent); font-weight: 600; } + +/* ---- toast notifications (bottom-centre, transient) ---- */ +.toasts { + position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%); z-index: 200; + display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; +} +.toast { + display: flex; align-items: center; gap: 9px; + background: var(--tip-bg); color: var(--tip-text); font-size: 13px; + padding: 9px 15px; border-radius: 9px; box-shadow: var(--shadow-md); + opacity: 0; transform: translateY(8px); + transition: opacity .22s var(--ease-3), transform .22s var(--ease-3); +} +.toast.show { opacity: 1; transform: translateY(0); } +.toast-ico { color: #37b24d; display: inline-flex; } /* Theme toggle */ .theme-toggle { diff --git a/frontend/src/viewmode.ts b/frontend/src/viewmode.ts index a6ab2cd..d463bfc 100644 --- a/frontend/src/viewmode.ts +++ b/frontend/src/viewmode.ts @@ -37,13 +37,23 @@ export function createViewModes(els: ViewModeElements, cb: ViewModeCallbacks): V els.view.style.setProperty('--split-left', savedSplit + '%'); } + function positionIndicator(): void { + const active = mode === 'timeline' ? els.btnTimeline : mode === 'map' ? els.btnMap : els.btnSplit; + const ind = els.btnTimeline.parentElement?.querySelector('.mode-ind'); + if (ind && active.offsetHeight) { + ind.style.top = active.offsetTop + 'px'; + ind.style.height = active.offsetHeight + 'px'; + } + } function apply(): void { els.view.classList.remove('mode-timeline', 'mode-map', 'mode-split'); els.view.classList.add('mode-' + mode); els.btnTimeline.classList.toggle('active', mode === 'timeline'); els.btnMap.classList.toggle('active', mode === 'map'); els.btnSplit.classList.toggle('active', mode === 'split'); + positionIndicator(); } + window.addEventListener('resize', positionIndicator); function setMode(next: ViewMode): void { mode = next; @@ -95,6 +105,7 @@ export function createViewModes(els: ViewModeElements, cb: ViewModeCallbacks): V const saved = localStorage.getItem(LS_MODE) as ViewMode | null; mode = saved === 'map' || saved === 'split' ? saved : 'timeline'; apply(); + requestAnimationFrame(positionIndicator); // header is laid out by the next frame return { getMode: () => mode,