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:
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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": "系统性能",
|
||||||
|
|||||||
Reference in New Issue
Block a user