Implement Phase 6A visual management dashboard
Build the desktop management surface at / while leaving the verified playback engine and display renderer untouched. Opening the dashboard goes straight to the playlist that is actually active, with slide imagery dominating rather than an admin table. Playlist selection and activation are deliberately separate. Choosing a playlist only changes what is being edited; an inactive playlist offers an explicit Activate Playlist action. Activation remains the server's decision: when playback queues the request to the current slide boundary the header shows STARTING SOON and keeps the old playlist marked active until authoritative state promotes it, so the UI never fakes activation timing. Entries render as large tiles in a responsive grid where position is the sequence, so no ordinal numbers are stamped on slides. Names drop file extensions and are uppercased in Roboto for presentation only; stored display names keep their case. Status rings are green, gray, amber, or red and are always accompanied by the same status as text, so nothing depends on colour alone. Clicking a tile toggles that playlist entry only, never the library asset or its use in other playlists. Tiles reorder by drag through dnd-kit, persisting through the existing playlist API and snapping back to the authoritative order if the server rejects the change. React identity is always the stable PlaylistEntry UUID, never an index. Uploads accept PNG, JPEG, and WebP from the picker or a drop anywhere on the page, run sequentially so appended order matches selection order, and report per-file state without blocking the dashboard. Content duplicates and assets already in the playlist are surfaced rather than duplicated. The Library panel browses and reuses existing assets, and permanent assets are never shown as stale. The live preview reuses the display's own presentation resolver and scene stylesheet, which are extracted to a shared module so the miniature and the signage output cannot drift apart. It takes on none of the kiosk's authority: no worker, no offline rotation, no heartbeat, no identity. Management is desktop-only below 768px, a restriction /display never inherits. Compose now attaches only the web service to the external tabernet network as announcements-web, keeping the API and object storage private. Verified with 200/200 backend, 20 client unit, and 44 Playwright tests. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
/* Desktop management surface. Slide imagery dominates; chrome stays quiet.
|
||||
None of this is applied to /display, which keeps its own scoped styling. */
|
||||
.dashboard {
|
||||
--surface: #0f1419;
|
||||
--panel: #161d25;
|
||||
--line: #232d38;
|
||||
--text: #eef2f6;
|
||||
--muted: #93a1b0;
|
||||
--accent: #3b82f6;
|
||||
--ok: #22c55e;
|
||||
--warn: #f59e0b;
|
||||
--bad: #ef4444;
|
||||
min-height: 100vh;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-family: "Roboto", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0.9rem 1.5rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.header-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.product {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.playlist-choice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.playlist-choice select {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font: inherit;
|
||||
min-width: 14rem;
|
||||
}
|
||||
|
||||
.header-activation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
.badge.active { color: var(--ok); }
|
||||
.badge.pending { color: var(--warn); }
|
||||
.badge.inactive { color: var(--muted); }
|
||||
|
||||
.button {
|
||||
font: inherit;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: 0.5rem 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:hover { border-color: var(--muted); }
|
||||
.button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.button[disabled] { opacity: 0.5; cursor: default; }
|
||||
.button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.button.subtle { background: transparent; color: var(--muted); }
|
||||
|
||||
.dashboard-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 22rem;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.playlist-heading h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.playlist-heading .muted,
|
||||
.playlist-heading .warning {
|
||||
margin: 0.25rem 0 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.warning { color: var(--warn); }
|
||||
|
||||
/* Position in the grid is the sequence; tiles carry no ordinal numbers. */
|
||||
.tile-grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tile[data-dragging="true"] {
|
||||
z-index: 5;
|
||||
filter: drop-shadow(0 1rem 1.5rem rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
|
||||
.tile-surface {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: #000;
|
||||
border-radius: 0.7rem;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16 / 9;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tile-surface:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
|
||||
.tile-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tile[data-enabled="false"] .tile-image { opacity: 0.4; filter: grayscale(0.7); }
|
||||
.tile-image-missing { background: #22303c; }
|
||||
|
||||
/* The ring reads clearly without covering the artwork. */
|
||||
.tile-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 0.7rem;
|
||||
border: 3px solid transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tile-ring[data-ring="playable"] { border-color: var(--ok); }
|
||||
.tile-ring[data-ring="disabled"] { border-color: #6b7785; }
|
||||
.tile-ring[data-ring="scheduled"] { border-color: var(--warn); }
|
||||
.tile-ring[data-ring="error"] { border-color: var(--bad); }
|
||||
|
||||
/* Status is always available as text, never colour alone. */
|
||||
.tile-status {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tile-drag {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0.4rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 0.4rem;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
cursor: grab;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tile-drag:focus-visible { outline: 2px solid var(--accent); }
|
||||
|
||||
.tile-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.03em;
|
||||
/* Presentation only: the stored display name keeps its original case. */
|
||||
text-transform: uppercase;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border: 2px dashed var(--line);
|
||||
border-radius: 1rem;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state h2 { margin: 0 0 0.5rem; font-weight: 500; }
|
||||
.empty-state p { color: var(--muted); margin: 0 0 1.5rem; }
|
||||
.empty-actions { display: flex; gap: 0.75rem; justify-content: center; }
|
||||
|
||||
.skeleton-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.skeleton-tile {
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 0.7rem;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
position: sticky;
|
||||
top: 1.5rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.9rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-frame { background: #000; }
|
||||
.status-rows { padding: 0.9rem 1rem; display: grid; gap: 0.75rem; }
|
||||
|
||||
.status-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.6rem;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
background: var(--bad);
|
||||
}
|
||||
|
||||
.status-dot[data-online="true"] { background: var(--ok); }
|
||||
.status-label { font-weight: 500; font-size: 0.9rem; }
|
||||
.status-detail {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.upload-queue {
|
||||
position: fixed;
|
||||
right: 1.25rem;
|
||||
bottom: 1.25rem;
|
||||
width: 22rem;
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.8rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.upload-queue header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.upload-queue h2 { margin: 0; font-size: 0.85rem; font-weight: 500; }
|
||||
.upload-queue ul { list-style: none; margin: 0.5rem 0 0; padding: 0; display: grid; gap: 0.5rem; }
|
||||
.upload-queue li { display: grid; grid-template-columns: 1fr auto; gap: 0.25rem 0.5rem; font-size: 0.78rem; }
|
||||
.upload-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.upload-state { color: var(--muted); }
|
||||
.upload-state[data-state="complete"] { color: var(--ok); }
|
||||
.upload-state[data-state="failed"] { color: var(--bad); }
|
||||
.upload-state[data-state="duplicate"],
|
||||
.upload-state[data-state="already_in_playlist"] { color: var(--warn); }
|
||||
.upload-message { grid-column: 1 / -1; color: var(--muted); }
|
||||
.upload-bar { grid-column: 1 / -1; height: 3px; background: var(--line); border-radius: 2px; }
|
||||
.upload-bar span { display: block; height: 100%; background: var(--accent); border-radius: 2px; }
|
||||
|
||||
.library-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 30rem;
|
||||
background: var(--panel);
|
||||
border-left: 1px solid var(--line);
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.library-panel header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.library-panel h2 { margin: 0; font-size: 1rem; font-weight: 500; }
|
||||
|
||||
.library-search {
|
||||
font: inherit;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.library-grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.library-grid button {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 0.6rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.library-grid li[data-selected="true"] button { border-color: var(--accent); }
|
||||
.library-grid button[disabled] { opacity: 0.55; cursor: default; }
|
||||
.library-grid img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; display: block; }
|
||||
.library-image-missing { width: 100%; aspect-ratio: 16 / 9; background: #22303c; display: block; }
|
||||
.library-name {
|
||||
font-size: 0.72rem;
|
||||
padding: 0 0.4rem;
|
||||
text-transform: uppercase;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.library-tag { font-size: 0.65rem; color: var(--muted); padding: 0 0.4rem 0.4rem; }
|
||||
.library-tag.warn { color: var(--bad); }
|
||||
.library-tag[data-expired="true"] { color: var(--bad); }
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 1.5rem;
|
||||
transform: translateX(-50%);
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--muted);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.toast[data-tone="error"] { border-left-color: var(--bad); }
|
||||
.toast[data-tone="success"] { border-left-color: var(--ok); }
|
||||
.toast button { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1rem; }
|
||||
|
||||
.drop-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 20, 25, 0.75);
|
||||
border: 3px dashed var(--accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.1rem;
|
||||
z-index: 40;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mobile-splash {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background: #0f1419;
|
||||
color: #eef2f6;
|
||||
font-family: "Roboto", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.mobile-splash h1 { margin: 0; font-weight: 500; }
|
||||
.mobile-splash .muted { color: #93a1b0; font-size: 0.9rem; max-width: 28rem; }
|
||||
Reference in New Issue
Block a user