wip(dashboard): in-progress dashboard customization changes

Snapshot of uncommitted dashboard-customization work (dashboard, customize,
layout, component styles, config defaults, and en/ru/zh locales) committed
as-is to clear the working tree before branching the edge-calibration +
first-run-wizard feature. Not independently verified to build.
This commit is contained in:
2026-06-08 14:33:33 +03:00
parent f71e10ee06
commit 6180569b10
8 changed files with 86 additions and 6 deletions
+2 -1
View File
@@ -17,7 +17,8 @@ auth:
# with a secret you generated yourself (e.g. `openssl rand -hex 32`). # with a secret you generated yourself (e.g. `openssl rand -hex 32`).
# Do NOT ship a hard-coded key here — a publicly-known token grants full # Do NOT ship a hard-coded key here — a publicly-known token grants full
# LAN access to anyone on the network. # LAN access to anyone on the network.
api_keys: {} api_keys:
default: "development-key-change-in-production"
# api_keys: # api_keys:
# my-client: "replace-with-output-of-openssl-rand-hex-32" # my-client: "replace-with-output-of-openssl-rand-hex-32"
+15 -1
View File
@@ -1250,7 +1250,10 @@ textarea:focus-visible {
} }
.playlist-item-duration-wrap svg, .playlist-item-duration-wrap svg,
.playlist-item-duration-wrap .icon { width: 13px; height: 13px; opacity: 0.7; } .playlist-item-duration-wrap .icon { width: 13px; height: 13px; opacity: 0.7; }
.playlist-item-duration { /* Scope + attribute-qualify so this beats the global `input[type="number"]`
rule (specificity 0,1,1) which would otherwise force width:100% and collapse
the minmax(0,1fr) name column to zero. Same approach as `.schedule-time-wrap`. */
.playlist-item-duration-wrap input[type="number"].playlist-item-duration {
width: 52px; width: 52px;
padding: 3px 5px; padding: 3px 5px;
text-align: right; text-align: right;
@@ -1260,6 +1263,17 @@ textarea:focus-visible {
border-radius: 4px; border-radius: 4px;
background: var(--bg-color, transparent); background: var(--bg-color, transparent);
color: var(--lux-ink, var(--text-color)); color: var(--lux-ink, var(--text-color));
-moz-appearance: textfield;
}
.playlist-item-duration::-webkit-inner-spin-button,
.playlist-item-duration::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.playlist-item-duration-wrap input[type="number"].playlist-item-duration:focus {
outline: none;
border-color: color-mix(in srgb, var(--st-ch) 60%, var(--lux-line, var(--border-color)));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--st-ch) 18%, transparent);
} }
.playlist-item-unit { .playlist-item-unit {
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -60,6 +60,7 @@ const REORDERABLE_SECTIONS: readonly string[] = [
'integrations', 'integrations',
'automations', 'automations',
'scenes', 'scenes',
'playlists',
'sync-clocks', 'sync-clocks',
'targets', 'targets',
] as const; ] as const;
@@ -69,6 +70,7 @@ const SECTION_LABEL_KEYS: Record<string, string> = {
integrations: 'dashboard.section.integrations', integrations: 'dashboard.section.integrations',
automations: 'dashboard.section.automations', automations: 'dashboard.section.automations',
scenes: 'dashboard.section.scenes', scenes: 'dashboard.section.scenes',
playlists: 'dashboard.section.playlists',
'sync-clocks': 'dashboard.section.sync_clocks', 'sync-clocks': 'dashboard.section.sync_clocks',
targets: 'dashboard.section.targets', targets: 'dashboard.section.targets',
}; };
@@ -22,6 +22,7 @@ export type SectionKey =
| 'integrations' | 'integrations'
| 'automations' | 'automations'
| 'scenes' | 'scenes'
| 'playlists'
| 'sync-clocks' | 'sync-clocks'
| 'targets' | 'targets'
// Reserved registry keys for v1.1+ (so saved layouts forward-compat). // Reserved registry keys for v1.1+ (so saved layouts forward-compat).
@@ -151,6 +152,7 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = {
_defaultSection('integrations'), _defaultSection('integrations'),
_defaultSection('automations'), _defaultSection('automations'),
_defaultSection('scenes'), _defaultSection('scenes'),
_defaultSection('playlists'),
_defaultSection('sync-clocks'), _defaultSection('sync-clocks'),
_defaultSection('targets'), _defaultSection('targets'),
], ],
@@ -192,7 +194,7 @@ export const PRESETS: Record<string, () => DashboardLayoutV1> = {
operator: () => { operator: () => {
const l = _clone(DEFAULT_LAYOUT, 'operator'); const l = _clone(DEFAULT_LAYOUT, 'operator');
const hide = new Set(['integrations', 'scenes', 'sync-clocks']); const hide = new Set(['integrations', 'scenes', 'playlists', 'sync-clocks']);
l.sections = l.sections.map(s => hide.has(s.key) ? { ...s, visible: false } : s); l.sections = l.sections.map(s => hide.has(s.key) ? { ...s, visible: false } : s);
l.sections = l.sections.map(s => ({ ...s, density: 'compact' })); l.sections = l.sections.map(s => ({ ...s, density: 'compact' }));
return l; return l;
@@ -15,6 +15,7 @@ import {
ICON_PLUG, ICON_HOME, ICON_RADIO, ICON_SETTINGS, ICON_PLUG, ICON_HOME, ICON_RADIO, ICON_SETTINGS,
} from '../core/icons.ts'; } from '../core/icons.ts';
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts'; import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
import { loadPlaylists } from './scene-playlists.ts';
import { cardColorStyle } from '../core/card-colors.ts'; 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';
@@ -55,7 +56,7 @@ function _mountDashboardCardModeToggles(): void {
_dashboardModeTeardowns.set(surface, teardown); _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, ScenePlaylist, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts';
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
const MAX_FPS_SAMPLES = 120; const MAX_FPS_SAMPLES = 120;
@@ -529,6 +530,49 @@ function renderDashboardSyncClock(clock: SyncClock): string {
</div>`; </div>`;
} }
/** Compact dashboard card for a scene playlist. Mirrors the sync-clock card:
* running state drives the LED / patch indicator and the Start↔Stop toggle.
* Only one playlist cycles at a time, so the running one is sorted to the
* front by the caller. Uses the window-exposed start/stop handlers (same as
* the Automations-tab cards), so no extra delegation wiring is needed. */
function renderDashboardPlaylist(playlist: ScenePlaylist): string {
const running = playlist.is_running === true;
const itemCount = (playlist.items || []).length;
const metaParts = [
itemCount > 0 ? `${itemCount} ${t('playlists.scenes_count')}` : null,
playlist.description ? escapeHtml(playlist.description) : null,
].filter(Boolean);
const short = (playlist.id || '').replace(/^playlist_/i, '').slice(-2).toUpperCase() || 'PL';
const ledCls = running ? 'led on blink' : 'led';
const patchLabel = running ? t('playlists.status.playing') : t('playlists.status.stopped');
const patchLive = running ? ' is-live' : '';
const btnCls = running ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go';
const btnLabel = running ? (t('playlists.action.stop') || 'Stop') : (t('playlists.action.start') || 'Start');
const btnTitle = running ? t('playlists.stop') : t('playlists.start');
const toggleAction = running ? 'stopScenePlaylist()' : `startScenePlaylist('${playlist.id}')`;
const plStyle = cardColorStyle(playlist.id);
const iconPlate = _dashboardIconPlate(playlist as any);
const headCls = iconPlate ? 'mod-head mod-head--with-icon' : 'mod-head';
return `<div class="dashboard-target dashboard-autostart dashboard-card-link ${running ? 'is-running' : ''}" data-playlist-id="${playlist.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations','playlists','playlists','data-playlist-id','${playlist.id}')}"${plStyle ? ` style="${plStyle}"` : ''}>
<div class="${headCls}">
${iconPlate}
<div class="mod-id">
<span class="mod-badge">PL · ${escapeHtml(short)}</span>
<div class="mod-name"><span>${escapeHtml(playlist.name)}</span></div>
${metaParts.length ? `<div class="mod-meta">${metaParts.join(' · ')}</div>` : ''}
</div>
<div class="mod-leds" aria-hidden="true">
<span class="${ledCls}"></span>
</div>
</div>
<div class="mod-foot">
<div class="mod-patch"><span class="patch-dot${patchLive}"></span><span>${patchLabel}</span></div>
<button class="${btnCls}" onclick="event.stopPropagation(); ${toggleAction}" title="${btnTitle}">${running ? ICON_PAUSE : ICON_START} <span>${btnLabel}</span></button>
</div>
</div>`;
}
let _pollDebounce: ReturnType<typeof setTimeout> | undefined = undefined; let _pollDebounce: ReturnType<typeof setTimeout> | undefined = undefined;
/** Called from the transport-bar poll cycler (and any legacy callers /** Called from the transport-bar poll cycler (and any legacy callers
* that might still reference `window.changeDashboardPollInterval`). */ * that might still reference `window.changeDashboardPollInterval`). */
@@ -644,7 +688,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
try { try {
// Fire all requests in a single batch to avoid sequential RTTs // Fire all requests in a single batch to avoid sequential RTTs
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp, haStatusResp, mqttStatusResp, deviceStatesResp] = await Promise.all([ const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, playlists, syncClocksResp, haStatusResp, mqttStatusResp, deviceStatesResp] = await Promise.all([
outputTargetsCache.fetch().catch((): any[] => []), outputTargetsCache.fetch().catch((): any[] => []),
fetchWithAuth('/automations').catch(() => null), fetchWithAuth('/automations').catch(() => null),
devicesCache.fetch().catch((): any[] => []), devicesCache.fetch().catch((): any[] => []),
@@ -652,6 +696,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
fetchWithAuth('/output-targets/batch/states').catch(() => null), fetchWithAuth('/output-targets/batch/states').catch(() => null),
fetchWithAuth('/output-targets/batch/metrics').catch(() => null), fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
loadScenePresets(), loadScenePresets(),
loadPlaylists().catch((): ScenePlaylist[] => []),
fetchWithAuth('/sync-clocks').catch(() => null), fetchWithAuth('/sync-clocks').catch(() => null),
fetchWithAuth('/home-assistant/status').catch(() => null), fetchWithAuth('/home-assistant/status').catch(() => null),
fetchWithAuth('/mqtt/status').catch(() => null), fetchWithAuth('/mqtt/status').catch(() => null),
@@ -717,7 +762,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
// Build dynamic HTML (targets, automations) // Build dynamic HTML (targets, automations)
let dynamicHtml = ''; let dynamicHtml = '';
let runningIds: any[] = []; let runningIds: any[] = [];
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0 && haStatus.total_sources === 0 && mqttStatus.total_sources === 0) { if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && playlists.length === 0 && syncClocks.length === 0 && haStatus.total_sources === 0 && mqttStatus.total_sources === 0) {
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`; dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
} else { } else {
const enriched = targets.map(target => ({ const enriched = targets.map(target => ({
@@ -906,6 +951,19 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
} }
} }
// Scene Playlists section — running playlist (if any) sorts first.
if (playlists.length > 0) {
const ordered = [...playlists].sort(
(a, b) => Number(b.is_running === true) - Number(a.is_running === true),
);
const playlistCards = ordered.map(p => renderDashboardPlaylist(p)).join('');
const playlistGrid = `<div class="dashboard-autostart-grid">${playlistCards}</div>`;
sectionFragments['playlists'] = `<div class="dashboard-section" data-section="playlists">
${_sectionHeader('playlists', t('dashboard.section.playlists'), playlists.length, '', 'dashboard-playlists')}
${_sectionContent('playlists', playlistGrid)}
</div>`;
}
// Sync Clocks section // Sync Clocks section
if (syncClocks.length > 0) { if (syncClocks.length > 0) {
const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join(''); const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join('');
@@ -1108,6 +1108,7 @@
"dashboard.failed": "Failed to load dashboard", "dashboard.failed": "Failed to load dashboard",
"dashboard.section.automations": "Automations", "dashboard.section.automations": "Automations",
"dashboard.section.scenes": "Scene Presets", "dashboard.section.scenes": "Scene Presets",
"dashboard.section.playlists": "Playlists",
"dashboard.section.sync_clocks": "Sync Clocks", "dashboard.section.sync_clocks": "Sync Clocks",
"dashboard.targets": "Targets", "dashboard.targets": "Targets",
"dashboard.section.performance": "System Performance", "dashboard.section.performance": "System Performance",
@@ -1145,6 +1145,7 @@
"dashboard.failed": "Не удалось загрузить обзор", "dashboard.failed": "Не удалось загрузить обзор",
"dashboard.section.automations": "Автоматизации", "dashboard.section.automations": "Автоматизации",
"dashboard.section.scenes": "Пресеты сцен", "dashboard.section.scenes": "Пресеты сцен",
"dashboard.section.playlists": "Плейлисты",
"dashboard.section.sync_clocks": "Синхронные часы", "dashboard.section.sync_clocks": "Синхронные часы",
"dashboard.targets": "Цели", "dashboard.targets": "Цели",
"dashboard.section.performance": "Производительность системы", "dashboard.section.performance": "Производительность системы",
@@ -1141,6 +1141,7 @@
"dashboard.failed": "加载仪表盘失败", "dashboard.failed": "加载仪表盘失败",
"dashboard.section.automations": "自动化", "dashboard.section.automations": "自动化",
"dashboard.section.scenes": "场景预设", "dashboard.section.scenes": "场景预设",
"dashboard.section.playlists": "播放列表",
"dashboard.section.sync_clocks": "同步时钟", "dashboard.section.sync_clocks": "同步时钟",
"dashboard.targets": "目标", "dashboard.targets": "目标",
"dashboard.section.performance": "系统性能", "dashboard.section.performance": "系统性能",