diff --git a/server/src/ledgrab/api/routes/preferences.py b/server/src/ledgrab/api/routes/preferences.py index c781ea1..198ab80 100644 --- a/server/src/ledgrab/api/routes/preferences.py +++ b/server/src/ledgrab/api/routes/preferences.py @@ -37,6 +37,7 @@ router = APIRouter() _DASHBOARD_LAYOUT_KEY = "dashboard_layout" _NOTIFICATION_PREFS_KEY = "notification_preferences" +_CARD_MODES_KEY = "card_modes" class DaylightTimezonePreference(BaseModel): @@ -163,6 +164,90 @@ async def put_notification_preferences( return body +# --------------------------------------------------------------------------- +# Card presentation modes (per-surface comfortable/compact/dense) +# --------------------------------------------------------------------------- + + +_VALID_CARD_MODES = {"comfortable", "compact", "dense", "row"} + + +@router.get( + "/api/v1/preferences/card-modes", + tags=["Preferences"], +) +async def get_card_modes( + _: AuthRequired, + db: Database = Depends(get_database), +) -> dict[str, Any]: + """Read the saved card-mode preferences. Returns an empty object when + nothing has been saved yet — the frontend falls back to the default + mode ("compact") for every surface in that case.""" + value = db.get_setting(_CARD_MODES_KEY) + return value if value is not None else {} + + +@router.put( + "/api/v1/preferences/card-modes", + tags=["Preferences"], +) +async def put_card_modes( + _: AuthRequired, + body: dict[str, Any] = Body(...), + db: Database = Depends(get_database), +) -> dict[str, bool]: + """Save card-mode preferences. The body must be a JSON object shaped + like ``{"version": 1, "surfaces": {"": "", …}}``. + + The surface registry is intentionally open (any string accepted) so + new card surfaces can adopt the toggle without a server migration. + Invalid mode values are rejected to prevent a bad client from + poisoning the stored value.""" + if not isinstance(body, dict): + raise HTTPException(status_code=422, detail="Body must be a JSON object") + if not isinstance(body.get("version"), int): + raise HTTPException( + status_code=422, + detail="Body must include a numeric 'version' field", + ) + surfaces = body.get("surfaces", {}) + if not isinstance(surfaces, dict): + raise HTTPException( + status_code=422, + detail="'surfaces' must be an object mapping surface keys to modes", + ) + for key, mode in surfaces.items(): + if not isinstance(key, str) or not key: + raise HTTPException( + status_code=422, + detail=f"Surface keys must be non-empty strings (got {key!r})", + ) + if mode not in _VALID_CARD_MODES: + raise HTTPException( + status_code=422, + detail=( + f"Surface {key!r} has invalid mode {mode!r}; " + f"expected one of {sorted(_VALID_CARD_MODES)}" + ), + ) + db.set_setting(_CARD_MODES_KEY, body) + return {"ok": True} + + +@router.delete( + "/api/v1/preferences/card-modes", + tags=["Preferences"], +) +async def delete_card_modes( + _: AuthRequired, + db: Database = Depends(get_database), +) -> dict[str, bool]: + """Delete saved card-mode preferences — every surface reverts to the + frontend default on next load.""" + db.set_setting(_CARD_MODES_KEY, {}) + return {"ok": True} + + # --------------------------------------------------------------------------- # Daylight timezone (global) # --------------------------------------------------------------------------- diff --git a/server/src/ledgrab/static/css/all.css b/server/src/ledgrab/static/css/all.css index 91f0b9d..0e01d61 100644 --- a/server/src/ledgrab/static/css/all.css +++ b/server/src/ledgrab/static/css/all.css @@ -10,6 +10,7 @@ @import './advanced-calibration.css'; @import './dashboard.css'; @import './dashboard-customize.css'; +@import './card-modes.css'; @import './streams.css'; @import './patterns.css'; @import './automations.css'; diff --git a/server/src/ledgrab/static/css/base.css b/server/src/ledgrab/static/css/base.css index f6b1592..694c1f6 100644 --- a/server/src/ledgrab/static/css/base.css +++ b/server/src/ledgrab/static/css/base.css @@ -43,6 +43,28 @@ --space-lg: 20px; --space-xl: 40px; + /* ── Card grid sizing ────────────────────────────────────────────── + Tokens for the auto-fill card grids (devices, displays, dashboard + targets, integrations, autostart). Defaults reproduce the values + that were inline before tokenization, so this layer is a no-op + until the card-mode toggle wires `[data-card-mode=…]` overrides. + + · `*-min` — minmax() column width for the main module + cards (devices, displays, dashboard targets/scenes). + · `*-min-narrow` — column width for slimmer dashboard-module + rows (integrations, autostart). + · `*-gap` / `*-gap-narrow` — corresponding row/column gap. */ + --card-grid-min: 380px; + --card-grid-gap: 14px; + --card-grid-min-narrow: 320px; + --card-grid-gap-narrow: 12px; + + /* Capture-template / source-card grids (sources, streams, templates, + color strips) have their own column proportions so they stay + distinct from device/target cards. */ + --templates-grid-min: 350px; + --templates-grid-gap: 20px; + /* Border radius */ --radius: 8px; --radius-sm: 4px; diff --git a/server/src/ledgrab/static/css/card-modes.css b/server/src/ledgrab/static/css/card-modes.css new file mode 100644 index 0000000..3d78ff3 --- /dev/null +++ b/server/src/ledgrab/static/css/card-modes.css @@ -0,0 +1,223 @@ +/* ────────────────────────────────────────────────────────────────────── + * Card presentation modes — `[data-card-mode="comfortable|compact|dense"]` + * + * Sister to the dashboard `[data-density]` system (which governs section + * header/gap only). This file targets the **card grids and the cards + * themselves**: column min-width, gap, internal padding, and which + * mod-* blocks render visibly. + * + * Apply the attribute to the grid container OR any ancestor (the page + * tab, the section). All tokens cascade. + * + *
+ *
+ *
+ * + *
+ * … + *
+ * + * Defaults (= `compact`) live in base.css :root; this file only overrides + * for `comfortable` and `dense`. Default mode is implicit; the attribute + * may be omitted on grids that haven't migrated yet. + * ────────────────────────────────────────────────────────────────────── */ + +/* ── Comfortable: roomier columns, expanded card padding ─────────── */ +[data-card-mode="comfortable"] { + --card-grid-min: 440px; + --card-grid-gap: 18px; + --card-grid-min-narrow: 360px; + --card-grid-gap-narrow: 16px; + --templates-grid-min: 400px; + --templates-grid-gap: 22px; +} + +[data-card-mode="comfortable"] .card, +[data-card-mode="comfortable"] .template-card { + padding: 22px 24px 20px; +} + +[data-card-mode="comfortable"] .dashboard-target:has(.mod-head) { + padding: 20px 22px 18px 26px; + gap: 16px; +} + +[data-card-mode="comfortable"] .dashboard-autostart:has(.mod-head), +[data-card-mode="comfortable"] .dashboard-integration:has(.mod-head) { + padding: 18px 20px 16px; +} + +/* ── Dense: tight columns, slim padding, hide auxiliary mod-* blocks ── */ +[data-card-mode="dense"] { + --card-grid-min: 260px; + --card-grid-gap: 8px; + --card-grid-min-narrow: 220px; + --card-grid-gap-narrow: 6px; + --templates-grid-min: 240px; + --templates-grid-gap: 10px; +} + +[data-card-mode="dense"] .card, +[data-card-mode="dense"] .template-card { + padding: 12px 14px 10px; +} + +[data-card-mode="dense"] .dashboard-target:has(.mod-head) { + padding: 10px 14px 10px 18px; + gap: 8px; +} + +[data-card-mode="dense"] .dashboard-autostart:has(.mod-head), +[data-card-mode="dense"] .dashboard-integration:has(.mod-head) { + padding: 10px 12px 8px; + gap: 6px; +} + +/* Auxiliary content drops out in dense — keeps identity (icon, name, + badge, dot) and primary control surfaces, sheds preview + secondary + text. The actual data-bearing metric row is preserved. */ +[data-card-mode="dense"] .mod-leds { + display: none; +} + +[data-card-mode="dense"] .mod-head { + gap: 6px; + margin-bottom: 6px; +} + +[data-card-mode="dense"] .mod-foot { + padding-top: 6px; + gap: 4px; +} + +/* Secondary text-button labels collapse to icon-only in dense; icon-only + buttons and the kebab menu are unaffected. Primary action keeps its + label so the "what does this card do" affordance survives. */ +[data-card-mode="dense"] .mod-btn:not(.mod-btn-icon):not(.mod-btn-primary) .mod-btn-label { + display: none; +} + +[data-card-mode="dense"] .mod-metrics { + gap: 4px 8px; +} + +[data-card-mode="dense"] .mod-metric .k { + font-size: 0.62rem; +} + +[data-card-mode="dense"] .mod-metric .v { + font-size: 0.92rem; +} + +/* Dashed corner bracket and channel stripe stay — they are the card's + identity even at small sizes. No display:none on ::before / ::after. */ + +/* ── Row: single column, full-width stacked list ───────────────────── + * Differs from `dense` in layout, not just padding: the grid collapses + * to one column so every card spans the available width. Cards keep + * their column-flex internals (mod-head → metrics → foot) — a true + * horizontal row layout would require rewriting the mod-card vocabulary + * and is a separate future mode. + * ────────────────────────────────────────────────────────────────── */ +[data-card-mode="row"] { + --card-grid-min: 100%; + --card-grid-gap: 6px; + --card-grid-min-narrow: 100%; + --card-grid-gap-narrow: 6px; + --templates-grid-min: 100%; + --templates-grid-gap: 6px; +} + +[data-card-mode="row"] .card, +[data-card-mode="row"] .template-card { + padding: 10px 14px 10px; +} + +[data-card-mode="row"] .dashboard-target:has(.mod-head) { + padding: 10px 14px 10px 18px; + gap: 8px; +} + +[data-card-mode="row"] .dashboard-autostart:has(.mod-head), +[data-card-mode="row"] .dashboard-integration:has(.mod-head) { + padding: 10px 12px 8px; + gap: 6px; +} + +/* Same auxiliary trims as `dense` — the row layout is information-dense + by nature, so secondary visuals drop out for the same reasons. */ +[data-card-mode="row"] .mod-leds { + display: none; +} + +[data-card-mode="row"] .mod-head { + gap: 6px; + margin-bottom: 6px; +} + +[data-card-mode="row"] .mod-foot { + padding-top: 6px; + gap: 4px; +} + +[data-card-mode="row"] .mod-btn:not(.mod-btn-icon):not(.mod-btn-primary) .mod-btn-label { + display: none; +} + +[data-card-mode="row"] .mod-metrics { + gap: 4px 8px; +} + +[data-card-mode="row"] .mod-metric .k { + font-size: 0.62rem; +} + +[data-card-mode="row"] .mod-metric .v { + font-size: 0.92rem; +} + + + +/* ────────────────────────────────────────────────────────────────────── + * Segmented `C / M / D` toggle — sibling of dash-cust-density but + * standalone so any section header / page toolbar can host one without + * pulling in the dashboard-customize stylesheet. + * ────────────────────────────────────────────────────────────────────── */ +.card-mode-toggle { + display: inline-flex; + gap: 2px; + padding: 2px; + border: var(--lux-hairline, 1px) solid var(--lux-line, var(--border-color)); + border-radius: var(--lux-r-sm, var(--radius-sm)); + background: var(--lux-bg-1, var(--card-bg)); +} + +.card-mode-toggle__btn { + appearance: none; + border: 0; + background: transparent; + color: var(--lux-ink-dim, var(--text-secondary)); + font: 600 0.7rem/1 var(--font-mono, monospace); + letter-spacing: 0.04em; + padding: 4px 8px; + min-width: 22px; + cursor: pointer; + border-radius: calc(var(--lux-r-sm, var(--radius-sm)) - 1px); + transition: background 0.15s ease, color 0.15s ease; +} + +.card-mode-toggle__btn:hover { + color: var(--lux-ink, var(--text-color)); + background: var(--hover-bg, rgba(255, 255, 255, 0.04)); +} + +.card-mode-toggle__btn.is-active { + color: var(--primary-contrast, #fff); + background: var(--primary-color); +} + +.card-mode-toggle__btn:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; +} + diff --git a/server/src/ledgrab/static/css/cards.css b/server/src/ledgrab/static/css/cards.css index 7d8eef5..99158f8 100644 --- a/server/src/ledgrab/static/css/cards.css +++ b/server/src/ledgrab/static/css/cards.css @@ -89,8 +89,8 @@ section { .displays-grid, .devices-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr)); - gap: 14px; + grid-template-columns: repeat(auto-fill, minmax(min(var(--card-grid-min, 380px), 100%), 1fr)); + gap: var(--card-grid-gap, 14px); } .devices-grid > .loading, diff --git a/server/src/ledgrab/static/css/dashboard.css b/server/src/ledgrab/static/css/dashboard.css index dfcc091..fb8a925 100644 --- a/server/src/ledgrab/static/css/dashboard.css +++ b/server/src/ledgrab/static/css/dashboard.css @@ -71,8 +71,8 @@ .dashboard-subsection .dashboard-section-content { display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr)); - gap: 14px; + grid-template-columns: repeat(auto-fill, minmax(min(var(--card-grid-min, 380px), 100%), 1fr)); + gap: var(--card-grid-gap, 14px); } .dashboard-subsection .dashboard-section-content .dashboard-target { @@ -958,8 +958,8 @@ button.mod-icon.is-empty:hover svg { transform: none; opacity: 1; } .dashboard-integrations-grid, .dashboard-autostart-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); - gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(min(var(--card-grid-min-narrow, 320px), 100%), 1fr)); + gap: var(--card-grid-gap-narrow, 12px); } /* Legacy row-style overrides kept for any card that still lacks .mod-head */ diff --git a/server/src/ledgrab/static/css/streams.css b/server/src/ledgrab/static/css/streams.css index 0fe7f66..a0e48f3 100644 --- a/server/src/ledgrab/static/css/streams.css +++ b/server/src/ledgrab/static/css/streams.css @@ -4,8 +4,8 @@ .templates-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); - gap: 20px; + grid-template-columns: repeat(auto-fill, minmax(var(--templates-grid-min, 350px), 1fr)); + gap: var(--templates-grid-gap, 20px); } .template-card { diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index bae8dcb..02d4442 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -57,6 +57,9 @@ import { import { hydrateDashboardLayoutFromCache, syncDashboardLayoutFromServer, } from './features/dashboard-layout.ts'; +import { + hydrateCardModesFromCache, syncCardModesFromServer, +} from './features/card-modes.ts'; import { openDashboardCustomize, closeDashboardCustomize, } from './features/dashboard-customize.ts'; @@ -728,6 +731,7 @@ document.addEventListener('DOMContentLoaded', async () => { // already reflects the user's saved customizations (no flash of // default-then-custom). Server sync runs after auth. hydrateDashboardLayoutFromCache(); + hydrateCardModesFromCache(); // Initialize locale (dispatches languageChanged which may trigger API calls) await initLocale(); @@ -828,6 +832,7 @@ document.addEventListener('DOMContentLoaded', async () => { // across browsers). Fire-and-forget — the cached layout is already // active; this overwrites it if the server has a newer copy. syncDashboardLayoutFromServer(); + syncCardModesFromServer(); // Trigger the active tab's loader — initTabs() ran before authRequired // was known, so its conditional loader call may have been skipped. diff --git a/server/src/ledgrab/static/js/core/card-sections.ts b/server/src/ledgrab/static/js/core/card-sections.ts index 421c514..26c5e74 100644 --- a/server/src/ledgrab/static/js/core/card-sections.ts +++ b/server/src/ledgrab/static/js/core/card-sections.ts @@ -24,6 +24,7 @@ import { t } from './i18n.ts'; import { showBulkToolbar, hideBulkToolbar, updateBulkToolbar } from './bulk-toolbar.ts'; import { ICON_LIST_CHECKS, ICON_EYE, ICON_EYE_OFF, ICON_CHECK } from './icons.ts'; +import { mountCardModeToggle } from '../features/card-modes.ts'; export interface BulkAction { key: string; @@ -144,6 +145,7 @@ export class CardSection { _pendingReconcile: CardItem[] | null; _animated: boolean; _showHidden: boolean; + _cardModeUnsubscribe: (() => void) | null; constructor(sectionKey: string, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible, emptyKey, bulkActions }: CardSectionOpts) { this.sectionKey = sectionKey; @@ -167,6 +169,7 @@ export class CardSection { this._pendingReconcile = null; this._animated = false; this._showHidden = false; + this._cardModeUnsubscribe = null; _sectionRegistry.set(sectionKey, this); } @@ -214,6 +217,7 @@ export class CardSection { ${this.headerExtra ? `${this.headerExtra}` : ''} ${hiddenToggle} ${this.bulkActions ? `` : ''} +
@@ -236,11 +240,30 @@ export class CardSection { if (this.collapsible) { header.addEventListener('mousedown', (e) => { - if ((e.target as HTMLElement).closest('.cs-filter-wrap') || (e.target as HTMLElement).closest('.cs-header-extra')) return; + const tgt = e.target as HTMLElement; + if (tgt.closest('.cs-filter-wrap') || tgt.closest('.cs-header-extra') || tgt.closest('.cs-mode-slot')) return; this._toggleCollapse(header, content); }); } + // Card-mode segmented toggle — mounts into the placeholder slot + // in the header. Re-mounted on every bind() because the DOM has + // been recreated; old subscriptions are torn down first to keep + // the listener Set bounded. + if (this._cardModeUnsubscribe) { + this._cardModeUnsubscribe(); + this._cardModeUnsubscribe = null; + } + const wrapper = document.querySelector(`[data-card-section="${this.sectionKey}"]`) as HTMLElement | null; + const modeSlot = document.querySelector(`[data-cs-mode-slot="${this.sectionKey}"]`) as HTMLElement | null; + if (wrapper && modeSlot) { + this._cardModeUnsubscribe = mountCardModeToggle({ + container: modeSlot, + surface: this.sectionKey, + host: wrapper, + }); + } + if (filterInput) { const resetBtn = document.querySelector(`[data-cs-filter-reset="${this.sectionKey}"]`) as HTMLElement | null; const updateResetVisibility = () => { diff --git a/server/src/ledgrab/static/js/features/card-modes.ts b/server/src/ledgrab/static/js/features/card-modes.ts new file mode 100644 index 0000000..889cb97 --- /dev/null +++ b/server/src/ledgrab/static/js/features/card-modes.ts @@ -0,0 +1,261 @@ +/** + * Card presentation modes — per-surface size toggle (comfortable / compact / dense). + * + * Sibling to `dashboard-layout.ts` but slimmer: a single open registry of + * surface keys ('devices', 'displays', 'dashboard-targets', …) each mapped + * to one of three modes. CSS does the heavy lifting via the + * `[data-card-mode="…"]` attribute (see card-modes.css). + * + * Boot sequence (matches dashboard-layout): + * 1. hydrateCardModesFromCache() — synchronous, called before first paint + * 2. syncCardModesFromServer() — async, runs after auth completes + * + * Persistence: + * - localStorage `card_modes_v1` cache for instant first paint + * - server `GET/PUT /preferences/card-modes` for cross-browser truth + * - PUT is debounced 300 ms; subscribers fire synchronously on save + * + * Surface keys are free-form strings — anything calling `setCardMode` is + * implicitly registering that key. Defaults are returned for unknown keys. + */ +import { fetchWithAuth } from '../core/api.ts'; +import { t } from '../core/i18n.ts'; + +const LS_KEY = 'card_modes_v1'; +const SCHEMA_VERSION = 1; + +export type CardMode = 'comfortable' | 'compact' | 'dense' | 'row'; + +export const CARD_MODES: readonly CardMode[] = ['comfortable', 'compact', 'dense', 'row'] as const; + +const DEFAULT_MODE: CardMode = 'compact'; + +export interface CardModePrefsV1 { + version: 1; + surfaces: Record; +} + +const DEFAULT_PREFS: CardModePrefsV1 = { + version: SCHEMA_VERSION, + surfaces: {}, +}; + +let _current: CardModePrefsV1 = _clone(DEFAULT_PREFS); +let _serverSyncedOnce = false; +let _saveTimer: ReturnType | null = null; +const _listeners = new Set<() => void>(); + +function _clone(prefs: CardModePrefsV1): CardModePrefsV1 { + return { version: prefs.version, surfaces: { ...prefs.surfaces } }; +} + +function _isCardMode(v: unknown): v is CardMode { + return v === 'comfortable' || v === 'compact' || v === 'dense' || v === 'row'; +} + +/** Normalise a parsed value back into a valid prefs object, dropping + * garbage values. Tolerates older/forward versions by treating the + * surfaces map as the only authoritative payload. */ +function _normalise(parsed: unknown): CardModePrefsV1 { + const out: CardModePrefsV1 = _clone(DEFAULT_PREFS); + if (!parsed || typeof parsed !== 'object') return out; + const obj = parsed as Record; + const surfaces = obj.surfaces; + if (surfaces && typeof surfaces === 'object') { + for (const [k, v] of Object.entries(surfaces as Record)) { + if (typeof k === 'string' && _isCardMode(v)) { + out.surfaces[k] = v; + } + } + } + return out; +} + +function _notify(): void { + for (const fn of _listeners) { + try { fn(); } catch (e) { console.error('card-modes listener', e); } + } +} + +/** Read the current prefs. Defensive copy. */ +export function getCardModePrefs(): CardModePrefsV1 { + return _clone(_current); +} + +/** Effective mode for a surface — returns the configured value or the + * default when the surface is unset. */ +export function getCardMode(surface: string): CardMode { + return _current.surfaces[surface] ?? DEFAULT_MODE; +} + +/** Persist a surface's mode. Updates cache, fires subscribers, + * schedules debounced server PUT. */ +export function setCardMode(surface: string, mode: CardMode): void { + if (!_isCardMode(mode)) return; + if (_current.surfaces[surface] === mode) return; + _current = { + version: _current.version, + surfaces: { ..._current.surfaces, [surface]: mode }, + }; + _persistLocal(); + _notify(); + _scheduleServerPush(); +} + +/** Subscribe to mode changes (any surface). Returns an unsubscribe fn. */ +export function subscribeCardModes(fn: () => void): () => void { + _listeners.add(fn); + return () => _listeners.delete(fn); +} + +function _persistLocal(): void { + try { localStorage.setItem(LS_KEY, JSON.stringify(_current)); } catch { /* quota */ } +} + +function _scheduleServerPush(): void { + if (_saveTimer) clearTimeout(_saveTimer); + _saveTimer = setTimeout(() => { + _saveTimer = null; + _pushToServer(_current).catch(e => console.warn('card-modes PUT failed', e)); + }, 300); +} + +async function _pushToServer(prefs: CardModePrefsV1): Promise { + try { + await fetchWithAuth('/preferences/card-modes', { + method: 'PUT', + body: JSON.stringify(prefs), + }); + } catch (e) { + console.warn('card-modes server PUT failed', e); + } +} + +/** Hydrate from localStorage cache. Synchronous — safe to call before + * auth so the first paint already uses the user's saved modes. */ +export function hydrateCardModesFromCache(): CardModePrefsV1 { + try { + const raw = localStorage.getItem(LS_KEY); + if (raw) { + _current = _normalise(JSON.parse(raw)); + } + } catch (e) { + console.warn('card-modes cache parse failed', e); + } + return _clone(_current); +} + +/** Pull prefs from server (post-auth). Replaces local cache when server + * has a saved value; otherwise pushes local cache up so other browsers + * inherit it. Safe to call repeatedly — only runs the round-trip once. */ +export async function syncCardModesFromServer(): Promise { + if (_serverSyncedOnce) return; + try { + const resp = await fetchWithAuth('/preferences/card-modes'); + if (!resp || !resp.ok) return; + const data = await resp.json(); + if (data && typeof data === 'object' && (data as Record).version) { + _current = _normalise(data); + _persistLocal(); + _notify(); + } else { + // Server has nothing — push what we have so the next browser + // picks up the same view. + if (Object.keys(_current.surfaces).length > 0) { + await _pushToServer(_current); + } + } + _serverSyncedOnce = true; + } catch (e) { + console.warn('card-modes server sync failed', e); + } +} + +// ───────────────────────────────────────────────────────────────────────── +// DOM helpers +// ───────────────────────────────────────────────────────────────────────── + +/** Apply `data-card-mode` to a host element based on the current pref + * for `surface`. Idempotent; safe to call on the same element after a + * mode change to re-sync. */ +export function applyCardModeAttr(host: HTMLElement, surface: string): void { + host.setAttribute('data-card-mode', getCardMode(surface)); +} + +/** Bind a host element to a surface's mode. Re-applies the attribute + * whenever the pref for that surface changes. Returns an unsubscribe + * function for use in a cleanup hook. */ +export function bindCardModeAttr(host: HTMLElement, surface: string): () => void { + applyCardModeAttr(host, surface); + return subscribeCardModes(() => applyCardModeAttr(host, surface)); +} + +// ───────────────────────────────────────────────────────────────────────── +// Toggle UI +// ───────────────────────────────────────────────────────────────────────── + +export interface MountCardModeToggleOpts { + /** Element to append the toggle to. */ + container: HTMLElement; + /** Surface key whose mode this toggle controls. */ + surface: string; + /** Optional host element to receive the `data-card-mode` attribute. + * Defaults to `container.parentElement ?? container`. Pass the grid + * container (or any common ancestor of the cards) so CSS overrides + * cascade correctly. */ + host?: HTMLElement; + /** Position hint for screen readers + analytics. */ + label?: string; +} + +const _MODE_BUTTONS: ReadonlyArray<{ v: CardMode; lbl: string; key: string }> = [ + { v: 'comfortable', lbl: 'C', key: 'card_mode.comfortable' }, + { v: 'compact', lbl: 'M', key: 'card_mode.compact' }, + { v: 'dense', lbl: 'D', key: 'card_mode.dense' }, + { v: 'row', lbl: 'R', key: 'card_mode.row' }, +]; + +/** Mount a segmented `C / M / D` toggle that drives the given surface. + * Returns a teardown function that removes the toggle and unsubscribes. */ +export function mountCardModeToggle(opts: MountCardModeToggleOpts): () => void { + const host = opts.host ?? opts.container.parentElement ?? opts.container; + const surface = opts.surface; + + const wrap = document.createElement('div'); + wrap.className = 'card-mode-toggle'; + wrap.setAttribute('role', 'radiogroup'); + wrap.setAttribute('aria-label', opts.label || t('card_mode.tooltip') || 'Card size'); + + const buttons: HTMLButtonElement[] = _MODE_BUTTONS.map(b => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'card-mode-toggle__btn'; + btn.dataset.cardMode = b.v; + btn.setAttribute('role', 'radio'); + btn.textContent = b.lbl; + btn.title = t(b.key) || b.v; + btn.setAttribute('aria-label', t(b.key) || b.v); + btn.addEventListener('click', () => setCardMode(surface, b.v)); + wrap.appendChild(btn); + return btn; + }); + + const refresh = () => { + const current = getCardMode(surface); + for (const btn of buttons) { + const isActive = btn.dataset.cardMode === current; + btn.classList.toggle('is-active', isActive); + btn.setAttribute('aria-checked', isActive ? 'true' : 'false'); + } + applyCardModeAttr(host, surface); + }; + + refresh(); + opts.container.appendChild(wrap); + const unsubscribe = subscribeCardModes(refresh); + + return () => { + unsubscribe(); + wrap.remove(); + }; +} diff --git a/server/src/ledgrab/static/js/features/dashboard.ts b/server/src/ledgrab/static/js/features/dashboard.ts index 3e611e8..973ca5c 100644 --- a/server/src/ledgrab/static/js/features/dashboard.ts +++ b/server/src/ledgrab/static/js/features/dashboard.ts @@ -19,6 +19,7 @@ import { cardColorStyle } from '../core/card-colors.ts'; import { renderDeviceIconSvg } from '../core/device-icons.ts'; import { createFpsSparkline } from '../core/chart-utils.ts'; import { getOrderedSections, isSectionVisible, getSection, subscribeDashboardLayout, getGlobalConfig } from './dashboard-layout.ts'; +import { mountCardModeToggle } from './card-modes.ts'; function _applyGlobalLayoutAttrs(): void { const c = document.getElementById('dashboard-content'); @@ -27,6 +28,33 @@ function _applyGlobalLayoutAttrs(): void { c.dataset.layoutWidth = g.width; c.dataset.layoutAnim = g.animations; } + +/** Card-mode toggle teardown registry. Each render replaces the dashboard + * inner HTML, so any previously-mounted toggle becomes detached. We tear + * down old subscribers before mounting fresh so the module-level listener + * Set in card-modes.ts stays bounded. Keyed by the surface name (matches + * `data-dashboard-mode-slot`). */ +const _dashboardModeTeardowns = new Map void>(); + +function _mountDashboardCardModeToggles(): void { + const container = document.getElementById('dashboard-content'); + if (!container) return; + for (const [, teardown] of _dashboardModeTeardowns) { + try { teardown(); } catch (e) { console.warn('card-mode teardown', e); } + } + _dashboardModeTeardowns.clear(); + const slots = container.querySelectorAll('[data-dashboard-mode-slot]'); + for (const slot of slots) { + const surface = slot.dataset.dashboardModeSlot; + if (!surface) continue; + // Host = nearest [data-section] ancestor (either a .dashboard-section + // or a .dashboard-subsection — both carry the attribute now). + const host = slot.closest('[data-section]') as HTMLElement | null; + if (!host) continue; + const teardown = mountCardModeToggle({ container: slot, surface, host }); + _dashboardModeTeardowns.set(surface, teardown); + } +} import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; @@ -552,7 +580,7 @@ export function toggleDashboardSection(sectionKey: string): void { } } -function _sectionHeader(sectionKey: string, label: string, count: number | string, extraHtml: string = ''): string { +function _sectionHeader(sectionKey: string, label: string, count: number | string, extraHtml: string = '', cardModeSurface: string = ''): string { const collapsed = _getCollapsedSections(); const isCollapsed = !!collapsed[sectionKey]; const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"'; @@ -562,12 +590,19 @@ function _sectionHeader(sectionKey: string, label: string, count: number | strin const countHtml = (count !== '' && count != null) ? `${count}` : ''; + // Optional card-mode toggle slot — mounted post-render in + // _mountDashboardCardModeToggles(). Sections that don't display a + // card grid (e.g. 'perf') pass an empty string and get no slot. + const modeSlot = cardModeSurface + ? `` + : ''; return `
${label} ${countHtml} + ${modeSlot} ${extraHtml}
`; } @@ -824,7 +859,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise _renderMQTTIntegrationCard(c)).join(''); const intGrid = `
${haCards}${mqttCards}
`; sectionFragments['integrations'] = `
- ${_sectionHeader('integrations', t('dashboard.section.integrations'), `${totalIntConnected}/${totalIntSources}`)} + ${_sectionHeader('integrations', t('dashboard.section.integrations'), `${totalIntConnected}/${totalIntSources}`, '', 'dashboard-integrations')} ${_sectionContent('integrations', intGrid)}
`; } @@ -838,7 +873,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise${automationItems}
`; sectionFragments['automations'] = `
- ${_sectionHeader('automations', t('dashboard.section.automations'), automations.length)} + ${_sectionHeader('automations', t('dashboard.section.automations'), automations.length, '', 'dashboard-automations')} ${_sectionContent('automations', automationGrid)}
`; } @@ -848,7 +883,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise - ${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra)} + ${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra, 'dashboard-scenes')} ${_sectionContent('scenes', sceneSec.content)} `; } @@ -859,7 +894,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise renderDashboardSyncClock(c)).join(''); const clockGrid = `
${clockCards}
`; sectionFragments['sync-clocks'] = `
- ${_sectionHeader('sync-clocks', t('dashboard.section.sync_clocks'), syncClocks.length)} + ${_sectionHeader('sync-clocks', t('dashboard.section.sync_clocks'), syncClocks.length, '', 'dashboard-sync-clocks')} ${_sectionContent('sync-clocks', clockGrid)}
`; } @@ -872,8 +907,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise${ICON_STOP} ${t('dashboard.stop_all')}`; const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap, cssSourceMap)).join(''); - targetsInner += `
- ${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn)} + targetsInner += `
+ ${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn, 'dashboard-running')} ${_sectionContent('running', runningItems)}
`; } @@ -881,8 +916,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise 0) { const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap, cssSourceMap)).join(''); - targetsInner += `
- ${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length)} + targetsInner += `
+ ${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length, '', 'dashboard-stopped')} ${_sectionContent('stopped', stoppedItems)}
`; } @@ -944,6 +979,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise `${c.id}:${c.is_running}`).sort().join(','); _cacheUptimeElements(); diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 6a631bb..e354ddc 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -969,6 +969,11 @@ "dashboard.customize.density.comfortable": "Comfortable", "dashboard.customize.density.compact": "Compact", "dashboard.customize.density.dense": "Dense", + "card_mode.tooltip": "Card size", + "card_mode.comfortable": "Comfortable", + "card_mode.compact": "Compact", + "card_mode.dense": "Dense", + "card_mode.row": "List", "dashboard.customize.collapse_default.on": "Start collapsed", "dashboard.customize.collapse_default.off": "Start expanded", "dashboard.customize.show": "Show", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index f6be444..26f6fda 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -964,6 +964,11 @@ "dashboard.customize.density.comfortable": "Просторно", "dashboard.customize.density.compact": "Компактно", "dashboard.customize.density.dense": "Плотно", + "card_mode.tooltip": "Размер карточек", + "card_mode.comfortable": "Просторно", + "card_mode.compact": "Компактно", + "card_mode.dense": "Плотно", + "card_mode.row": "Список", "dashboard.customize.collapse_default.on": "Свёрнуто по умолчанию", "dashboard.customize.collapse_default.off": "Развёрнуто по умолчанию", "dashboard.customize.show": "Показать", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index f289303..c5da76c 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -964,6 +964,11 @@ "dashboard.customize.density.comfortable": "宽松", "dashboard.customize.density.compact": "紧凑", "dashboard.customize.density.dense": "密集", + "card_mode.tooltip": "卡片大小", + "card_mode.comfortable": "宽松", + "card_mode.compact": "紧凑", + "card_mode.dense": "密集", + "card_mode.row": "列表", "dashboard.customize.collapse_default.on": "默认折叠", "dashboard.customize.collapse_default.off": "默认展开", "dashboard.customize.show": "显示", diff --git a/server/tests/test_preferences_card_modes_api.py b/server/tests/test_preferences_card_modes_api.py new file mode 100644 index 0000000..40d7798 --- /dev/null +++ b/server/tests/test_preferences_card_modes_api.py @@ -0,0 +1,119 @@ +"""Tests for /api/v1/preferences/card-modes endpoints.""" + +import pytest + +from ledgrab.config import get_config + + +@pytest.fixture(scope="module") +def client(): + """TestClient with auth header read at fixture-build time. + + Mirrors test_preferences_api.py — the auth key is resolved here so + any singleton mutation during pytest collection cannot leave us with + a stale Bearer header. + """ + from fastapi.testclient import TestClient + + from ledgrab.main import app + + api_key = next(iter(get_config().auth.api_keys.values()), "") + with TestClient(app, raise_server_exceptions=False) as c: + if api_key: + c.headers["Authorization"] = f"Bearer {api_key}" + yield c + + +def _prefs(surfaces: dict[str, str] | None = None) -> dict: + return {"version": 1, "surfaces": surfaces or {}} + + +def test_get_default_empty(client): + """When nothing is saved, GET returns {}.""" + client.delete("/api/v1/preferences/card-modes") + resp = client.get("/api/v1/preferences/card-modes") + assert resp.status_code == 200 + assert resp.json() == {} + + +def test_put_then_get_round_trip(client): + """PUT a prefs object, GET it back verbatim.""" + body = _prefs({"led-devices": "dense", "led-targets": "comfortable"}) + put = client.put("/api/v1/preferences/card-modes", json=body) + assert put.status_code == 200 + assert put.json() == {"ok": True} + + got = client.get("/api/v1/preferences/card-modes") + assert got.status_code == 200 + assert got.json() == body + + +def test_put_rejects_missing_version(client): + """Body without numeric version is rejected with 422.""" + resp = client.put( + "/api/v1/preferences/card-modes", + json={"surfaces": {"led-devices": "dense"}}, + ) + assert resp.status_code == 422 + + +def test_put_rejects_invalid_mode(client): + """A mode value outside the allowed set is rejected with 422.""" + resp = client.put( + "/api/v1/preferences/card-modes", + json=_prefs({"led-devices": "extreme"}), + ) + assert resp.status_code == 422 + + +def test_put_rejects_non_dict_surfaces(client): + """`surfaces` must be an object, not an array or string.""" + resp = client.put( + "/api/v1/preferences/card-modes", + json={"version": 1, "surfaces": ["led-devices", "dense"]}, + ) + assert resp.status_code == 422 + + +def test_put_accepts_empty_surfaces(client): + """An empty surfaces map is a valid (no-override) state.""" + resp = client.put("/api/v1/preferences/card-modes", json=_prefs({})) + assert resp.status_code == 200 + + +def test_put_accepts_unknown_surface_keys(client): + """Surface keys are an open registry — any non-empty string is OK.""" + body = _prefs( + { + "led-devices": "compact", + "automations": "dense", + "weather-sources": "comfortable", + "future-surface-v2": "compact", + } + ) + resp = client.put("/api/v1/preferences/card-modes", json=body) + assert resp.status_code == 200 + got = client.get("/api/v1/preferences/card-modes").json() + assert got["surfaces"]["future-surface-v2"] == "compact" + + +def test_put_accepts_row_mode(client): + """`row` is a valid mode (added alongside the original three).""" + body = _prefs({"led-devices": "row"}) + resp = client.put("/api/v1/preferences/card-modes", json=body) + assert resp.status_code == 200 + got = client.get("/api/v1/preferences/card-modes").json() + assert got["surfaces"]["led-devices"] == "row" + + +def test_delete_clears(client): + """DELETE wipes saved prefs so the next GET returns empty.""" + client.put( + "/api/v1/preferences/card-modes", + json=_prefs({"led-devices": "dense"}), + ) + deleted = client.delete("/api/v1/preferences/card-modes") + assert deleted.status_code == 200 + after = client.get("/api/v1/preferences/card-modes") + assert after.status_code == 200 + assert after.json() == {}