diff --git a/README.md b/README.md index 6bd0abf..c77045e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..4054b7e --- /dev/null +++ b/docs/api.md @@ -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. diff --git a/docs/websocket.md b/docs/websocket.md new file mode 100644 index 0000000..84d2a89 --- /dev/null +++ b/docs/websocket.md @@ -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. diff --git a/src/common.js b/src/common.js index 93e900a..01ab94f 100644 --- a/src/common.js +++ b/src/common.js @@ -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, diff --git a/src/data/admin.js b/src/data/admin.js index 9603a92..ca3a4e0 100644 --- a/src/data/admin.js +++ b/src/data/admin.js @@ -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 diff --git a/src/data/canvas-sizes.js b/src/data/canvas-sizes.js index 0d4a119..39f17fd 100644 --- a/src/data/canvas-sizes.js +++ b/src/data/canvas-sizes.js @@ -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; } diff --git a/src/data/playlists.js b/src/data/playlists.js index cb72f87..ac7b8a9 100644 --- a/src/data/playlists.js +++ b/src/data/playlists.js @@ -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; } diff --git a/src/data/screens.js b/src/data/screens.js index 159b0b5..2f3de3e 100644 --- a/src/data/screens.js +++ b/src/data/screens.js @@ -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 }; } diff --git a/src/data/templates.js b/src/data/templates.js index fce3203..08f8a48 100644 --- a/src/data/templates.js +++ b/src/data/templates.js @@ -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.'); diff --git a/src/player-render.js b/src/player-render.js deleted file mode 100644 index 46f93f0..0000000 --- a/src/player-render.js +++ /dev/null @@ -1,1236 +0,0 @@ -const path = require('path'); - -function mediaKind(mediaType, mediaPath) { - const ext = path.extname(mediaPath || '').toLowerCase(); - if ((mediaType || '').startsWith('image/') || ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'].includes(ext)) { - return 'image'; - } - if ((mediaType || '').startsWith('video/') || ['.mp4', '.webm', '.ogg'].includes(ext)) { - return 'video'; - } - if ((mediaType || '').includes('pdf') || ext === '.pdf') { - return 'pdf'; - } - return 'file'; -} - -function escapeHtml(value) { - return String(value ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -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(//gi, ''); - output = output.replace(//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 '
'; - } - return closing ? `` : `<${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 '' + sanitizeRichText(block.data.text || '') + ''; - } - - 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('') + ''; - } - - if (block.type === 'delimiter') { - return '
'; - } - - if (block.type === 'code') { - return '
' + escapeHtml(block.data.code || '') + '
'; - } - - if (block.type === 'table') { - return renderEditorJsTable(block.data); - } - - if (block.type === 'paragraph') { - return '

' + sanitizeRichText(block.data.text || '') + '

'; - } - - 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('') + '' : ''; - return '
  • ' + sanitizeRichText(content || '') + nested + '
  • '; - } - return '
  • ' + sanitizeRichText(item || '') + '
  • '; -} - -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 '' + cells.map(function (cell) { - return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + ''; - }).join('') + ''; - }).join(''); - return '' + tableRows + '
    '; -} - -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 '
    HTML
    '; - } - return ''; -} - -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) - }; -} - -function renderPlayerPage(slug, initialData) { - return ` - - - - - Screen ${escapeHtml(slug)} - - - -
    Loading screen...
    - - - `; -} - -module.exports = { - mediaKind, - renderPlayerPage -}; diff --git a/src/player.js b/src/player.js index b804e1d..5ac63ec 100644 --- a/src/player.js +++ b/src/player.js @@ -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); diff --git a/src/player/player-page.script.html b/src/player/player-page.script.html new file mode 100644 index 0000000..e112870 --- /dev/null +++ b/src/player/player-page.script.html @@ -0,0 +1,1234 @@ + \ No newline at end of file diff --git a/src/player/player-page.template.html b/src/player/player-page.template.html new file mode 100644 index 0000000..18871b6 --- /dev/null +++ b/src/player/player-page.template.html @@ -0,0 +1,13 @@ + + + + + + {{TITLE}} + + + +
    Loading screen...
    + {{SCRIPT_BLOCK}} + + diff --git a/src/player/render.js b/src/player/render.js new file mode 100644 index 0000000..3ac5e0f --- /dev/null +++ b/src/player/render.js @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +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(//gi, ''); + output = output.replace(//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 '
    '; + } + return closing ? `` : `<${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 '' + sanitizeRichText(block.data.text || '') + ''; + } 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('') + ''; + } else if (block.type === 'delimiter') { + return '
    '; + } else if (block.type === 'code') { + return '
    ' + escapeHtml(block.data.code || '') + '
    '; + } else if (block.type === 'table') { + return renderEditorJsTable(block.data); + } else if (block.type === 'paragraph') { + return '

    ' + sanitizeRichText(block.data.text || '') + '

    '; + } + + 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('') + '' : ''; + return '
  • ' + sanitizeRichText(content || '') + nested + '
  • '; + } + return '
  • ' + sanitizeRichText(item || '') + '
  • '; +} + +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 '' + cells.map(function (cell) { + return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + ''; + }).join('') + ''; + }).join(''); + return '' + tableRows + '
    '; +} + +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 '
    HTML
    '; + } + return ''; +} + +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 +}; diff --git a/src/webui.js b/src/webui.js index a17dad1..2d578cd 100644 --- a/src/webui.js +++ b/src/webui.js @@ -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(); diff --git a/src/webui/public/css/admin-editor.css b/src/webui/public/css/admin-editor.css deleted file mode 100644 index 78629a9..0000000 --- a/src/webui/public/css/admin-editor.css +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/src/webui/public/css/admin.css b/src/webui/public/css/admin.css index 66b7aee..f1aa3bb 100644 --- a/src/webui/public/css/admin.css +++ b/src/webui/public/css/admin.css @@ -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; +} + diff --git a/src/webui/public/css/template-designer.css b/src/webui/public/css/template-designer.css deleted file mode 100644 index 80078c7..0000000 --- a/src/webui/public/css/template-designer.css +++ /dev/null @@ -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; -} diff --git a/src/webui/public/js/admin-page.js b/src/webui/public/js/admin-page.js index 69e53ac..6a1af1c 100644 --- a/src/webui/public/js/admin-page.js +++ b/src/webui/public/js/admin-page.js @@ -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 '
    '; } @@ -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 = '
    '; - 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(); - } }()); diff --git a/src/webui/public/js/slide-form.js b/src/webui/public/js/slide-form.js index 455cf24..914734a 100644 --- a/src/webui/public/js/slide-form.js +++ b/src/webui/public/js/slide-form.js @@ -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 = '
    '; - 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, '&') diff --git a/src/webui/public/js/system-status.js b/src/webui/public/js/system-status.js new file mode 100644 index 0000000..808cafa --- /dev/null +++ b/src/webui/public/js/system-status.js @@ -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(); +}()); \ No newline at end of file diff --git a/src/webui/public/js/table-sort.js b/src/webui/public/js/table-sort.js new file mode 100644 index 0000000..3cc135f --- /dev/null +++ b/src/webui/public/js/table-sort.js @@ -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; +}()); \ No newline at end of file diff --git a/src/webui/public/js/theme-init.js b/src/webui/public/js/theme-init.js new file mode 100644 index 0000000..1f71dfc --- /dev/null +++ b/src/webui/public/js/theme-init.js @@ -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; +}()); \ No newline at end of file diff --git a/src/webui/public/js/theme-sidebar.js b/src/webui/public/js/theme-sidebar.js new file mode 100644 index 0000000..97836a2 --- /dev/null +++ b/src/webui/public/js/theme-sidebar.js @@ -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(); +}()); \ No newline at end of file diff --git a/src/webui/public/js/toast.js b/src/webui/public/js/toast.js new file mode 100644 index 0000000..b3b850d --- /dev/null +++ b/src/webui/public/js/toast.js @@ -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 = '
    '; + 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(); +}()); \ No newline at end of file diff --git a/src/webui/routes/playlists/edit.js b/src/webui/routes/playlists/edit.js index 2198d4c..1499147 100644 --- a/src/webui/routes/playlists/edit.js +++ b/src/webui/routes/playlists/edit.js @@ -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'] }); }; diff --git a/src/webui/routes/playlists/slide-config.js b/src/webui/routes/playlists/slide-config.js index 439de0f..4ce7b66 100644 --- a/src/webui/routes/playlists/slide-config.js +++ b/src/webui/routes/playlists/slide-config.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'] }); }; diff --git a/src/webui/routes/slides/form.js b/src/webui/routes/slides/form.js index e5d21ff..46e129f 100644 --- a/src/webui/routes/slides/form.js +++ b/src/webui/routes/slides/form.js @@ -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 }); }; diff --git a/src/webui/routes/templates/add.js b/src/webui/routes/templates/add.js index 2ecea47..9b543ad 100644 --- a/src/webui/routes/templates/add.js +++ b/src/webui/routes/templates/add.js @@ -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'] }); }; diff --git a/src/webui/views/dashboard/index.hbs b/src/webui/views/dashboard/index.hbs index 95a5bcf..153b114 100644 --- a/src/webui/views/dashboard/index.hbs +++ b/src/webui/views/dashboard/index.hbs @@ -9,7 +9,7 @@
    {{playlists.length}}Playlists
    {{slides.length}}Slides
    {{screens.length}}Screens
    -
    {{connectedClientsCount}}Connected clients
    +
    {{connectedClientsCount}}Clients
    @@ -21,7 +21,7 @@
    - +
    diff --git a/src/webui/views/frame-layout.hbs b/src/webui/views/frame-layout.hbs index e5cd9c4..6c40c14 100644 --- a/src/webui/views/frame-layout.hbs +++ b/src/webui/views/frame-layout.hbs @@ -4,27 +4,7 @@ {{title}} - Signage - + {{#if stylesheets.length}} {{#each stylesheets}} @@ -55,6 +35,8 @@ + + {{#if scripts.length}} {{#each scripts}} diff --git a/src/webui/views/layout.hbs b/src/webui/views/layout.hbs index 59b8eb6..64b196a 100644 --- a/src/webui/views/layout.hbs +++ b/src/webui/views/layout.hbs @@ -4,27 +4,7 @@ {{title}} - Signage - + {{#if stylesheets.length}} {{#each stylesheets}} @@ -116,7 +96,11 @@ + + + + {{/if}} {{#if scripts.length}} {{#each scripts}} diff --git a/src/webui/views/playlists/edit.hbs b/src/webui/views/playlists/edit.hbs index a376dac..ad7470f 100644 --- a/src/webui/views/playlists/edit.hbs +++ b/src/webui/views/playlists/edit.hbs @@ -6,7 +6,7 @@ Back to playlists -
    +