Optimisations and updated readme

This commit is contained in:
2026-07-14 12:50:52 +01:00
parent 9709457b73
commit ff23474a54
33 changed files with 3073 additions and 2426 deletions
+6
View File
@@ -16,6 +16,12 @@ It runs as two connected services:
- Upload images and other media for use in slides and templates
- View live screen connections and send player commands
## Documentation
Player-facing API details live in [docs/api.md](docs/api.md). It covers the player HTTP endpoints for screen playback, playlist data, connections, and commands.
Player websocket behavior lives in [docs/websocket.md](docs/websocket.md). It covers the player control socket, snapshot stream, and the command/message shapes the player accepts.
## What You Need
- Docker and Docker Compose
+131
View File
@@ -0,0 +1,131 @@
# Player API Reference
## Overview
Player service base URL: `http://localhost:3001`
This document uses OpenAPI-style sections, but stays in plain markdown.
## Endpoints
### `GET /`
Returns a plain-text landing response for the player service.
### `GET /screen/{slug}`
Returns the rendered player page for a screen.
### `GET /api/screens/{slug}/playlist`
Returns the current playlist payload for a screen.
### `GET /api/screens/{slug}/connections`
### `GET /api/screens/{slug}/clients`
Returns the live player connection snapshot for a screen.
### `POST /api/screens/{slug}/commands`
Sends a command to the player connections for a screen.
Accepted request fields:
- `command` required
- `connectionId` optional
- `clientId` optional
- `blackout` optional when `command=blackout`
Supported commands:
- `refresh`
- `reload`
- `pause`
- `blackout`
- `previous`
- `next`
- `left`
- `right`
If `connectionId` or `clientId` is provided, the command targets a single player connection. Otherwise it is broadcast to all connections for that screen.
## Response Shapes
### Playlist Response
`GET /api/screens/{slug}/playlist` returns an object with:
- `screen`
- `playlist`
- `slides`
### Connections Response
`GET /api/screens/{slug}/connections` and `GET /api/screens/{slug}/clients` return an object with:
- `screen`
- `screenSlug`
- `count`
- `connections`
### Command Response
`POST /api/screens/{slug}/commands` returns an object with:
- `screen`
- `screenSlug`
- `command`
- `connectionId`
- `sent`
## Data Models
### Screen
- `id`
- `name`
- `slug`
- `playlist_id`
- `created_at`
- `modified_at`
### Playlist
- `id`
- `name`
- `fade_between_slides`
### Slide
- `id`
- `title`
- `body`
- `duration_seconds`
- `schedule_mode`
- `schedule_start_datetime`
- `schedule_end_datetime`
- `schedule_start_time`
- `schedule_end_time`
- `schedule_days_json`
- `media_url`
- `media_type`
- `kind`
- `template_id`
- `template`
- `content`
### Connection
- `id`
- `clientId`
- `label`
- `userAgent`
- `viewport`
- `page`
- `currentSlide`
- `paused`
- `blackout`
- `clientIp`
- `remoteAddress`
- `connectedAt`
- `lastSeenAt`
## Notes
- The player API is the only surface documented here.
- Web UI/admin routes are intentionally omitted.
+175
View File
@@ -0,0 +1,175 @@
# Player WebSocket Reference
## Overview
Player service websocket base URL: `ws://localhost:3001`
This document uses OpenAPI-style sections, but stays in plain markdown.
## Channels
### `GET /ws/screens/{slug}`
Player control channel.
This is the bidirectional socket used by the player page. The player sends status messages to the server, and the server sends commands back to the player.
### `GET /ws/screens/{slug}/events`
Player snapshot channel.
This is the server-to-dashboard snapshot stream for live player connection state.
## Player Control Channel
### Messages from player to server
The player sends two message types:
- `hello`
- `state`
#### `hello`
Example payload:
```json
{
"type": "hello",
"clientId": "client-id",
"userAgent": "browser ua",
"page": "http://.../screen/demo",
"viewport": { "width": 1920, "height": 1080 },
"paused": false,
"blackout": false,
"currentSlide": null
}
```
#### `state`
Example payload:
```json
{
"type": "state",
"clientId": "client-id",
"userAgent": "browser ua",
"page": "http://.../screen/demo",
"viewport": { "width": 1920, "height": 1080 },
"paused": false,
"blackout": false,
"currentSlide": {
"id": 12,
"title": "Main Slide",
"kind": "image",
"playlistSignature": "..."
}
}
```
### Messages from server to player
The server sends command messages with:
- `type: "command"`
- `command`
- optional `sentAt`
- optional `targetConnectionId`
- optional `blackout` for blackout commands
Supported commands:
- `refresh`
- `reload`
- `pause`
- `blackout`
- `previous`
- `next`
- `left`
- `right`
#### `refresh`
Asks the player to refetch the current playlist.
#### `reload`
Forces a browser page reload.
#### `pause`
Toggles paused state on the player.
#### `blackout`
Sets blackout state explicitly.
Example payload:
```json
{
"type": "command",
"command": "blackout",
"blackout": false
}
```
Use `false` to restore and `true` to blackout.
#### `previous` / `left`
Moves to the previous slide.
#### `next` / `right`
Moves to the next slide.
## Snapshot Channel
### Messages from server to client
The server sends snapshot payloads on `/ws/screens/{slug}/events`.
Example payload:
```json
{
"type": "snapshot",
"slug": "demo",
"connections": [],
"sentAt": "2026-07-14T12:00:00.000Z"
}
```
The snapshot channel is server-to-client only.
## Data Models
### Connection Snapshot
- `id`
- `clientId`
- `label`
- `userAgent`
- `viewport`
- `page`
- `currentSlide`
- `paused`
- `blackout`
- `clientIp`
- `remoteAddress`
- `connectedAt`
- `lastSeenAt`
### Command Message
- `type`
- `command`
- `sentAt`
- `targetConnectionId`
- `blackout`
### Snapshot Message
- `type`
- `slug`
- `connections`
- `sentAt`
## Notes
- This document covers only the player websocket channels.
- Web UI/admin websocket behavior is intentionally omitted.
+1 -1
View File
@@ -1,6 +1,6 @@
const db = require('./db');
const data = require('./data');
const player = require('./player-render');
const player = require('./player/render');
module.exports = {
createPool: db.createPool,
+5 -5
View File
@@ -1,6 +1,6 @@
async function fetchAdminData(pool) {
const [playlists] = await pool.query('SELECT * FROM playlists ORDER BY id DESC');
const [canvasSizes] = await pool.query('SELECT * FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
@@ -8,16 +8,16 @@ async function fetchAdminData(pool) {
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC
`);
const [templateRegions] = await pool.query('SELECT * FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
const [slides] = await pool.query(`
SELECT s.*, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
FROM slides s
LEFT JOIN slide_templates st ON st.id = s.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY s.id DESC
`);
const [screens] = await pool.query(`
SELECT s.*, p.name AS playlist_name
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
FROM screens s
LEFT JOIN playlists p ON p.id = s.playlist_id
ORDER BY s.id DESC
+2 -2
View File
@@ -1,10 +1,10 @@
async function fetchCanvasSizesData(pool) {
const [canvasSizes] = await pool.query('SELECT * FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
return { canvasSizes };
}
async function fetchCanvasSizeById(pool, id) {
const [rows] = await pool.query('SELECT * FROM canvas_sizes WHERE id = ?', [id]);
const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [id]);
return rows[0] || null;
}
+1 -1
View File
@@ -1,5 +1,5 @@
async function fetchPlaylistById(pool, id) {
const [rows] = await pool.query('SELECT * FROM playlists WHERE id = ?', [id]);
const [rows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [id]);
return rows[0] || null;
}
+2 -2
View File
@@ -23,7 +23,7 @@ async function uniqueScreenSlug(pool, baseSlug) {
async function fetchScreenById(pool, id) {
const [rows] = await pool.query(`
SELECT s.*, p.name AS playlist_name
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
FROM screens s
LEFT JOIN playlists p ON p.id = s.playlist_id
WHERE s.id = ?
@@ -32,7 +32,7 @@ async function fetchScreenById(pool, id) {
}
async function fetchScreenEditData(pool) {
const [playlists] = await pool.query('SELECT * FROM playlists ORDER BY id DESC');
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
return { playlists };
}
+3 -3
View File
@@ -19,7 +19,7 @@ async function fetchTemplateById(pool, id) {
return null;
}
const template = templates[0];
const [regions] = await pool.query('SELECT * FROM slide_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
template.regions = regions;
return template;
}
@@ -32,7 +32,7 @@ async function fetchTemplatesData(pool) {
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC
`);
const [templateRegions] = await pool.query('SELECT * FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
return { templates, templateRegions };
}
@@ -118,7 +118,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
let resolvedCanvasSizeId = canvasSizeId;
if (resolvedCanvasSizeId) {
const [canvasSizes] = await pool.query('SELECT * FROM canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]);
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]);
const canvasSize = canvasSizes[0];
if (!canvasSize) {
const error = new Error('Canvas size not found.');
-1236
View File
File diff suppressed because it is too large Load Diff
+87 -7
View File
@@ -7,17 +7,22 @@ const { WebSocketServer, WebSocket } = require('ws');
const common = require('./common');
async function buildScreenPlaylist(pool, slug) {
const [screenRows] = await pool.query('SELECT * FROM screens WHERE slug = ?', [slug]);
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]);
if (!screenRows.length) {
return { screen: null, playlist: null, slides: [] };
}
const screen = screenRows[0];
if (!screen.playlist_id) {
return { screen, playlist: null, slides: [] };
return {
screen,
playlist: null,
slides: [],
revision: getPlaylistRevision(screen, null, [], [], [])
};
}
const [playlistRows] = await pool.query('SELECT * FROM playlists WHERE id = ?', [screen.playlist_id]);
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
const playlist = playlistRows[0] || null;
const [slideRows] = await pool.query(`
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
@@ -35,15 +40,17 @@ async function buildScreenPlaylist(pool, slug) {
.filter(function (slide) { return slide.template_id; })
.map(function (slide) { return slide.template_id; });
const templatesById = {};
let templateRows = [];
let regionRows = [];
if (templateIds.length) {
const [templateRows] = await pool.query(`
[templateRows] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
FROM slide_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE st.id IN (?)
`, [templateIds]);
const [regionRows] = await pool.query('SELECT * FROM slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
templateRows.forEach(function (template) {
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
templatesById[template.id] = template;
@@ -64,16 +71,82 @@ async function buildScreenPlaylist(pool, slug) {
schedule_days_json: slide.schedule_days_json,
media_url: slide.media_path,
media_type: slide.media_type,
kind: common.mediaKind(slide.media_type, slide.media_path),
kind: common.mediaKind(slide.media_path),
template_id: slide.template_id,
template: slide.template_id ? templatesById[slide.template_id] || null : null,
content: common.parseJsonSafe(slide.content_json) || {}
};
});
return { screen, playlist, slides };
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows);
return { screen, playlist, slides, revision };
}
function updatePlaylistRevisionHash(hash, value) {
hash.update(String(value === null || value === undefined ? '' : value));
hash.update('\0');
}
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows) {
const hash = crypto.createHash('sha1');
updatePlaylistRevisionHash(hash, screen && screen.id);
updatePlaylistRevisionHash(hash, screen && screen.playlist_id);
updatePlaylistRevisionHash(hash, screen && screen.modified_at);
updatePlaylistRevisionHash(hash, playlist && playlist.id);
updatePlaylistRevisionHash(hash, playlist && playlist.modified_at);
updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides);
(Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) {
updatePlaylistRevisionHash(hash, slide.id);
updatePlaylistRevisionHash(hash, slide.title);
updatePlaylistRevisionHash(hash, slide.body);
updatePlaylistRevisionHash(hash, slide.template_id);
updatePlaylistRevisionHash(hash, slide.content_json);
updatePlaylistRevisionHash(hash, slide.media_path);
updatePlaylistRevisionHash(hash, slide.media_type);
updatePlaylistRevisionHash(hash, slide.modified_at);
updatePlaylistRevisionHash(hash, slide.position);
updatePlaylistRevisionHash(hash, slide.duration_seconds);
updatePlaylistRevisionHash(hash, slide.schedule_mode);
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
updatePlaylistRevisionHash(hash, slide.schedule_start_time);
updatePlaylistRevisionHash(hash, slide.schedule_end_time);
updatePlaylistRevisionHash(hash, slide.schedule_days_json);
});
(Array.isArray(templateRows) ? templateRows : []).forEach(function (template) {
updatePlaylistRevisionHash(hash, template.id);
updatePlaylistRevisionHash(hash, template.name);
updatePlaylistRevisionHash(hash, template.canvas_size_id);
updatePlaylistRevisionHash(hash, template.canvas_size_width);
updatePlaylistRevisionHash(hash, template.canvas_size_height);
updatePlaylistRevisionHash(hash, template.background_image_path);
updatePlaylistRevisionHash(hash, template.modified_at);
});
(Array.isArray(regionRows) ? regionRows : []).forEach(function (region) {
updatePlaylistRevisionHash(hash, region.id);
updatePlaylistRevisionHash(hash, region.template_id);
updatePlaylistRevisionHash(hash, region.region_key);
updatePlaylistRevisionHash(hash, region.region_type);
updatePlaylistRevisionHash(hash, region.label);
updatePlaylistRevisionHash(hash, region.font_family);
updatePlaylistRevisionHash(hash, region.x);
updatePlaylistRevisionHash(hash, region.y);
updatePlaylistRevisionHash(hash, region.width);
updatePlaylistRevisionHash(hash, region.height);
updatePlaylistRevisionHash(hash, region.z_index);
updatePlaylistRevisionHash(hash, region.modified_at);
});
return hash.digest('hex');
}
async function start() {
const app = express();
const pool = common.createPool();
@@ -282,6 +355,13 @@ async function start() {
if (!data.screen) {
return res.status(404).json({ error: 'Screen not found' });
}
const etag = '"' + String(data.revision || '') + '"';
res.set('ETag', etag);
if (String(req.headers['if-none-match'] || '').split(',').map(function (value) {
return String(value || '').trim();
}).includes(etag)) {
return res.status(304).end();
}
res.json(data);
} catch (error) {
next(error);
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{TITLE}}</title>
<link rel="stylesheet" href="/assets/css/player.css" />
</head>
<body>
<div id="app"><div class="empty">Loading screen...</div></div>
{{SCRIPT_BLOCK}}
</body>
</html>
+260
View File
@@ -0,0 +1,260 @@
const fs = require('fs');
const path = require('path');
const Handlebars = require('handlebars');
function mediaKind(mediaPath) {
const ext = path.extname(mediaPath || '').toLowerCase();
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'].includes(ext)) {
return 'image';
}
if (['.mp4', '.webm', '.ogg'].includes(ext)) {
return 'video';
}
if (ext === '.pdf') {
return 'pdf';
}
return 'file';
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function sanitizeFontFamily(value) {
return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim();
}
function sanitizeFontSize(value) {
return Math.max(8, Number(value || 0) || 24);
}
function sanitizeTextColor(value, fallback) {
const raw = String(value || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
return raw;
}
return fallback || '#000000';
}
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
function safeJsonForScript(value) {
return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c');
}
function parseMaybeJson(value) {
if (typeof value !== 'string') {
return value;
}
const raw = value.trim();
if (!raw) {
return value;
}
if (raw[0] !== '{' && raw[0] !== '[') {
return value;
}
try {
return JSON.parse(raw);
} catch (_error) {
return value;
}
}
function normalizeContentValue(value) {
if (!value || typeof value !== 'object') {
return {
type: 'text',
value: parseMaybeJson(value)
};
}
const normalized = {};
Object.keys(value).forEach(function (key) {
normalized[key] = value[key];
});
if (normalized.value !== undefined) {
normalized.value = parseMaybeJson(normalized.value);
} else if (normalized.font_family !== undefined && normalized.font_family !== null) {
normalized.font_family = sanitizeFontFamily(normalized.font_family);
} else if (normalized.font_size !== undefined && normalized.font_size !== null) {
normalized.font_size = sanitizeFontSize(normalized.font_size);
} else if (normalized.font_color !== undefined && normalized.font_color !== null) {
normalized.font_color = sanitizeTextColor(normalized.font_color);
} else if (!normalized.type) {
normalized.type = 'text';
}
return normalized;
}
function normalizeSlide(slide) {
const normalized = {};
Object.keys(slide || {}).forEach(function (key) {
normalized[key] = slide[key];
});
normalized.content = {};
const content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
Object.keys(content).forEach(function (regionKey) {
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
});
return normalized;
}
function sanitizeRichText(html) {
let output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, (tag) => {
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i);
if (!match) {
return '';
}
const closing = Boolean(match[1]);
const name = String(match[2] || '').toLowerCase();
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
return '';
}
if (name === 'br') {
return '<br>';
}
return closing ? `</${name}>` : `<${name}>`;
});
}
function renderEditorJsBlock(block) {
if (!block || !block.type || !block.data) {
return '';
}
if (block.type === 'header') {
const level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
} else if (block.type === 'list') {
const tag = block.data.style === 'ordered' ? 'ol' : 'ul';
const items = Array.isArray(block.data.items) ? block.data.items : [];
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map((item) => renderEditorJsListItem(item, tag)).join('') + '</' + tag + '>';
} else if (block.type === 'delimiter') {
return '<hr />';
} else if (block.type === 'code') {
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
} else if (block.type === 'table') {
return renderEditorJsTable(block.data);
} else if (block.type === 'paragraph') {
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
}
return '';
}
function renderEditorJsListItem(item, tag) {
if (item && typeof item === 'object') {
const content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
const children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
const nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map((child) => renderEditorJsListItem(child, tag)).join('') + '</' + tag + '>' : '';
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
}
return '<li>' + sanitizeRichText(item || '') + '</li>';
}
function renderEditorJsTable(data) {
const rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
if (!rows.length) {
return '';
}
const hasHeadings = Boolean(data.withHeadings);
const tableRows = rows.map(function (row, rowIndex) {
const cells = Array.isArray(row) ? row : [];
const cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
const cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
return '<tr>' + cells.map(function (cell) {
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
}).join('') + '</tr>';
}).join('');
return '<table class="editorjs-table">' + tableRows + '</table>';
}
function renderEditorJsContent(value) {
if (value && typeof value === 'object') {
if (Array.isArray(value.blocks)) {
return value.blocks.map(renderEditorJsBlock).join('');
}
if (value.value !== undefined) {
return renderEditorJsContent(value.value);
}
}
const raw = String(value || '');
try {
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.blocks)) {
return parsed.blocks.map(renderEditorJsBlock).join('');
}
} catch (_error) {
// fall through to legacy HTML rendering
}
return sanitizeRichText(raw);
}
function renderHtmlRegionContent(value) {
const html = String(value || '').trim();
if (!html) {
return '<div class="template-region-placeholder">HTML</div>';
}
return '<iframe class="template-region-html-frame" sandbox="" srcdoc="' + escapeHtml(html) + '" title="HTML region" loading="eager"></iframe>';
}
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
const width = Math.max(1, Number(canvasWidth || 0) || 1920);
const height = Math.max(1, Number(canvasHeight || 0) || 1080);
const viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
const viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
const scale = Math.min(viewportWidth / width, viewportHeight / height);
return {
width: Math.round(width * scale),
height: Math.round(height * scale)
};
}
const playerPageTemplatePath = path.join(__dirname, 'player-page.template.html');
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
let playerPageTemplateCache = null;
let playerPageScriptCache = null;
function loadTemplate(filePath, cache) {
const stat = fs.statSync(filePath);
if (cache.value && cache.mtimeMs === stat.mtimeMs) {
return cache.value;
}
const compiled = Handlebars.compile(fs.readFileSync(filePath, 'utf8'));
cache.value = compiled;
cache.mtimeMs = stat.mtimeMs;
return compiled;
}
function getPlayerPageTemplate() {
return loadTemplate(playerPageTemplatePath, playerPageTemplateCache || (playerPageTemplateCache = {}));
}
function getPlayerPageScript() {
return loadTemplate(playerPageScriptPath, playerPageScriptCache || (playerPageScriptCache = {}));
}
function renderPlayerPage(slug, initialData) {
const template = getPlayerPageTemplate();
const script = getPlayerPageScript()({
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null))
});
return template({
TITLE: 'Screen ' + slug,
SCRIPT_BLOCK: new Handlebars.SafeString(script)
});
}
module.exports = {
mediaKind,
renderPlayerPage
};
+2 -1
View File
@@ -59,6 +59,7 @@ function normalizeScheduleMode(value) {
return 'always';
}
function getAuditUserId(req) {
return req && req.currentUser ? Number(req.currentUser.id) : null;
}
@@ -807,7 +808,7 @@ async function start() {
const sent = command === 'blackout'
? await notifyPlayerScreens(slugs, {
command: 'blackout',
blackout: !['0', 'false', 'off', 'restore'].includes(String(req.body.blackout || '').trim().toLowerCase())
blackout: req.body.blackout
})
: await notifyPlayerScreens(slugs, command);
await broadcastDashboardState();
-356
View File
@@ -1,356 +0,0 @@
.slide-editor-top {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
margin-bottom: 16px;
}
.slide-image-file {
margin-top: 8px;
}
.slide-preview-card {
display: flex;
flex-direction: column;
gap: 0;
padding: 0;
}
.slide-preview-header {
margin-bottom: 0;
padding: 16px 16px 8px;
}
.slide-preview-header h3 {
margin: 0;
}
.slide-preview-stage {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: 0;
background: linear-gradient(135deg, #111827, #1f2937);
}
.slide-preview-scroll {
min-height: 0;
padding: 0 16px 16px;
margin-top: 0;
}
.slide-preview-scroll button {
margin-bottom: 8px;
}
.slide-preview-background {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.slide-preview-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
color: #cbd5e1;
background: rgba(17, 24, 39, 0.35);
z-index: 1;
}
.slide-preview-overlay {
position: absolute;
inset: 0;
z-index: 2;
}
.slide-preview-region {
position: absolute;
box-sizing: border-box;
padding: 0;
overflow: hidden;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(15, 23, 42, 0.2);
backdrop-filter: blur(1px);
}
.slide-preview-region h1,
.slide-preview-region h2,
.slide-preview-region h3,
.slide-preview-region h4,
.slide-preview-region h5,
.slide-preview-region h6,
.slide-preview-region p,
.slide-preview-region blockquote,
.slide-preview-region ul,
.slide-preview-region ol,
.slide-preview-region pre {
margin: 0;
}
.slide-preview-region ul,
.slide-preview-region ol {
padding-left: 18px;
}
.slide-preview-region code {
white-space: pre-wrap;
}
.slide-preview-text-content {
width: 100%;
height: 100%;
}
.slide-preview-image-region {
background: rgba(15, 23, 42, 0.25);
}
.slide-preview-image {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.slide-preview-webpage-region {
background: rgba(15, 23, 42, 0.35);
overflow: hidden;
}
.slide-preview-webpage-frame {
width: 100%;
height: 100%;
border: 0;
display: block;
background: #fff;
overflow: hidden;
}
.slide-preview-html-region {
background: rgba(15, 23, 42, 0.35);
overflow: hidden;
}
.slide-preview-html-frame {
width: 100%;
height: 100%;
border: 0;
display: block;
background: transparent;
overflow: hidden;
}
.slide-schedule-dialog {
width: min(900px, calc(100vw - 32px));
max-width: 900px;
border: none;
border-radius: 12px;
padding: 16px;
background: var(--surface);
color: var(--text);
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.35);
}
.slide-schedule-dialog::backdrop {
background: rgba(15, 23, 42, 0.55);
}
.slide-schedule-dialog iframe {
width: 100%;
height: auto;
min-height: 120px;
border: 0;
margin-top: 0;
background: var(--surface);
}
.slide-schedule-dialog-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.slide-schedule-dialog button {
width: auto;
margin: 0;
}
.slide-schedule-dialog button.secondary {
box-shadow: none;
}
.slide-schedule-dialog button.secondary:hover {
filter: brightness(0.92);
}
.slide-schedule-dialog form {
display: grid;
gap: 16px;
}
.slide-schedule-dialog .card {
display: grid;
gap: 12px;
}
.schedule-type-field {
display: block;
margin-bottom: 20px;
}
.schedule-day-option {
margin-top: 0;
justify-content: flex-start;
width: 100%;
gap: 14px;
}
.schedule-day-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
margin-top: 16px;
}
.schedule-day-label {
font-size: 13px;
font-weight: 700;
color: var(--text);
line-height: 1.1;
}
.schedule-day-option .switch-ui {
flex: none;
}
.schedule-day-option input:focus-visible + .switch-ui {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
.schedule-day-option input:checked + .switch-ui {
background: linear-gradient(180deg, var(--primary), var(--primary-dark));
border-color: rgba(37, 99, 235, 0.55);
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08), 0 0 0 4px rgba(37, 99, 235, 0.12);
}
.schedule-save-actions {
margin-top: 16px;
}
.schedule-save-actions button {
margin-top: 0;
}
.slide-preview-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #cbd5e1;
font-size: 12px;
}
.slide-preview-table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
background: none;
}
.slide-preview-table tbody tr {
background: none;
border: default;
}
.slide-preview-table th,
.slide-preview-table td {
border: 1px solid rgba(255, 255, 255, 0.35);
padding: 6px 8px;
text-align: left;
vertical-align: top;
}
.slide-preview-table th {
font-weight: 700;
}
.rich-editor {
display: grid;
gap: 8px;
}
.rich-toolbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rich-toolbar button {
width: auto;
margin-top: 0;
padding: 8px 10px;
}
.rich-surface {
min-height: 240px;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
color: var(--text);
line-height: 1.4;
overflow: auto;
}
.rich-surface:focus {
outline: 2px solid rgba(37, 99, 235, 0.3);
border-color: var(--primary);
}
.editorjs-holder {
min-height: 240px;
border: 1px solid var(--border-strong);
border-radius: 8px;
padding: 16px 12px 12px;
background: var(--editor-paper);
color: var(--editor-text);
margin-top: 8px;
}
.editorjs-holder .codex-editor,
.editorjs-holder .codex-editor__redactor,
.editorjs-holder .ce-block,
.editorjs-holder .ce-paragraph,
.editorjs-holder .ce-header,
.editorjs-holder .ce-list,
.editorjs-holder .ce-list__item,
.editorjs-holder [contenteditable],
.editorjs-holder [contenteditable] * {
color: var(--editor-text);
}
.editorjs-holder [contenteditable] {
caret-color: var(--editor-text);
}
.editorjs-holder .codex-editor__redactor [contenteditable]:empty:after {
color: rgba(0, 0, 0, 0.45);
}
@media (max-width: 1100px) {
.slide-editor-top {
grid-template-columns: 1fr;
}
}
+614
View File
@@ -110,6 +110,7 @@ a {
color: var(--primary);
}
/* Shared admin shell and global UI used across most admin pages. */
.admin-shell-body {
overflow-x: hidden;
}
@@ -517,6 +518,7 @@ html[data-theme='dark'] .theme-toggle__icon--sun {
padding: 28px;
}
/* Dashboard and list-style admin pages. */
.page-shell {
display: grid;
gap: 18px;
@@ -608,6 +610,7 @@ html[data-theme='dark'] .theme-toggle__icon--sun {
margin-bottom: 0;
}
/* Authentication pages. */
.auth-shell-body {
background: var(--auth-body-bg);
}
@@ -676,6 +679,7 @@ html[data-theme='dark'] .theme-toggle__icon--sun {
line-height: 1.5;
}
/* Account and user management pages. */
.account-card {
max-width: 720px;
}
@@ -765,6 +769,25 @@ html[data-theme='dark'] .theme-toggle__icon--sun {
white-space: nowrap;
}
/* Text region controls. */
.template-field-head {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.template-text-style-row {
margin-top: 0;
align-items: flex-end;
}
.template-text-style-row input[type="color"] {
height: 44px;
padding: 4px;
}
.users-table th:last-child {
width: 220px;
@@ -798,6 +821,7 @@ html[data-theme='dark'] .theme-toggle__icon--sun {
align-items: start;
}
/* Playlist, canvas size, template, and screen edit pages. */
.user-inline-form .button-link,
.user-password-form .button-link {
width: fit-content;
@@ -883,6 +907,7 @@ html[data-theme='dark'] .theme-toggle__icon--sun {
margin-top: 0;
}
/* Shared table, form, and widget styling used across admin pages. */
.card h3 {
margin: 0 0 14px;
font-size: 18px;
@@ -1049,6 +1074,7 @@ html[data-theme='dark'] .theme-toggle__icon--sun {
justify-content: flex-end;
}
/* Shared form controls. */
label {
display: block;
font-size: 14px;
@@ -1408,6 +1434,7 @@ tbody tr:last-child td {
}
}
/* Responsive shell behavior. */
@media (max-width: 1100px) {
.app-shell {
grid-template-columns: 1fr;
@@ -1885,3 +1912,590 @@ table td .actions form {
margin-left: 6px;
}
/* Slide editor pages. */
.slide-editor-top {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
margin-bottom: 16px;
}
.slide-image-file {
margin-top: 8px;
}
.slide-preview-card {
display: flex;
flex-direction: column;
gap: 0;
padding: 0;
}
.slide-preview-header {
margin-bottom: 0;
padding: 16px 16px 8px;
}
.slide-preview-header h3 {
margin: 0;
}
.slide-preview-stage {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: 0;
background: linear-gradient(135deg, #111827, #1f2937);
}
.slide-preview-scroll {
min-height: 0;
padding: 0 16px 16px;
margin-top: 0;
}
.slide-preview-scroll button {
margin-bottom: 8px;
}
.slide-preview-background {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.slide-preview-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
color: #cbd5e1;
background: rgba(17, 24, 39, 0.35);
z-index: 1;
}
.slide-preview-overlay {
position: absolute;
inset: 0;
z-index: 2;
}
.slide-preview-region {
position: absolute;
box-sizing: border-box;
padding: 0;
overflow: hidden;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(15, 23, 42, 0.2);
backdrop-filter: blur(1px);
}
.slide-preview-region h1,
.slide-preview-region h2,
.slide-preview-region h3,
.slide-preview-region h4,
.slide-preview-region h5,
.slide-preview-region h6,
.slide-preview-region p,
.slide-preview-region blockquote,
.slide-preview-region ul,
.slide-preview-region ol,
.slide-preview-region pre {
margin: 0;
}
.slide-preview-region ul,
.slide-preview-region ol {
padding-left: 18px;
}
.slide-preview-region code {
white-space: pre-wrap;
}
.slide-preview-text-content {
width: 100%;
height: 100%;
}
.slide-preview-image-region {
background: rgba(15, 23, 42, 0.25);
}
.slide-preview-image {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.slide-preview-webpage-region {
background: rgba(15, 23, 42, 0.35);
overflow: hidden;
}
.slide-preview-webpage-frame {
width: 100%;
height: 100%;
border: 0;
display: block;
background: #fff;
overflow: hidden;
}
.slide-preview-html-region {
background: rgba(15, 23, 42, 0.35);
overflow: hidden;
}
.slide-preview-html-frame {
width: 100%;
height: 100%;
border: 0;
display: block;
background: transparent;
overflow: hidden;
}
.slide-schedule-dialog {
width: min(900px, calc(100vw - 32px));
max-width: 900px;
border: none;
border-radius: 12px;
padding: 16px;
background: var(--surface);
color: var(--text);
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.35);
}
.slide-schedule-dialog::backdrop {
background: rgba(15, 23, 42, 0.55);
}
.slide-schedule-dialog iframe {
width: 100%;
height: auto;
min-height: 120px;
border: 0;
margin-top: 0;
background: var(--surface);
}
.slide-schedule-dialog-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.slide-schedule-dialog button {
width: auto;
margin: 0;
}
.slide-schedule-dialog button.secondary {
box-shadow: none;
}
.slide-schedule-dialog button.secondary:hover {
filter: brightness(0.92);
}
.slide-schedule-dialog form {
display: grid;
gap: 16px;
}
.slide-schedule-dialog .card {
display: grid;
gap: 12px;
}
.schedule-type-field {
display: block;
margin-bottom: 20px;
}
.schedule-day-option {
margin-top: 0;
justify-content: flex-start;
width: 100%;
gap: 14px;
}
.schedule-day-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
margin-top: 16px;
}
.schedule-day-label {
font-size: 13px;
font-weight: 700;
color: var(--text);
line-height: 1.1;
}
.schedule-day-option .switch-ui {
flex: none;
}
.schedule-day-option input:focus-visible + .switch-ui {
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
}
.schedule-day-option input:checked + .switch-ui {
background: linear-gradient(180deg, var(--primary), var(--primary-dark));
border-color: rgba(37, 99, 235, 0.55);
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08), 0 0 0 4px rgba(37, 99, 235, 0.12);
}
.schedule-save-actions {
margin-top: 16px;
}
.schedule-save-actions button {
margin-top: 0;
}
.slide-preview-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #cbd5e1;
font-size: 12px;
}
.slide-preview-table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
background: none;
}
.slide-preview-table tbody tr {
background: none;
border: 0;
}
.slide-preview-table th,
.slide-preview-table td {
border: 1px solid rgba(255, 255, 255, 0.35);
padding: 6px 8px;
text-align: left;
vertical-align: top;
}
.slide-preview-table th {
font-weight: 700;
}
.rich-editor {
display: grid;
gap: 8px;
}
.rich-toolbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rich-toolbar button {
width: auto;
margin-top: 0;
padding: 8px 10px;
}
.rich-surface {
min-height: 240px;
padding: 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
color: var(--text);
line-height: 1.4;
overflow: auto;
}
.rich-surface:focus {
outline: 2px solid rgba(37, 99, 235, 0.3);
border-color: var(--primary);
}
.editorjs-holder {
min-height: 240px;
border: 1px solid var(--border-strong);
border-radius: 8px;
padding: 16px 12px 12px;
background: var(--editor-paper);
color: var(--editor-text);
margin-top: 8px;
}
.editorjs-holder .codex-editor,
.editorjs-holder .codex-editor__redactor,
.editorjs-holder .ce-block,
.editorjs-holder .ce-paragraph,
.editorjs-holder .ce-header,
.editorjs-holder .ce-list,
.editorjs-holder .ce-list__item,
.editorjs-holder [contenteditable],
.editorjs-holder [contenteditable] * {
color: var(--editor-text);
}
.editorjs-holder [contenteditable] {
caret-color: var(--editor-text);
}
.editorjs-holder .codex-editor__redactor [contenteditable]:empty:after {
color: rgba(0, 0, 0, 0.45);
}
@media (max-width: 1100px) {
.slide-editor-top {
grid-template-columns: 1fr;
}
}
/* Template designer pages. */
.template-designer-layout {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
gap: 12px;
align-items: start;
}
.template-designer-main {
display: grid;
gap: 8px;
}
.template-meta-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
}
.template-meta-row > label {
flex: 1 1 0;
margin-top: 0;
}
.template-designer-sidebar {
display: grid;
gap: 8px;
}
.template-designer-actions {
display: flex;
flex-wrap: nowrap;
gap: 8px;
margin: 0 0 8px 0;
}
#template-form .template-designer-actions button {
flex: 1 1 0;
width: auto;
margin-top: 0;
}
.region-select {
width: 100%;
margin-bottom: 8px;
}
#canvas-size-select:disabled {
opacity: 0.55;
cursor: not-allowed;
}
#canvas-size-summary.is-locked {
color: #94a3b8;
}
.designer-stage {
position: relative;
width: 100%;
background: #0f172a;
border-radius: 0;
overflow: hidden;
border: 1px solid #1f2937;
cursor: crosshair;
user-select: none;
box-sizing: border-box;
}
.designer-stage img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: fill;
pointer-events: none;
}
.designer-stage .empty-bg {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 14px;
pointer-events: none;
}
.designer-overlay {
position: absolute;
inset: 0;
}
.designer-rect {
position: absolute;
border: 2px solid #22c55e;
background: rgba(34, 197, 94, 0.14);
color: #fff;
font-size: 12px;
overflow: hidden;
box-sizing: border-box;
}
.designer-rect.selected {
border-color: #f59e0b;
background: rgba(245, 158, 11, 0.18);
}
.designer-rect-label {
position: absolute;
left: 0;
top: 0;
padding: 2px 6px;
background: rgba(15, 23, 42, 0.8);
font-size: 11px;
pointer-events: none;
}
.designer-rect .resize-handle {
position: absolute;
width: 12px;
height: 12px;
background: var(--surface);
border: 1px solid var(--border-strong);
box-sizing: border-box;
}
.designer-rect .resize-handle.nw {
left: -7px;
top: -7px;
cursor: nwse-resize;
}
.designer-rect .resize-handle.ne {
right: -7px;
top: -7px;
cursor: nesw-resize;
}
.designer-rect .resize-handle.sw {
left: -7px;
bottom: -7px;
cursor: nesw-resize;
}
.designer-rect .resize-handle.se {
right: -7px;
bottom: -7px;
cursor: nwse-resize;
}
.designer-draft {
border-style: dashed;
background: rgba(59, 130, 246, 0.12);
border-color: #60a5fa;
}
.region-list {
display: grid;
gap: 8px;
}
#template-form {
display: grid;
}
#template-form label {
margin-top: 8px;
}
#template-form input,
#template-form textarea,
#template-form select,
#template-form button {
margin-top: 4px;
}
#template-form .card {
padding: 12px;
}
.template-field-card + .template-field-card {
margin-top: 12px;
}
.template-designer-sidebar .row.template-designer-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.template-save-button {
margin-bottom: 0;
}
@media (max-width: 720px) {
.template-designer-sidebar .row.template-designer-actions {
grid-template-columns: 1fr;
}
}
.image-filename {
display: block;
margin-top: 8px;
}
.region-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 8px;
display: grid;
gap: 8px;
}
.region-item.selected {
border-color: #f59e0b;
box-shadow: 0 0 0 1px #f59e0b inset;
}
.region-item .row {
align-items: end;
gap: 6px;
}
.region-item[hidden] {
display: none !important;
}
-245
View File
@@ -1,245 +0,0 @@
.template-designer-layout {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
gap: 12px;
align-items: start;
}
.template-designer-main {
display: grid;
gap: 8px;
}
.template-meta-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
}
.template-meta-row > label {
flex: 1 1 0;
margin-top: 0;
}
.template-designer-sidebar {
display: grid;
gap: 8px;
}
.template-designer-actions {
display: flex;
flex-wrap: nowrap;
gap: 8px;
margin: 0 0 8px 0;
}
#template-form .template-designer-actions button {
flex: 1 1 0;
width: auto;
margin-top: 0;
}
.region-select {
width: 100%;
margin-bottom: 8px;
}
#canvas-size-select:disabled {
opacity: 0.55;
cursor: not-allowed;
}
#canvas-size-summary.is-locked {
color: #94a3b8;
}
.designer-stage {
position: relative;
width: 100%;
background: #0f172a;
border-radius: 0;
overflow: hidden;
border: 1px solid #1f2937;
cursor: crosshair;
user-select: none;
box-sizing: border-box;
}
.designer-stage img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: fill;
pointer-events: none;
}
.designer-stage .empty-bg {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 14px;
pointer-events: none;
}
.designer-overlay {
position: absolute;
inset: 0;
}
.designer-rect {
position: absolute;
border: 2px solid #22c55e;
background: rgba(34, 197, 94, 0.14);
color: #fff;
font-size: 12px;
overflow: hidden;
box-sizing: border-box;
}
.designer-rect.selected {
border-color: #f59e0b;
background: rgba(245, 158, 11, 0.18);
}
.designer-rect-label {
position: absolute;
left: 0;
top: 0;
padding: 2px 6px;
background: rgba(15, 23, 42, 0.8);
font-size: 11px;
pointer-events: none;
}
.designer-rect .resize-handle {
position: absolute;
width: 12px;
height: 12px;
background: var(--surface);
border: 1px solid var(--border-strong);
box-sizing: border-box;
}
.designer-rect .resize-handle.nw {
left: -7px;
top: -7px;
cursor: nwse-resize;
}
.designer-rect .resize-handle.ne {
right: -7px;
top: -7px;
cursor: nesw-resize;
}
.designer-rect .resize-handle.sw {
left: -7px;
bottom: -7px;
cursor: nesw-resize;
}
.designer-rect .resize-handle.se {
right: -7px;
bottom: -7px;
cursor: nwse-resize;
}
.designer-draft {
border-style: dashed;
background: rgba(59, 130, 246, 0.12);
border-color: #60a5fa;
}
.region-list {
display: grid;
gap: 8px;
}
#template-form {
display: grid;
}
#template-form label {
margin-top: 8px;
}
#template-form input,
#template-form textarea,
#template-form select,
#template-form button {
margin-top: 4px;
}
#template-form .card {
padding: 12px;
}
.template-field-card + .template-field-card {
margin-top: 12px;
}
.template-designer-sidebar .row.template-designer-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.template-save-button {
margin-bottom: 0;
}
.template-field-head {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.template-text-style-row {
margin-top: 0;
align-items: flex-end;
}
.template-text-style-row input[type="color"] {
height: 36px;
padding: 4px;
}
@media (max-width: 720px) {
.template-designer-sidebar .row.template-designer-actions {
grid-template-columns: 1fr;
}
}
.image-filename {
display: block;
margin-top: 8px;
}
.region-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 8px;
display: grid;
gap: 8px;
}
.region-item.selected {
border-color: #f59e0b;
box-shadow: 0 0 0 1px #f59e0b inset;
}
.region-item .row {
align-items: end;
gap: 6px;
}
.region-item[hidden] {
display: none !important;
}
+12 -469
View File
@@ -1,5 +1,4 @@
(function () {
var THEME_STORAGE_KEY = 'webui-theme';
function escapeHtml(value) {
return String(value ?? '')
@@ -32,196 +31,6 @@
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getTableHeaderText(headerCell) {
return String(headerCell && headerCell.textContent ? headerCell.textContent : '').trim().toLowerCase();
}
function cellHasButtonContent(cell) {
return Boolean(cell && cell.querySelector && cell.querySelector('button, form, input, select, textarea'));
}
function getCellSortValue(cell) {
if (!cell) {
return '';
}
var sortValue = cell.getAttribute && cell.getAttribute('data-sort-value');
if (sortValue !== null && sortValue !== undefined && sortValue !== '') {
return String(sortValue).trim();
}
return String(cell.textContent || '').replace(/\s+/g, ' ').trim();
}
function getComparableSortValue(rawValue) {
var value = String(rawValue || '').trim();
if (!value) {
return { type: 'empty', value: '' };
}
var numericValue = Number(value.replace(/,/g, ''));
if (!Number.isNaN(numericValue) && value !== '') {
return { type: 'number', value: numericValue };
}
var dateValue = Date.parse(value);
if (!Number.isNaN(dateValue)) {
return { type: 'date', value: dateValue };
}
return { type: 'string', value: value.toLowerCase() };
}
function compareSortValues(leftValue, rightValue) {
if (leftValue.type === 'empty' && rightValue.type === 'empty') {
return 0;
}
if (leftValue.type === 'empty') {
return 1;
}
if (rightValue.type === 'empty') {
return -1;
}
if (leftValue.type === rightValue.type) {
if (leftValue.value < rightValue.value) {
return -1;
}
if (leftValue.value > rightValue.value) {
return 1;
}
return 0;
}
return String(leftValue.value).localeCompare(String(rightValue.value), undefined, { numeric: true, sensitivity: 'base' });
}
function getSortableTableState(table) {
if (!table._sortableState) {
table._sortableState = {
columnIndex: null,
direction: 'asc'
};
}
return table._sortableState;
}
function updateSortableHeaderState(table) {
var state = getSortableTableState(table);
var headerCells = table.tHead && table.tHead.rows && table.tHead.rows.length ? Array.prototype.slice.call(table.tHead.rows[0].cells || []) : [];
headerCells.forEach(function (headerCell, index) {
if (!headerCell) {
return;
}
headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable');
headerCell.removeAttribute('aria-sort');
headerCell.removeAttribute('role');
headerCell.removeAttribute('tabindex');
if (isSortableTableColumn(table, index)) {
headerCell.classList.add('sortable');
headerCell.setAttribute('role', 'button');
headerCell.setAttribute('tabindex', '0');
if (state.columnIndex === index) {
headerCell.classList.add(state.direction === 'desc' ? 'sort-desc' : 'sort-asc');
headerCell.setAttribute('aria-sort', state.direction === 'desc' ? 'descending' : 'ascending');
} else {
headerCell.setAttribute('aria-sort', 'none');
}
} else {
headerCell.classList.add('unsortable');
}
});
}
function isSortableTableColumn(table, columnIndex) {
var headerCell = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0].cells[columnIndex] : null;
if (!headerCell) {
return false;
}
if (/actions?|buttons?/i.test(getTableHeaderText(headerCell))) {
return false;
}
var bodies = table.tBodies ? Array.prototype.slice.call(table.tBodies) : [];
for (var i = 0; i < bodies.length; i += 1) {
var rows = Array.prototype.slice.call(bodies[i].rows || []);
for (var j = 0; j < rows.length; j += 1) {
var cell = rows[j].cells ? rows[j].cells[columnIndex] : null;
if (cell && cellHasButtonContent(cell)) {
return false;
}
}
}
return true;
}
function sortTable(table, columnIndex, direction) {
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
if (!tbody) {
return;
}
var rows = Array.prototype.slice.call(tbody.rows || []);
if (!rows.length) {
return;
}
var multiplier = direction === 'desc' ? -1 : 1;
rows.sort(function (leftRow, rightRow) {
var leftCell = leftRow.cells ? leftRow.cells[columnIndex] : null;
var rightCell = rightRow.cells ? rightRow.cells[columnIndex] : null;
var leftComparable = getComparableSortValue(getCellSortValue(leftCell));
var rightComparable = getComparableSortValue(getCellSortValue(rightCell));
return compareSortValues(leftComparable, rightComparable) * multiplier;
});
rows.forEach(function (row) {
tbody.appendChild(row);
});
}
function applyTableSort(table) {
var state = getSortableTableState(table);
if (state.columnIndex === null || state.columnIndex === undefined) {
return;
}
sortTable(table, state.columnIndex, state.direction);
updateSortableHeaderState(table);
}
function initSortableTables() {
var tables = Array.prototype.slice.call(document.querySelectorAll('table'));
tables.forEach(function (table) {
var headerRow = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0] : null;
if (!headerRow) {
return;
}
Array.prototype.forEach.call(headerRow.cells, function (headerCell, index) {
if (!isSortableTableColumn(table, index)) {
return;
}
headerCell.addEventListener('click', function () {
var state = getSortableTableState(table);
var nextDirection = state.columnIndex === index && state.direction === 'asc' ? 'desc' : 'asc';
state.columnIndex = index;
state.direction = nextDirection;
sortTable(table, index, nextDirection);
updateSortableHeaderState(table);
});
headerCell.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
headerCell.click();
}
});
});
updateSortableHeaderState(table);
});
}
function renderClientActionCell(client) {
var paused = Boolean(client.paused);
var pauseButtonClass = 'button-link list-action' + (paused ? ' is-paused' : '');
@@ -230,7 +39,7 @@
var blackoutButtonClass = 'button-link list-action' + (blackout ? ' is-blackout' : '');
var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout';
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
var blackoutCommandValue = blackout ? '0' : '1';
var blackoutCommandValue = blackout ? 'false' : 'true';
return '<div class="actions"><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="inline-form" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="button-link list-action is-danger" data-action="reload">Reload</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="inline-form" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="button-link list-action list-action--nav" data-action="previous" aria-label="Previous slide">◀</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="inline-form" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="button-link list-action list-action--nav" data-action="next" aria-label="Next slide">▶</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="inline-form" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause">' + pauseButtonLabel + '</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="inline-form" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout">' + blackoutButtonLabel + '</button></form></div>';
}
@@ -296,7 +105,7 @@
}
var blackoutStateInput = blackoutForm.querySelector('input[name="blackout"]');
if (blackoutStateInput) {
blackoutStateInput.value = blackout ? '0' : '1';
blackoutStateInput.value = blackout ? 'false' : 'true';
}
var blackoutConnectionInput = blackoutForm.querySelector('input[name="connectionId"]');
if (blackoutConnectionInput) {
@@ -458,7 +267,7 @@
tbody.removeChild(tbody.lastElementChild);
}
applyTableSort(document.getElementById('dashboard-clients-table'));
window.applyTableSort(document.getElementById('dashboard-clients-table'));
}
function updateScreenTable(state) {
@@ -475,7 +284,7 @@
return;
}
tbody.innerHTML = state.screens.map(renderScreenRow).join('');
applyTableSort(table);
window.applyTableSort(table);
}
function updateDashboardQuickActions(state) {
@@ -495,7 +304,7 @@
blackoutButton.textContent = label;
blackoutButton.className = 'button-link list-action' + (allBlackout ? ' is-blackout' : '');
if (blackoutInput) {
blackoutInput.value = allBlackout ? '0' : '1';
blackoutInput.value = allBlackout ? 'false' : 'true';
}
if (blackoutForm) {
blackoutForm.setAttribute('data-confirm-message', allBlackout ? 'Restore all connected clients?' : 'Blackout all connected clients?');
@@ -511,171 +320,9 @@
updateScreenTable(state);
updateClientTable(state);
updateDashboardQuickActions(state);
updateSidebarStatus(Boolean(state.playerServiceConnected), 'Live player feed');
}
function updateSidebarStatus(status, label) {
var dot = document.getElementById('sidebar-status-dot');
var text = document.getElementById('sidebar-status-text');
var pill = document.getElementById('sidebar-status-pill');
if (!dot && !text && !pill) {
return;
}
var normalizedStatus = status === 'online' || status === 'offline' || status === 'unknown'
? status
: (status ? 'online' : 'offline');
var statusLabel = normalizedStatus === 'online'
? (label || 'Connected to player feed')
: normalizedStatus === 'offline'
? 'Disconnected from player feed'
: (label || 'Connecting to player feed');
var pillLabel = normalizedStatus === 'online' ? 'Connected' : normalizedStatus === 'offline' ? 'Disconnected' : 'Connecting';
if (dot) {
dot.classList.remove('status-dot--unknown', 'status-dot--online', 'status-dot--offline');
dot.classList.add(normalizedStatus === 'online' ? 'status-dot--online' : normalizedStatus === 'offline' ? 'status-dot--offline' : 'status-dot--unknown');
dot.setAttribute('aria-label', statusLabel);
dot.setAttribute('title', statusLabel);
}
if (text) {
text.textContent = statusLabel;
}
if (pill) {
pill.classList.remove('status-pill--unknown', 'status-pill--online', 'status-pill--offline');
pill.classList.add(normalizedStatus === 'online' ? 'status-pill--online' : normalizedStatus === 'offline' ? 'status-pill--offline' : 'status-pill--unknown');
pill.textContent = pillLabel;
}
}
function connectDashboardSocket() {
if (!window.WebSocket) {
return;
}
var socket = null;
var reconnectTimer = null;
function scheduleReconnect() {
if (reconnectTimer) {
return;
}
reconnectTimer = window.setTimeout(function () {
reconnectTimer = null;
connect();
}, 5000);
}
function connect() {
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/admin/dashboard');
updateSidebarStatus('unknown', 'Connecting to player feed');
socket.onmessage = function (event) {
try {
var payload = JSON.parse(String(event.data || '{}'));
if (payload && payload.type === 'dashboard-state') {
handleDashboardState(payload.state);
}
} catch (_error) {
// Ignore malformed dashboard payloads.
}
};
socket.onclose = function () {
updateSidebarStatus('offline');
scheduleReconnect();
};
socket.onerror = function () {
updateSidebarStatus('offline');
try {
socket.close();
} catch (_error) {
// ignore close errors
}
};
socket.onopen = function () {
updateSidebarStatus('unknown', 'Connecting to player feed');
};
}
connect();
}
function dismissToast(toast) {
toast.classList.add('toast-hide');
window.setTimeout(function () {
if (toast && toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 220);
}
function showToast(message) {
var text = String(message || '').trim();
if (!text) {
return;
}
var existingToast = document.getElementById('app-toast');
var existingBody = existingToast ? existingToast.querySelector('.toast-body') : null;
if (existingToast && existingBody) {
existingBody.textContent = text;
existingToast.classList.remove('toast-hide');
window.clearTimeout(existingToast._dismissTimer);
existingToast._dismissTimer = window.setTimeout(function () {
dismissToast(existingToast);
}, 4000);
return;
}
var toast = document.createElement('div');
toast.className = 'toast';
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.innerHTML = '<div class="toast-body"></div><button type="button" class="toast-close" aria-label="Dismiss notification">×</button>';
toast.querySelector('.toast-body').textContent = text;
var closeButton = toast.querySelector('.toast-close');
closeButton.addEventListener('click', function () {
dismissToast(toast);
});
document.body.appendChild(toast);
toast._dismissTimer = window.setTimeout(function () {
dismissToast(toast);
}, 4000);
}
function initToast() {
var toast = document.getElementById('app-toast');
var closeButton = document.getElementById('app-toast-close');
if (!toast || !closeButton) {
return;
}
try {
var url = new URL(window.location.href);
if (url.searchParams.has('message')) {
url.searchParams.delete('message');
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
}
} catch (_error) {
// ignore URL cleanup failures
}
closeButton.addEventListener('click', function () {
dismissToast(toast);
});
window.setTimeout(function () {
dismissToast(toast);
}, 4000);
}
window.webuiHandleDashboardState = handleDashboardState;
function initConfirmForms() {
document.addEventListener('submit', function (event) {
@@ -790,6 +437,10 @@
throw new Error(text || 'Unable to save changes.');
});
}
if (form.hasAttribute && form.hasAttribute('data-async-save-reload')) {
window.location.replace(response.url || window.location.href);
return;
}
return response.text().then(function (text) {
var savedMessage = '';
try {
@@ -823,120 +474,12 @@
});
}
function initSidebarToggle() {
var body = document.body;
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]'));
var backdrop = document.querySelector('[data-sidebar-backdrop]');
if (!toggleButtons.length || !backdrop) {
return;
}
function setSidebarOpen(isOpen) {
body.classList.toggle('is-sidebar-open', isOpen);
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
});
}
function toggleSidebar() {
setSidebarOpen(!body.classList.contains('is-sidebar-open'));
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
toggleSidebar();
});
});
backdrop.addEventListener('click', function () {
setSidebarOpen(false);
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
setSidebarOpen(false);
}
});
if (window.initSortableTables) {
window.initSortableTables();
}
function getPreferredTheme() {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
function getStoredTheme() {
try {
var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
if (storedTheme === 'dark' || storedTheme === 'light') {
return storedTheme;
}
} catch (error) {
return null;
}
return null;
}
function setStoredTheme(theme) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch (error) {
return;
}
}
function applyTheme(theme) {
var normalizedTheme = theme === 'dark' ? 'dark' : 'light';
var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark';
var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode';
document.documentElement.dataset.theme = normalizedTheme;
document.documentElement.style.colorScheme = normalizedTheme;
Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) {
var icon = toggleButton.querySelector('.theme-toggle__icon');
toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false');
toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase());
if (icon) {
icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun');
icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon');
}
});
return normalizedTheme;
}
function initThemeToggle() {
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
var storedTheme = getStoredTheme();
var theme = storedTheme || getPreferredTheme();
applyTheme(theme);
if (!toggleButtons.length) {
return;
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
var nextTheme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
setStoredTheme(nextTheme);
applyTheme(nextTheme);
});
});
}
initToast();
initConfirmForms();
initAsyncCommandForms();
initAsyncSaveForms();
initSubmitOnChange();
initThemeToggle();
initSidebarToggle();
initSortableTables();
if (document.getElementById('sidebar-status-pill') || document.getElementById('sidebar-status-dot') || document.getElementById('sidebar-status-text')) {
connectDashboardSocket();
}
}());
-48
View File
@@ -25,54 +25,6 @@
var editorInstances = new Map();
var submitting = false;
function dismissToast(toast) {
if (!toast) {
return;
}
toast.classList.add('toast-hide');
window.setTimeout(function () {
if (toast && toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 220);
}
function showToast(message) {
var text = String(message || '').trim();
if (!text) {
return;
}
var existingToast = document.getElementById('app-toast');
var existingBody = existingToast ? existingToast.querySelector('.toast-body') : null;
if (existingToast && existingBody) {
existingBody.textContent = text;
existingToast.classList.remove('toast-hide');
window.clearTimeout(existingToast._dismissTimer);
existingToast._dismissTimer = window.setTimeout(function () {
dismissToast(existingToast);
}, 4000);
return;
}
var toast = document.createElement('div');
toast.className = 'toast';
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.innerHTML = '<div class="toast-body"></div><button type="button" class="toast-close" aria-label="Dismiss notification">×</button>';
toast.querySelector('.toast-body').textContent = text;
var closeButton = toast.querySelector('.toast-close');
closeButton.addEventListener('click', function () {
dismissToast(toast);
});
document.body.appendChild(toast);
toast._dismissTimer = window.setTimeout(function () {
dismissToast(toast);
}, 4000);
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
+99
View File
@@ -0,0 +1,99 @@
(function () {
function updateSidebarStatus(status, label) {
var dot = document.getElementById('sidebar-status-dot');
var text = document.getElementById('sidebar-status-text');
var pill = document.getElementById('sidebar-status-pill');
if (!dot && !text && !pill) {
return;
}
var normalizedStatus = status === 'online' || status === 'offline' || status === 'unknown'
? status
: (status ? 'online' : 'offline');
var statusLabel = normalizedStatus === 'online'
? (label || 'Connected to player feed')
: normalizedStatus === 'offline'
? 'Disconnected from player feed'
: (label || 'Connecting to player feed');
var pillLabel = normalizedStatus === 'online' ? 'Connected' : normalizedStatus === 'offline' ? 'Disconnected' : 'Connecting';
dot.classList.remove('status-dot--unknown', 'status-dot--online', 'status-dot--offline');
dot.classList.add(normalizedStatus === 'online' ? 'status-dot--online' : normalizedStatus === 'offline' ? 'status-dot--offline' : 'status-dot--unknown');
dot.setAttribute('aria-label', statusLabel);
dot.setAttribute('title', statusLabel);
if (text) {
text.textContent = statusLabel;
}
if (pill) {
pill.classList.remove('status-pill--unknown', 'status-pill--online', 'status-pill--offline');
pill.classList.add(normalizedStatus === 'online' ? 'status-pill--online' : normalizedStatus === 'offline' ? 'status-pill--offline' : 'status-pill--unknown');
pill.textContent = pillLabel;
}
}
function connectDashboardSocket() {
if (!window.WebSocket) {
return;
}
var hasStatusIndicators = document.getElementById('sidebar-status-dot') || document.getElementById('sidebar-status-text') || document.getElementById('sidebar-status-pill');
if (!hasStatusIndicators) {
return;
}
var socket = null;
var reconnectTimer = null;
function scheduleReconnect() {
if (reconnectTimer) {
return;
}
reconnectTimer = window.setTimeout(function () {
reconnectTimer = null;
connect();
}, 5000);
}
function connect() {
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/admin/dashboard');
updateSidebarStatus('unknown', 'Connecting to player feed');
socket.onmessage = function (event) {
try {
var payload = JSON.parse(String(event.data || '{}'));
if (payload && payload.type === 'dashboard-state' && typeof window.webuiHandleDashboardState === 'function') {
window.webuiHandleDashboardState(payload.state);
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed');
}
} catch (_error) {
// Ignore malformed dashboard payloads.
}
};
socket.onclose = function () {
updateSidebarStatus('offline');
scheduleReconnect();
};
socket.onerror = function () {
updateSidebarStatus('offline');
try {
socket.close();
} catch (_error) {
// ignore close errors
}
};
socket.onopen = function () {
updateSidebarStatus('unknown', 'Connecting to player feed');
};
}
connect();
}
connectDashboardSocket();
}());
+194
View File
@@ -0,0 +1,194 @@
(function () {
function getTableHeaderText(headerCell) {
return String(headerCell && headerCell.textContent ? headerCell.textContent : '').trim().toLowerCase();
}
function cellHasButtonContent(cell) {
return Boolean(cell && cell.querySelector && cell.querySelector('button, form, input, select, textarea'));
}
function getCellSortValue(cell) {
if (!cell) {
return '';
}
var sortValue = cell.getAttribute && cell.getAttribute('data-sort-value');
if (sortValue !== null && sortValue !== undefined && sortValue !== '') {
return String(sortValue).trim();
}
return String(cell.textContent || '').replace(/\s+/g, ' ').trim();
}
function getComparableSortValue(rawValue) {
var value = String(rawValue || '').trim();
if (!value) {
return { type: 'empty', value: '' };
}
var numericValue = Number(value.replace(/,/g, ''));
if (!Number.isNaN(numericValue) && value !== '') {
return { type: 'number', value: numericValue };
}
var dateValue = Date.parse(value);
if (!Number.isNaN(dateValue)) {
return { type: 'date', value: dateValue };
}
return { type: 'string', value: value.toLowerCase() };
}
function compareSortValues(leftValue, rightValue) {
if (leftValue.type === 'empty' && rightValue.type === 'empty') {
return 0;
}
if (leftValue.type === 'empty') {
return 1;
}
if (rightValue.type === 'empty') {
return -1;
}
if (leftValue.type === rightValue.type) {
if (leftValue.value < rightValue.value) {
return -1;
}
if (leftValue.value > rightValue.value) {
return 1;
}
return 0;
}
return String(leftValue.value).localeCompare(String(rightValue.value), undefined, { numeric: true, sensitivity: 'base' });
}
function getSortableTableState(table) {
if (!table._sortableState) {
table._sortableState = {
columnIndex: null,
direction: 'asc'
};
}
return table._sortableState;
}
function isSortableTableColumn(table, columnIndex) {
var headerCell = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0].cells[columnIndex] : null;
if (!headerCell) {
return false;
}
if (/actions?|buttons?/i.test(getTableHeaderText(headerCell))) {
return false;
}
var bodies = table.tBodies ? Array.prototype.slice.call(table.tBodies) : [];
for (var i = 0; i < bodies.length; i += 1) {
var rows = Array.prototype.slice.call(bodies[i].rows || []);
for (var j = 0; j < rows.length; j += 1) {
var cell = rows[j].cells ? rows[j].cells[columnIndex] : null;
if (cell && cellHasButtonContent(cell)) {
return false;
}
}
}
return true;
}
function updateSortableHeaderState(table) {
var state = getSortableTableState(table);
var headerCells = table.tHead && table.tHead.rows && table.tHead.rows.length ? Array.prototype.slice.call(table.tHead.rows[0].cells || []) : [];
headerCells.forEach(function (headerCell, index) {
if (!headerCell) {
return;
}
headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable');
headerCell.removeAttribute('aria-sort');
headerCell.removeAttribute('role');
headerCell.removeAttribute('tabindex');
if (isSortableTableColumn(table, index)) {
headerCell.classList.add('sortable');
headerCell.setAttribute('role', 'button');
headerCell.setAttribute('tabindex', '0');
if (state.columnIndex === index) {
headerCell.classList.add(state.direction === 'desc' ? 'sort-desc' : 'sort-asc');
headerCell.setAttribute('aria-sort', state.direction === 'desc' ? 'descending' : 'ascending');
} else {
headerCell.setAttribute('aria-sort', 'none');
}
} else {
headerCell.classList.add('unsortable');
}
});
}
function sortTable(table, columnIndex, direction) {
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
if (!tbody) {
return;
}
var rows = Array.prototype.slice.call(tbody.rows || []);
if (!rows.length) {
return;
}
var multiplier = direction === 'desc' ? -1 : 1;
rows.sort(function (leftRow, rightRow) {
var leftCell = leftRow.cells ? leftRow.cells[columnIndex] : null;
var rightCell = rightRow.cells ? rightRow.cells[columnIndex] : null;
var leftComparable = getComparableSortValue(getCellSortValue(leftCell));
var rightComparable = getComparableSortValue(getCellSortValue(rightCell));
return compareSortValues(leftComparable, rightComparable) * multiplier;
});
rows.forEach(function (row) {
tbody.appendChild(row);
});
}
function applyTableSort(table) {
var state = getSortableTableState(table);
if (state.columnIndex === null || state.columnIndex === undefined) {
return;
}
sortTable(table, state.columnIndex, state.direction);
updateSortableHeaderState(table);
}
function initSortableTables() {
var tables = Array.prototype.slice.call(document.querySelectorAll('table'));
tables.forEach(function (table) {
var headerRow = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0] : null;
if (!headerRow) {
return;
}
Array.prototype.forEach.call(headerRow.cells, function (headerCell, index) {
if (!isSortableTableColumn(table, index)) {
return;
}
headerCell.addEventListener('click', function () {
var state = getSortableTableState(table);
var nextDirection = state.columnIndex === index && state.direction === 'asc' ? 'desc' : 'asc';
state.columnIndex = index;
state.direction = nextDirection;
sortTable(table, index, nextDirection);
updateSortableHeaderState(table);
});
headerCell.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
headerCell.click();
}
});
});
updateSortableHeaderState(table);
});
}
window.applyTableSort = applyTableSort;
window.initSortableTables = initSortableTables;
}());
+20
View File
@@ -0,0 +1,20 @@
(function () {
var storageKey = 'webui-theme';
var theme = 'light';
try {
var storedTheme = window.localStorage.getItem(storageKey);
if (storedTheme === 'dark' || storedTheme === 'light') {
theme = storedTheme;
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
theme = 'dark';
}
} catch (error) {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
theme = 'dark';
}
}
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
}());
+118
View File
@@ -0,0 +1,118 @@
(function () {
var THEME_STORAGE_KEY = 'webui-theme';
function getPreferredTheme() {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
function getStoredTheme() {
try {
var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
if (storedTheme === 'dark' || storedTheme === 'light') {
return storedTheme;
}
} catch (error) {
return null;
}
return null;
}
function setStoredTheme(theme) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch (error) {
return;
}
}
function applyTheme(theme) {
var normalizedTheme = theme === 'dark' ? 'dark' : 'light';
var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark';
var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode';
document.documentElement.dataset.theme = normalizedTheme;
document.documentElement.style.colorScheme = normalizedTheme;
Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) {
var icon = toggleButton.querySelector('.theme-toggle__icon');
toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false');
toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase());
if (icon) {
icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun');
icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon');
}
});
return normalizedTheme;
}
function initThemeToggle() {
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
var storedTheme = getStoredTheme();
var theme = storedTheme || getPreferredTheme();
applyTheme(theme);
if (!toggleButtons.length) {
return;
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
var nextTheme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
setStoredTheme(nextTheme);
applyTheme(nextTheme);
});
});
}
function initSidebarToggle() {
var body = document.body;
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]'));
var backdrop = document.querySelector('[data-sidebar-backdrop]');
if (!toggleButtons.length || !backdrop) {
return;
}
function setSidebarOpen(isOpen) {
body.classList.toggle('is-sidebar-open', isOpen);
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
});
}
function toggleSidebar() {
setSidebarOpen(!body.classList.contains('is-sidebar-open'));
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
toggleSidebar();
});
});
backdrop.addEventListener('click', function () {
setSidebarOpen(false);
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
setSidebarOpen(false);
}
});
}
window.getPreferredTheme = getPreferredTheme;
window.getStoredTheme = getStoredTheme;
window.setStoredTheme = setStoredTheme;
window.applyTheme = applyTheme;
window.initThemeToggle = initThemeToggle;
window.initSidebarToggle = initSidebarToggle;
initThemeToggle();
initSidebarToggle();
}());
+82
View File
@@ -0,0 +1,82 @@
(function () {
function dismissToast(toast) {
if (!toast) {
return;
}
toast.classList.add('toast-hide');
window.setTimeout(function () {
if (toast && toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 220);
}
function showToast(message) {
var text = String(message || '').trim();
if (!text) {
return;
}
var existingToast = document.getElementById('app-toast');
var existingBody = existingToast ? existingToast.querySelector('.toast-body') : null;
if (existingToast && existingBody) {
existingBody.textContent = text;
existingToast.classList.remove('toast-hide');
window.clearTimeout(existingToast._dismissTimer);
existingToast._dismissTimer = window.setTimeout(function () {
dismissToast(existingToast);
}, 4000);
return;
}
var toast = document.createElement('div');
toast.className = 'toast';
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.innerHTML = '<div class="toast-body"></div><button type="button" class="toast-close" aria-label="Dismiss notification">×</button>';
toast.querySelector('.toast-body').textContent = text;
var closeButton = toast.querySelector('.toast-close');
closeButton.addEventListener('click', function () {
dismissToast(toast);
});
document.body.appendChild(toast);
toast._dismissTimer = window.setTimeout(function () {
dismissToast(toast);
}, 4000);
}
function initToast() {
var toast = document.getElementById('app-toast');
var closeButton = document.getElementById('app-toast-close');
if (!toast || !closeButton) {
return;
}
try {
var url = new URL(window.location.href);
if (url.searchParams.has('message')) {
url.searchParams.delete('message');
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
}
} catch (_error) {
// ignore URL cleanup failures
}
closeButton.addEventListener('click', function () {
dismissToast(toast);
});
window.setTimeout(function () {
dismissToast(toast);
}, 4000);
}
window.dismissToast = dismissToast;
window.showToast = showToast;
window.initToast = initToast;
initToast();
}());
-1
View File
@@ -110,7 +110,6 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
selectableSlides: selectableSlides,
playlistCanvasSignature: playlistCanvasSignature,
playlistCanvasMismatch: playlistCanvasMismatch,
stylesheets: ['css/admin-editor.css'],
scripts: ['js/playlist-schedule.js']
});
};
@@ -59,7 +59,6 @@ module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide,
...day,
checked: selectedDays.has(day.value)
})),
stylesheets: ['css/admin-editor.css'],
scripts: ['js/playlist-schedule.js']
});
};
+1 -2
View File
@@ -21,7 +21,6 @@ module.exports = function renderSlideFormPage(data, mode, slide, message, curren
existingTemplateId: slide && slide.template_id ? slide.template_id : null,
existingContent: slide && slide.content ? slide.content : {}
},
currentUser: currentUser || null,
stylesheets: ['css/admin-editor.css']
currentUser: currentUser || null
});
};
-1
View File
@@ -59,7 +59,6 @@ module.exports = function renderTemplateFormPage(template, mode, message, canvas
currentUser: currentUser || null,
template: current,
canvasSizes: canvasSizes || [],
stylesheets: ['css/template-designer.css'],
scripts: ['js/template-designer.js']
});
};
+2 -2
View File
@@ -9,7 +9,7 @@
<section class="stat"><strong id="dashboard-playlist-count">{{playlists.length}}</strong><span>Playlists</span></section>
<section class="stat"><strong id="dashboard-slide-count">{{slides.length}}</strong><span>Slides</span></section>
<section class="stat"><strong id="dashboard-screen-count">{{screens.length}}</strong><span>Screens</span></section>
<section class="stat"><strong id="dashboard-client-count">{{connectedClientsCount}}</strong><span>Connected clients</span></section>
<section class="stat"><strong id="dashboard-client-count">{{connectedClientsCount}}</strong><span>Clients</span></section>
</div>
<div class="card">
@@ -21,7 +21,7 @@
</form>
<form method="post" action="/admin/commands" class="inline-form" data-confirm-message="Blackout all connected clients?" data-async-command>
<input type="hidden" name="command" value="blackout" />
<input type="hidden" name="blackout" value="1" />
<input type="hidden" name="blackout" value="true" />
<button type="submit" class="button-link list-action" id="dashboard-blackout-all-button">Blackout all clients</button>
</form>
</div>
+3 -21
View File
@@ -4,27 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{title}} - Signage</title>
<script>
(function () {
var storageKey = 'webui-theme';
var theme = 'light';
try {
var storedTheme = window.localStorage.getItem(storageKey);
if (storedTheme === 'dark' || storedTheme === 'light') {
theme = storedTheme;
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
theme = 'dark';
}
} catch (error) {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
theme = 'dark';
}
}
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
}());
</script>
<script src="/assets/js/theme-init.js"></script>
<link rel="stylesheet" href="/assets/css/admin.css" />
{{#if stylesheets.length}}
{{#each stylesheets}}
@@ -55,6 +35,8 @@
</main>
</div>
</div>
<script src="/assets/js/theme-sidebar.js"></script>
<script src="/assets/js/toast.js"></script>
<script src="/assets/js/admin-page.js"></script>
{{#if scripts.length}}
{{#each scripts}}
+5 -21
View File
@@ -4,27 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>{{title}} - Signage</title>
<script>
(function () {
var storageKey = 'webui-theme';
var theme = 'light';
try {
var storedTheme = window.localStorage.getItem(storageKey);
if (storedTheme === 'dark' || storedTheme === 'light') {
theme = storedTheme;
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
theme = 'dark';
}
} catch (error) {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
theme = 'dark';
}
}
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
}());
</script>
<script src="/assets/js/theme-init.js"></script>
<link rel="stylesheet" href="/assets/css/admin.css" />
{{#if stylesheets.length}}
{{#each stylesheets}}
@@ -116,7 +96,11 @@
</main>
</div>
</div>
<script src="/assets/js/theme-sidebar.js"></script>
<script src="/assets/js/toast.js"></script>
<script src="/assets/js/table-sort.js"></script>
<script src="/assets/js/admin-page.js"></script>
<script src="/assets/js/system-status.js"></script>
{{/if}}
{{#if scripts.length}}
{{#each scripts}}
+1 -1
View File
@@ -6,7 +6,7 @@
<a class="button-link" href="/admin/playlists">Back to playlists</a>
</div>
<form id="playlist-edit-form" method="post" action="/admin/playlists/{{playlist.id}}" data-async-save>
<form id="playlist-edit-form" method="post" action="/admin/playlists/{{playlist.id}}" data-async-save data-async-save-reload>
<div class="card playlist-name-card">
<div class="row playlist-edit-header">
<label class="playlist-name-field">Name