feat(ui): per-surface card presentation modes (C/M/D/R)
Adds a comfortable/compact/dense/row toggle to every card grid in the
app. Each surface (LED devices, targets, automations, scenes, sources,
streams, dashboard subsections, etc.) remembers its mode independently.
Persistence mirrors dashboard-layout: localStorage cache for first paint,
debounced PUT to /api/v1/preferences/card-modes (new endpoint) for
cross-browser sync. Surface registry is open — any non-empty key
accepted server-side; modes validated against {comfortable, compact,
dense, row}.
CSS is token-driven: grid min-width and gap come from --card-grid-min /
--card-grid-gap / --card-grid-min-narrow / --card-grid-gap-narrow /
--templates-grid-min / --templates-grid-gap defined on :root, overridden
per [data-card-mode]. Dense/row also hide .mod-leds, collapse secondary
button labels, and tighten .mod-metrics; row collapses the grid to one
full-width column. Coexists with the existing per-section [data-density]
on the dashboard tab — different attribute, additive concern.
Toggle UI auto-mounts into every CardSection header (18+ surfaces) plus
the six dashboard subsections via post-render mount; teardown tracking
keeps the listener Set bounded across re-renders.
i18n: card_mode.{tooltip,comfortable,compact,dense,row} in en/ru/zh.
Tests: 9 new cases in tests/test_preferences_card_modes_api.py covering
defaults, round-trip, validation, open-registry keys, row mode, delete.
This commit is contained in:
@@ -37,6 +37,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
|
_DASHBOARD_LAYOUT_KEY = "dashboard_layout"
|
||||||
_NOTIFICATION_PREFS_KEY = "notification_preferences"
|
_NOTIFICATION_PREFS_KEY = "notification_preferences"
|
||||||
|
_CARD_MODES_KEY = "card_modes"
|
||||||
|
|
||||||
|
|
||||||
class DaylightTimezonePreference(BaseModel):
|
class DaylightTimezonePreference(BaseModel):
|
||||||
@@ -163,6 +164,90 @@ async def put_notification_preferences(
|
|||||||
return body
|
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": {"<surface>": "<mode>", …}}``.
|
||||||
|
|
||||||
|
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)
|
# Daylight timezone (global)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
@import './advanced-calibration.css';
|
@import './advanced-calibration.css';
|
||||||
@import './dashboard.css';
|
@import './dashboard.css';
|
||||||
@import './dashboard-customize.css';
|
@import './dashboard-customize.css';
|
||||||
|
@import './card-modes.css';
|
||||||
@import './streams.css';
|
@import './streams.css';
|
||||||
@import './patterns.css';
|
@import './patterns.css';
|
||||||
@import './automations.css';
|
@import './automations.css';
|
||||||
|
|||||||
@@ -43,6 +43,28 @@
|
|||||||
--space-lg: 20px;
|
--space-lg: 20px;
|
||||||
--space-xl: 40px;
|
--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 */
|
/* Border radius */
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--radius-sm: 4px;
|
--radius-sm: 4px;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <section data-card-mode="dense">
|
||||||
|
* <div class="devices-grid">…</div>
|
||||||
|
* </section>
|
||||||
|
*
|
||||||
|
* <div class="dashboard-section" data-density="dense" data-card-mode="dense">
|
||||||
|
* …
|
||||||
|
* </div>
|
||||||
|
*
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -89,8 +89,8 @@ section {
|
|||||||
.displays-grid,
|
.displays-grid,
|
||||||
.devices-grid {
|
.devices-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(min(var(--card-grid-min, 380px), 100%), 1fr));
|
||||||
gap: 14px;
|
gap: var(--card-grid-gap, 14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.devices-grid > .loading,
|
.devices-grid > .loading,
|
||||||
|
|||||||
@@ -71,8 +71,8 @@
|
|||||||
|
|
||||||
.dashboard-subsection .dashboard-section-content {
|
.dashboard-subsection .dashboard-section-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(min(var(--card-grid-min, 380px), 100%), 1fr));
|
||||||
gap: 14px;
|
gap: var(--card-grid-gap, 14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-subsection .dashboard-section-content .dashboard-target {
|
.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-integrations-grid,
|
||||||
.dashboard-autostart-grid {
|
.dashboard-autostart-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(min(var(--card-grid-min-narrow, 320px), 100%), 1fr));
|
||||||
gap: 12px;
|
gap: var(--card-grid-gap-narrow, 12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Legacy row-style overrides kept for any card that still lacks .mod-head */
|
/* Legacy row-style overrides kept for any card that still lacks .mod-head */
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
.templates-grid {
|
.templates-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(var(--templates-grid-min, 350px), 1fr));
|
||||||
gap: 20px;
|
gap: var(--templates-grid-gap, 20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-card {
|
.template-card {
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
hydrateDashboardLayoutFromCache, syncDashboardLayoutFromServer,
|
hydrateDashboardLayoutFromCache, syncDashboardLayoutFromServer,
|
||||||
} from './features/dashboard-layout.ts';
|
} from './features/dashboard-layout.ts';
|
||||||
|
import {
|
||||||
|
hydrateCardModesFromCache, syncCardModesFromServer,
|
||||||
|
} from './features/card-modes.ts';
|
||||||
import {
|
import {
|
||||||
openDashboardCustomize, closeDashboardCustomize,
|
openDashboardCustomize, closeDashboardCustomize,
|
||||||
} from './features/dashboard-customize.ts';
|
} from './features/dashboard-customize.ts';
|
||||||
@@ -728,6 +731,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// already reflects the user's saved customizations (no flash of
|
// already reflects the user's saved customizations (no flash of
|
||||||
// default-then-custom). Server sync runs after auth.
|
// default-then-custom). Server sync runs after auth.
|
||||||
hydrateDashboardLayoutFromCache();
|
hydrateDashboardLayoutFromCache();
|
||||||
|
hydrateCardModesFromCache();
|
||||||
|
|
||||||
// Initialize locale (dispatches languageChanged which may trigger API calls)
|
// Initialize locale (dispatches languageChanged which may trigger API calls)
|
||||||
await initLocale();
|
await initLocale();
|
||||||
@@ -828,6 +832,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// across browsers). Fire-and-forget — the cached layout is already
|
// across browsers). Fire-and-forget — the cached layout is already
|
||||||
// active; this overwrites it if the server has a newer copy.
|
// active; this overwrites it if the server has a newer copy.
|
||||||
syncDashboardLayoutFromServer();
|
syncDashboardLayoutFromServer();
|
||||||
|
syncCardModesFromServer();
|
||||||
|
|
||||||
// Trigger the active tab's loader — initTabs() ran before authRequired
|
// Trigger the active tab's loader — initTabs() ran before authRequired
|
||||||
// was known, so its conditional loader call may have been skipped.
|
// was known, so its conditional loader call may have been skipped.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
import { t } from './i18n.ts';
|
import { t } from './i18n.ts';
|
||||||
import { showBulkToolbar, hideBulkToolbar, updateBulkToolbar } from './bulk-toolbar.ts';
|
import { showBulkToolbar, hideBulkToolbar, updateBulkToolbar } from './bulk-toolbar.ts';
|
||||||
import { ICON_LIST_CHECKS, ICON_EYE, ICON_EYE_OFF, ICON_CHECK } from './icons.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 {
|
export interface BulkAction {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -144,6 +145,7 @@ export class CardSection {
|
|||||||
_pendingReconcile: CardItem[] | null;
|
_pendingReconcile: CardItem[] | null;
|
||||||
_animated: boolean;
|
_animated: boolean;
|
||||||
_showHidden: boolean;
|
_showHidden: boolean;
|
||||||
|
_cardModeUnsubscribe: (() => void) | null;
|
||||||
|
|
||||||
constructor(sectionKey: string, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible, emptyKey, bulkActions }: CardSectionOpts) {
|
constructor(sectionKey: string, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible, emptyKey, bulkActions }: CardSectionOpts) {
|
||||||
this.sectionKey = sectionKey;
|
this.sectionKey = sectionKey;
|
||||||
@@ -167,6 +169,7 @@ export class CardSection {
|
|||||||
this._pendingReconcile = null;
|
this._pendingReconcile = null;
|
||||||
this._animated = false;
|
this._animated = false;
|
||||||
this._showHidden = false;
|
this._showHidden = false;
|
||||||
|
this._cardModeUnsubscribe = null;
|
||||||
_sectionRegistry.set(sectionKey, this);
|
_sectionRegistry.set(sectionKey, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +217,7 @@ export class CardSection {
|
|||||||
${this.headerExtra ? `<span class="cs-header-extra">${this.headerExtra}</span>` : ''}
|
${this.headerExtra ? `<span class="cs-header-extra">${this.headerExtra}</span>` : ''}
|
||||||
${hiddenToggle}
|
${hiddenToggle}
|
||||||
${this.bulkActions ? `<button type="button" class="cs-bulk-toggle" data-cs-bulk="${this.sectionKey}" title="${t('bulk.select')}">${ICON_LIST_CHECKS}</button>` : ''}
|
${this.bulkActions ? `<button type="button" class="cs-bulk-toggle" data-cs-bulk="${this.sectionKey}" title="${t('bulk.select')}">${ICON_LIST_CHECKS}</button>` : ''}
|
||||||
|
<span class="cs-mode-slot" data-cs-mode-slot="${this.sectionKey}"></span>
|
||||||
<div class="cs-filter-wrap">
|
<div class="cs-filter-wrap">
|
||||||
<input type="text" class="cs-filter" data-cs-filter="${this.sectionKey}"
|
<input type="text" class="cs-filter" data-cs-filter="${this.sectionKey}"
|
||||||
data-i18n-placeholder="section.filter.placeholder" placeholder="${t('section.filter.placeholder')}" autocomplete="off">
|
data-i18n-placeholder="section.filter.placeholder" placeholder="${t('section.filter.placeholder')}" autocomplete="off">
|
||||||
@@ -236,11 +240,30 @@ export class CardSection {
|
|||||||
|
|
||||||
if (this.collapsible) {
|
if (this.collapsible) {
|
||||||
header.addEventListener('mousedown', (e) => {
|
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);
|
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) {
|
if (filterInput) {
|
||||||
const resetBtn = document.querySelector(`[data-cs-filter-reset="${this.sectionKey}"]`) as HTMLElement | null;
|
const resetBtn = document.querySelector(`[data-cs-filter-reset="${this.sectionKey}"]`) as HTMLElement | null;
|
||||||
const updateResetVisibility = () => {
|
const updateResetVisibility = () => {
|
||||||
|
|||||||
@@ -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<string, CardMode>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PREFS: CardModePrefsV1 = {
|
||||||
|
version: SCHEMA_VERSION,
|
||||||
|
surfaces: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
let _current: CardModePrefsV1 = _clone(DEFAULT_PREFS);
|
||||||
|
let _serverSyncedOnce = false;
|
||||||
|
let _saveTimer: ReturnType<typeof setTimeout> | 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<string, unknown>;
|
||||||
|
const surfaces = obj.surfaces;
|
||||||
|
if (surfaces && typeof surfaces === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(surfaces as Record<string, unknown>)) {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, unknown>).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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { cardColorStyle } from '../core/card-colors.ts';
|
|||||||
import { renderDeviceIconSvg } from '../core/device-icons.ts';
|
import { renderDeviceIconSvg } from '../core/device-icons.ts';
|
||||||
import { createFpsSparkline } from '../core/chart-utils.ts';
|
import { createFpsSparkline } from '../core/chart-utils.ts';
|
||||||
import { getOrderedSections, isSectionVisible, getSection, subscribeDashboardLayout, getGlobalConfig } from './dashboard-layout.ts';
|
import { getOrderedSections, isSectionVisible, getSection, subscribeDashboardLayout, getGlobalConfig } from './dashboard-layout.ts';
|
||||||
|
import { mountCardModeToggle } from './card-modes.ts';
|
||||||
|
|
||||||
function _applyGlobalLayoutAttrs(): void {
|
function _applyGlobalLayoutAttrs(): void {
|
||||||
const c = document.getElementById('dashboard-content');
|
const c = document.getElementById('dashboard-content');
|
||||||
@@ -27,6 +28,33 @@ function _applyGlobalLayoutAttrs(): void {
|
|||||||
c.dataset.layoutWidth = g.width;
|
c.dataset.layoutWidth = g.width;
|
||||||
c.dataset.layoutAnim = g.animations;
|
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<string, () => 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<HTMLElement>('[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';
|
import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts';
|
||||||
|
|
||||||
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
|
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 collapsed = _getCollapsedSections();
|
||||||
const isCollapsed = !!collapsed[sectionKey];
|
const isCollapsed = !!collapsed[sectionKey];
|
||||||
const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"';
|
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)
|
const countHtml = (count !== '' && count != null)
|
||||||
? `<span class="dashboard-section-count">${count}</span>`
|
? `<span class="dashboard-section-count">${count}</span>`
|
||||||
: '';
|
: '';
|
||||||
|
// 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
|
||||||
|
? `<span class="dashboard-mode-slot" data-dashboard-mode-slot="${cardModeSurface}"></span>`
|
||||||
|
: '';
|
||||||
return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}">
|
return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}">
|
||||||
<span class="dashboard-section-toggle" onclick="toggleDashboardSection('${sectionKey}')">
|
<span class="dashboard-section-toggle" onclick="toggleDashboardSection('${sectionKey}')">
|
||||||
<span class="dashboard-section-chevron"${chevronStyle}>▶</span>
|
<span class="dashboard-section-chevron"${chevronStyle}>▶</span>
|
||||||
${label}
|
${label}
|
||||||
${countHtml}
|
${countHtml}
|
||||||
</span>
|
</span>
|
||||||
|
${modeSlot}
|
||||||
${extraHtml}
|
${extraHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -824,7 +859,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
const mqttCards = mqttStatus.connections.map(c => _renderMQTTIntegrationCard(c)).join('');
|
const mqttCards = mqttStatus.connections.map(c => _renderMQTTIntegrationCard(c)).join('');
|
||||||
const intGrid = `<div class="dashboard-integrations-grid">${haCards}${mqttCards}</div>`;
|
const intGrid = `<div class="dashboard-integrations-grid">${haCards}${mqttCards}</div>`;
|
||||||
sectionFragments['integrations'] = `<div class="dashboard-section" data-section="integrations">
|
sectionFragments['integrations'] = `<div class="dashboard-section" data-section="integrations">
|
||||||
${_sectionHeader('integrations', t('dashboard.section.integrations'), `${totalIntConnected}/${totalIntSources}`)}
|
${_sectionHeader('integrations', t('dashboard.section.integrations'), `${totalIntConnected}/${totalIntSources}`, '', 'dashboard-integrations')}
|
||||||
${_sectionContent('integrations', intGrid)}
|
${_sectionContent('integrations', intGrid)}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -838,7 +873,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
const automationGrid = `<div class="dashboard-autostart-grid">${automationItems}</div>`;
|
const automationGrid = `<div class="dashboard-autostart-grid">${automationItems}</div>`;
|
||||||
|
|
||||||
sectionFragments['automations'] = `<div class="dashboard-section" data-section="automations">
|
sectionFragments['automations'] = `<div class="dashboard-section" data-section="automations">
|
||||||
${_sectionHeader('automations', t('dashboard.section.automations'), automations.length)}
|
${_sectionHeader('automations', t('dashboard.section.automations'), automations.length, '', 'dashboard-automations')}
|
||||||
${_sectionContent('automations', automationGrid)}
|
${_sectionContent('automations', automationGrid)}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -848,7 +883,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
const sceneSec = renderScenePresetsSection(scenePresets);
|
const sceneSec = renderScenePresetsSection(scenePresets);
|
||||||
if (sceneSec && typeof sceneSec === 'object') {
|
if (sceneSec && typeof sceneSec === 'object') {
|
||||||
sectionFragments['scenes'] = `<div class="dashboard-section" data-section="scenes">
|
sectionFragments['scenes'] = `<div class="dashboard-section" data-section="scenes">
|
||||||
${_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)}
|
${_sectionContent('scenes', sceneSec.content)}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -859,7 +894,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join('');
|
const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join('');
|
||||||
const clockGrid = `<div class="dashboard-autostart-grid">${clockCards}</div>`;
|
const clockGrid = `<div class="dashboard-autostart-grid">${clockCards}</div>`;
|
||||||
sectionFragments['sync-clocks'] = `<div class="dashboard-section" data-section="sync-clocks">
|
sectionFragments['sync-clocks'] = `<div class="dashboard-section" data-section="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)}
|
${_sectionContent('sync-clocks', clockGrid)}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -872,8 +907,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
const stopAllBtn = `<button class="btn btn-sm btn-primary dashboard-stop-all" onclick="event.stopPropagation(); dashboardStopAll()" title="${t('dashboard.stop_all')}">${ICON_STOP} ${t('dashboard.stop_all')}</button>`;
|
const stopAllBtn = `<button class="btn btn-sm btn-primary dashboard-stop-all" onclick="event.stopPropagation(); dashboardStopAll()" title="${t('dashboard.stop_all')}">${ICON_STOP} ${t('dashboard.stop_all')}</button>`;
|
||||||
const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap, cssSourceMap)).join('');
|
const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap, cssSourceMap)).join('');
|
||||||
|
|
||||||
targetsInner += `<div class="dashboard-subsection">
|
targetsInner += `<div class="dashboard-subsection" data-section="running">
|
||||||
${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn)}
|
${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn, 'dashboard-running')}
|
||||||
${_sectionContent('running', runningItems)}
|
${_sectionContent('running', runningItems)}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -881,8 +916,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
if (stopped.length > 0) {
|
if (stopped.length > 0) {
|
||||||
const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap, cssSourceMap)).join('');
|
const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap, cssSourceMap)).join('');
|
||||||
|
|
||||||
targetsInner += `<div class="dashboard-subsection">
|
targetsInner += `<div class="dashboard-subsection" data-section="stopped">
|
||||||
${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length)}
|
${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length, '', 'dashboard-stopped')}
|
||||||
${_sectionContent('stopped', stoppedItems)}
|
${_sectionContent('stopped', stoppedItems)}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -944,6 +979,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
const el = container.querySelector(`.dashboard-section[data-section="${CSS.escape(s.key)}"]`) as HTMLElement | null;
|
const el = container.querySelector(`.dashboard-section[data-section="${CSS.escape(s.key)}"]`) as HTMLElement | null;
|
||||||
if (el) el.dataset.density = s.density;
|
if (el) el.dataset.density = s.density;
|
||||||
}
|
}
|
||||||
|
_mountDashboardCardModeToggles();
|
||||||
_lastRunningIds = runningIds;
|
_lastRunningIds = runningIds;
|
||||||
_lastSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
|
_lastSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
|
||||||
_cacheUptimeElements();
|
_cacheUptimeElements();
|
||||||
|
|||||||
@@ -969,6 +969,11 @@
|
|||||||
"dashboard.customize.density.comfortable": "Comfortable",
|
"dashboard.customize.density.comfortable": "Comfortable",
|
||||||
"dashboard.customize.density.compact": "Compact",
|
"dashboard.customize.density.compact": "Compact",
|
||||||
"dashboard.customize.density.dense": "Dense",
|
"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.on": "Start collapsed",
|
||||||
"dashboard.customize.collapse_default.off": "Start expanded",
|
"dashboard.customize.collapse_default.off": "Start expanded",
|
||||||
"dashboard.customize.show": "Show",
|
"dashboard.customize.show": "Show",
|
||||||
|
|||||||
@@ -964,6 +964,11 @@
|
|||||||
"dashboard.customize.density.comfortable": "Просторно",
|
"dashboard.customize.density.comfortable": "Просторно",
|
||||||
"dashboard.customize.density.compact": "Компактно",
|
"dashboard.customize.density.compact": "Компактно",
|
||||||
"dashboard.customize.density.dense": "Плотно",
|
"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.on": "Свёрнуто по умолчанию",
|
||||||
"dashboard.customize.collapse_default.off": "Развёрнуто по умолчанию",
|
"dashboard.customize.collapse_default.off": "Развёрнуто по умолчанию",
|
||||||
"dashboard.customize.show": "Показать",
|
"dashboard.customize.show": "Показать",
|
||||||
|
|||||||
@@ -964,6 +964,11 @@
|
|||||||
"dashboard.customize.density.comfortable": "宽松",
|
"dashboard.customize.density.comfortable": "宽松",
|
||||||
"dashboard.customize.density.compact": "紧凑",
|
"dashboard.customize.density.compact": "紧凑",
|
||||||
"dashboard.customize.density.dense": "密集",
|
"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.on": "默认折叠",
|
||||||
"dashboard.customize.collapse_default.off": "默认展开",
|
"dashboard.customize.collapse_default.off": "默认展开",
|
||||||
"dashboard.customize.show": "显示",
|
"dashboard.customize.show": "显示",
|
||||||
|
|||||||
@@ -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() == {}
|
||||||
Reference in New Issue
Block a user