Optimisations and updated readme
This commit is contained in:
@@ -16,6 +16,12 @@ It runs as two connected services:
|
||||
- Upload images and other media for use in slides and templates
|
||||
- View live screen connections and send player commands
|
||||
|
||||
## Documentation
|
||||
|
||||
Player-facing API details live in [docs/api.md](docs/api.md). It covers the player HTTP endpoints for screen playback, playlist data, connections, and commands.
|
||||
|
||||
Player websocket behavior lives in [docs/websocket.md](docs/websocket.md). It covers the player control socket, snapshot stream, and the command/message shapes the player accepts.
|
||||
|
||||
## What You Need
|
||||
|
||||
- Docker and Docker Compose
|
||||
|
||||
+131
@@ -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.
|
||||
@@ -0,0 +1,175 @@
|
||||
# Player WebSocket Reference
|
||||
|
||||
## Overview
|
||||
|
||||
Player service websocket base URL: `ws://localhost:3001`
|
||||
|
||||
This document uses OpenAPI-style sections, but stays in plain markdown.
|
||||
|
||||
## Channels
|
||||
|
||||
### `GET /ws/screens/{slug}`
|
||||
Player control channel.
|
||||
|
||||
This is the bidirectional socket used by the player page. The player sends status messages to the server, and the server sends commands back to the player.
|
||||
|
||||
### `GET /ws/screens/{slug}/events`
|
||||
Player snapshot channel.
|
||||
|
||||
This is the server-to-dashboard snapshot stream for live player connection state.
|
||||
|
||||
## Player Control Channel
|
||||
|
||||
### Messages from player to server
|
||||
|
||||
The player sends two message types:
|
||||
|
||||
- `hello`
|
||||
- `state`
|
||||
|
||||
#### `hello`
|
||||
|
||||
Example payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "hello",
|
||||
"clientId": "client-id",
|
||||
"userAgent": "browser ua",
|
||||
"page": "http://.../screen/demo",
|
||||
"viewport": { "width": 1920, "height": 1080 },
|
||||
"paused": false,
|
||||
"blackout": false,
|
||||
"currentSlide": null
|
||||
}
|
||||
```
|
||||
|
||||
#### `state`
|
||||
|
||||
Example payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "state",
|
||||
"clientId": "client-id",
|
||||
"userAgent": "browser ua",
|
||||
"page": "http://.../screen/demo",
|
||||
"viewport": { "width": 1920, "height": 1080 },
|
||||
"paused": false,
|
||||
"blackout": false,
|
||||
"currentSlide": {
|
||||
"id": 12,
|
||||
"title": "Main Slide",
|
||||
"kind": "image",
|
||||
"playlistSignature": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Messages from server to player
|
||||
|
||||
The server sends command messages with:
|
||||
|
||||
- `type: "command"`
|
||||
- `command`
|
||||
- optional `sentAt`
|
||||
- optional `targetConnectionId`
|
||||
- optional `blackout` for blackout commands
|
||||
|
||||
Supported commands:
|
||||
|
||||
- `refresh`
|
||||
- `reload`
|
||||
- `pause`
|
||||
- `blackout`
|
||||
- `previous`
|
||||
- `next`
|
||||
- `left`
|
||||
- `right`
|
||||
|
||||
#### `refresh`
|
||||
Asks the player to refetch the current playlist.
|
||||
|
||||
#### `reload`
|
||||
Forces a browser page reload.
|
||||
|
||||
#### `pause`
|
||||
Toggles paused state on the player.
|
||||
|
||||
#### `blackout`
|
||||
Sets blackout state explicitly.
|
||||
|
||||
Example payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "command",
|
||||
"command": "blackout",
|
||||
"blackout": false
|
||||
}
|
||||
```
|
||||
|
||||
Use `false` to restore and `true` to blackout.
|
||||
|
||||
#### `previous` / `left`
|
||||
Moves to the previous slide.
|
||||
|
||||
#### `next` / `right`
|
||||
Moves to the next slide.
|
||||
|
||||
## Snapshot Channel
|
||||
|
||||
### Messages from server to client
|
||||
|
||||
The server sends snapshot payloads on `/ws/screens/{slug}/events`.
|
||||
|
||||
Example payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "snapshot",
|
||||
"slug": "demo",
|
||||
"connections": [],
|
||||
"sentAt": "2026-07-14T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
The snapshot channel is server-to-client only.
|
||||
|
||||
## Data Models
|
||||
|
||||
### Connection Snapshot
|
||||
|
||||
- `id`
|
||||
- `clientId`
|
||||
- `label`
|
||||
- `userAgent`
|
||||
- `viewport`
|
||||
- `page`
|
||||
- `currentSlide`
|
||||
- `paused`
|
||||
- `blackout`
|
||||
- `clientIp`
|
||||
- `remoteAddress`
|
||||
- `connectedAt`
|
||||
- `lastSeenAt`
|
||||
|
||||
### Command Message
|
||||
|
||||
- `type`
|
||||
- `command`
|
||||
- `sentAt`
|
||||
- `targetConnectionId`
|
||||
- `blackout`
|
||||
|
||||
### Snapshot Message
|
||||
|
||||
- `type`
|
||||
- `slug`
|
||||
- `connections`
|
||||
- `sentAt`
|
||||
|
||||
## Notes
|
||||
|
||||
- This document covers only the player websocket channels.
|
||||
- Web UI/admin websocket behavior is intentionally omitted.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
const db = require('./db');
|
||||
const data = require('./data');
|
||||
const player = require('./player-render');
|
||||
const player = require('./player/render');
|
||||
|
||||
module.exports = {
|
||||
createPool: db.createPool,
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
async function fetchCanvasSizesData(pool) {
|
||||
const [canvasSizes] = await pool.query('SELECT * FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
return { canvasSizes };
|
||||
}
|
||||
|
||||
async function fetchCanvasSizeById(pool, id) {
|
||||
const [rows] = await pool.query('SELECT * FROM canvas_sizes WHERE id = ?', [id]);
|
||||
const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
async function fetchPlaylistById(pool, id) {
|
||||
const [rows] = await pool.query('SELECT * FROM playlists WHERE id = ?', [id]);
|
||||
const [rows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+87
-7
@@ -7,17 +7,22 @@ const { WebSocketServer, WebSocket } = require('ws');
|
||||
const common = require('./common');
|
||||
|
||||
async function buildScreenPlaylist(pool, slug) {
|
||||
const [screenRows] = await pool.query('SELECT * FROM screens WHERE slug = ?', [slug]);
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]);
|
||||
if (!screenRows.length) {
|
||||
return { screen: null, playlist: null, slides: [] };
|
||||
}
|
||||
|
||||
const screen = screenRows[0];
|
||||
if (!screen.playlist_id) {
|
||||
return { screen, playlist: null, slides: [] };
|
||||
return {
|
||||
screen,
|
||||
playlist: null,
|
||||
slides: [],
|
||||
revision: getPlaylistRevision(screen, null, [], [], [])
|
||||
};
|
||||
}
|
||||
|
||||
const [playlistRows] = await pool.query('SELECT * FROM playlists WHERE id = ?', [screen.playlist_id]);
|
||||
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
|
||||
const playlist = playlistRows[0] || null;
|
||||
const [slideRows] = await pool.query(`
|
||||
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
|
||||
@@ -35,15 +40,17 @@ async function buildScreenPlaylist(pool, slug) {
|
||||
.filter(function (slide) { return slide.template_id; })
|
||||
.map(function (slide) { return slide.template_id; });
|
||||
const templatesById = {};
|
||||
let templateRows = [];
|
||||
let regionRows = [];
|
||||
if (templateIds.length) {
|
||||
const [templateRows] = await pool.query(`
|
||||
[templateRows] = await pool.query(`
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slide_templates st
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE st.id IN (?)
|
||||
`, [templateIds]);
|
||||
const [regionRows] = await pool.query('SELECT * FROM slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
|
||||
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
|
||||
templateRows.forEach(function (template) {
|
||||
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
|
||||
templatesById[template.id] = template;
|
||||
@@ -64,16 +71,82 @@ async function buildScreenPlaylist(pool, slug) {
|
||||
schedule_days_json: slide.schedule_days_json,
|
||||
media_url: slide.media_path,
|
||||
media_type: slide.media_type,
|
||||
kind: common.mediaKind(slide.media_type, slide.media_path),
|
||||
kind: common.mediaKind(slide.media_path),
|
||||
template_id: slide.template_id,
|
||||
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
||||
content: common.parseJsonSafe(slide.content_json) || {}
|
||||
};
|
||||
});
|
||||
|
||||
return { screen, playlist, slides };
|
||||
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows);
|
||||
|
||||
return { screen, playlist, slides, revision };
|
||||
}
|
||||
|
||||
function updatePlaylistRevisionHash(hash, value) {
|
||||
hash.update(String(value === null || value === undefined ? '' : value));
|
||||
hash.update('\0');
|
||||
}
|
||||
|
||||
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows) {
|
||||
const hash = crypto.createHash('sha1');
|
||||
|
||||
updatePlaylistRevisionHash(hash, screen && screen.id);
|
||||
updatePlaylistRevisionHash(hash, screen && screen.playlist_id);
|
||||
updatePlaylistRevisionHash(hash, screen && screen.modified_at);
|
||||
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.id);
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.modified_at);
|
||||
updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides);
|
||||
|
||||
(Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) {
|
||||
updatePlaylistRevisionHash(hash, slide.id);
|
||||
updatePlaylistRevisionHash(hash, slide.title);
|
||||
updatePlaylistRevisionHash(hash, slide.body);
|
||||
updatePlaylistRevisionHash(hash, slide.template_id);
|
||||
updatePlaylistRevisionHash(hash, slide.content_json);
|
||||
updatePlaylistRevisionHash(hash, slide.media_path);
|
||||
updatePlaylistRevisionHash(hash, slide.media_type);
|
||||
updatePlaylistRevisionHash(hash, slide.modified_at);
|
||||
updatePlaylistRevisionHash(hash, slide.position);
|
||||
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_mode);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_start_time);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_end_time);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_days_json);
|
||||
});
|
||||
|
||||
(Array.isArray(templateRows) ? templateRows : []).forEach(function (template) {
|
||||
updatePlaylistRevisionHash(hash, template.id);
|
||||
updatePlaylistRevisionHash(hash, template.name);
|
||||
updatePlaylistRevisionHash(hash, template.canvas_size_id);
|
||||
updatePlaylistRevisionHash(hash, template.canvas_size_width);
|
||||
updatePlaylistRevisionHash(hash, template.canvas_size_height);
|
||||
updatePlaylistRevisionHash(hash, template.background_image_path);
|
||||
updatePlaylistRevisionHash(hash, template.modified_at);
|
||||
});
|
||||
|
||||
(Array.isArray(regionRows) ? regionRows : []).forEach(function (region) {
|
||||
updatePlaylistRevisionHash(hash, region.id);
|
||||
updatePlaylistRevisionHash(hash, region.template_id);
|
||||
updatePlaylistRevisionHash(hash, region.region_key);
|
||||
updatePlaylistRevisionHash(hash, region.region_type);
|
||||
updatePlaylistRevisionHash(hash, region.label);
|
||||
updatePlaylistRevisionHash(hash, region.font_family);
|
||||
updatePlaylistRevisionHash(hash, region.x);
|
||||
updatePlaylistRevisionHash(hash, region.y);
|
||||
updatePlaylistRevisionHash(hash, region.width);
|
||||
updatePlaylistRevisionHash(hash, region.height);
|
||||
updatePlaylistRevisionHash(hash, region.z_index);
|
||||
updatePlaylistRevisionHash(hash, region.modified_at);
|
||||
});
|
||||
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
|
||||
async function start() {
|
||||
const app = express();
|
||||
const pool = common.createPool();
|
||||
@@ -282,6 +355,13 @@ async function start() {
|
||||
if (!data.screen) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
const etag = '"' + String(data.revision || '') + '"';
|
||||
res.set('ETag', etag);
|
||||
if (String(req.headers['if-none-match'] || '').split(',').map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).includes(etag)) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{TITLE}}</title>
|
||||
<link rel="stylesheet" href="/assets/css/player.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><div class="empty">Loading screen...</div></div>
|
||||
{{SCRIPT_BLOCK}}
|
||||
</body>
|
||||
</html>
|
||||
@@ -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, '"')
|
||||
.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(/</g, '\\u003c');
|
||||
}
|
||||
|
||||
function parseMaybeJson(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
const raw = value.trim();
|
||||
if (!raw) {
|
||||
return value;
|
||||
}
|
||||
if (raw[0] !== '{' && raw[0] !== '[') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContentValue(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
type: 'text',
|
||||
value: parseMaybeJson(value)
|
||||
};
|
||||
}
|
||||
const normalized = {};
|
||||
Object.keys(value).forEach(function (key) {
|
||||
normalized[key] = value[key];
|
||||
});
|
||||
if (normalized.value !== undefined) {
|
||||
normalized.value = parseMaybeJson(normalized.value);
|
||||
} else if (normalized.font_family !== undefined && normalized.font_family !== null) {
|
||||
normalized.font_family = sanitizeFontFamily(normalized.font_family);
|
||||
} else if (normalized.font_size !== undefined && normalized.font_size !== null) {
|
||||
normalized.font_size = sanitizeFontSize(normalized.font_size);
|
||||
} else if (normalized.font_color !== undefined && normalized.font_color !== null) {
|
||||
normalized.font_color = sanitizeTextColor(normalized.font_color);
|
||||
} else if (!normalized.type) {
|
||||
normalized.type = 'text';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeSlide(slide) {
|
||||
const normalized = {};
|
||||
Object.keys(slide || {}).forEach(function (key) {
|
||||
normalized[key] = slide[key];
|
||||
});
|
||||
normalized.content = {};
|
||||
const content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
|
||||
Object.keys(content).forEach(function (regionKey) {
|
||||
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
let output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, (tag) => {
|
||||
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
const closing = Boolean(match[1]);
|
||||
const name = String(match[2] || '').toLowerCase();
|
||||
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
|
||||
return '';
|
||||
}
|
||||
if (name === 'br') {
|
||||
return '<br>';
|
||||
}
|
||||
return closing ? `</${name}>` : `<${name}>`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderEditorJsBlock(block) {
|
||||
if (!block || !block.type || !block.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (block.type === 'header') {
|
||||
const level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
|
||||
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
|
||||
} else if (block.type === 'list') {
|
||||
const tag = block.data.style === 'ordered' ? 'ol' : 'ul';
|
||||
const items = Array.isArray(block.data.items) ? block.data.items : [];
|
||||
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map((item) => renderEditorJsListItem(item, tag)).join('') + '</' + tag + '>';
|
||||
} else if (block.type === 'delimiter') {
|
||||
return '<hr />';
|
||||
} else if (block.type === 'code') {
|
||||
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
|
||||
} else if (block.type === 'table') {
|
||||
return renderEditorJsTable(block.data);
|
||||
} else if (block.type === 'paragraph') {
|
||||
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderEditorJsListItem(item, tag) {
|
||||
if (item && typeof item === 'object') {
|
||||
const content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
|
||||
const children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
|
||||
const nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map((child) => renderEditorJsListItem(child, tag)).join('') + '</' + tag + '>' : '';
|
||||
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
|
||||
}
|
||||
return '<li>' + sanitizeRichText(item || '') + '</li>';
|
||||
}
|
||||
|
||||
function renderEditorJsTable(data) {
|
||||
const rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
|
||||
if (!rows.length) {
|
||||
return '';
|
||||
}
|
||||
const hasHeadings = Boolean(data.withHeadings);
|
||||
const tableRows = rows.map(function (row, rowIndex) {
|
||||
const cells = Array.isArray(row) ? row : [];
|
||||
const cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
|
||||
const cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
|
||||
return '<tr>' + cells.map(function (cell) {
|
||||
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
|
||||
}).join('') + '</tr>';
|
||||
}).join('');
|
||||
return '<table class="editorjs-table">' + tableRows + '</table>';
|
||||
}
|
||||
|
||||
function renderEditorJsContent(value) {
|
||||
if (value && typeof value === 'object') {
|
||||
if (Array.isArray(value.blocks)) {
|
||||
return value.blocks.map(renderEditorJsBlock).join('');
|
||||
}
|
||||
if (value.value !== undefined) {
|
||||
return renderEditorJsContent(value.value);
|
||||
}
|
||||
}
|
||||
const raw = String(value || '');
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && Array.isArray(parsed.blocks)) {
|
||||
return parsed.blocks.map(renderEditorJsBlock).join('');
|
||||
}
|
||||
} catch (_error) {
|
||||
// fall through to legacy HTML rendering
|
||||
}
|
||||
return sanitizeRichText(raw);
|
||||
}
|
||||
|
||||
function renderHtmlRegionContent(value) {
|
||||
const html = String(value || '').trim();
|
||||
if (!html) {
|
||||
return '<div class="template-region-placeholder">HTML</div>';
|
||||
}
|
||||
return '<iframe class="template-region-html-frame" sandbox="" srcdoc="' + escapeHtml(html) + '" title="HTML region" loading="eager"></iframe>';
|
||||
}
|
||||
|
||||
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
|
||||
const width = Math.max(1, Number(canvasWidth || 0) || 1920);
|
||||
const height = Math.max(1, Number(canvasHeight || 0) || 1080);
|
||||
const viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
|
||||
const viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
|
||||
const scale = Math.min(viewportWidth / width, viewportHeight / height);
|
||||
return {
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale)
|
||||
};
|
||||
}
|
||||
|
||||
const playerPageTemplatePath = path.join(__dirname, 'player-page.template.html');
|
||||
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
|
||||
let playerPageTemplateCache = null;
|
||||
let playerPageScriptCache = null;
|
||||
|
||||
function loadTemplate(filePath, cache) {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (cache.value && cache.mtimeMs === stat.mtimeMs) {
|
||||
return cache.value;
|
||||
}
|
||||
const compiled = Handlebars.compile(fs.readFileSync(filePath, 'utf8'));
|
||||
cache.value = compiled;
|
||||
cache.mtimeMs = stat.mtimeMs;
|
||||
return compiled;
|
||||
}
|
||||
|
||||
function getPlayerPageTemplate() {
|
||||
return loadTemplate(playerPageTemplatePath, playerPageTemplateCache || (playerPageTemplateCache = {}));
|
||||
}
|
||||
|
||||
function getPlayerPageScript() {
|
||||
return loadTemplate(playerPageScriptPath, playerPageScriptCache || (playerPageScriptCache = {}));
|
||||
}
|
||||
|
||||
function renderPlayerPage(slug, initialData) {
|
||||
const template = getPlayerPageTemplate();
|
||||
const script = getPlayerPageScript()({
|
||||
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
|
||||
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null))
|
||||
});
|
||||
|
||||
return template({
|
||||
TITLE: 'Screen ' + slug,
|
||||
SCRIPT_BLOCK: new Handlebars.SafeString(script)
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mediaKind,
|
||||
renderPlayerPage
|
||||
};
|
||||
+2
-1
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
(function () {
|
||||
var THEME_STORAGE_KEY = 'webui-theme';
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
@@ -32,196 +31,6 @@
|
||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
||||
}
|
||||
|
||||
function getTableHeaderText(headerCell) {
|
||||
return String(headerCell && headerCell.textContent ? headerCell.textContent : '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function cellHasButtonContent(cell) {
|
||||
return Boolean(cell && cell.querySelector && cell.querySelector('button, form, input, select, textarea'));
|
||||
}
|
||||
|
||||
function getCellSortValue(cell) {
|
||||
if (!cell) {
|
||||
return '';
|
||||
}
|
||||
var sortValue = cell.getAttribute && cell.getAttribute('data-sort-value');
|
||||
if (sortValue !== null && sortValue !== undefined && sortValue !== '') {
|
||||
return String(sortValue).trim();
|
||||
}
|
||||
return String(cell.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function getComparableSortValue(rawValue) {
|
||||
var value = String(rawValue || '').trim();
|
||||
if (!value) {
|
||||
return { type: 'empty', value: '' };
|
||||
}
|
||||
|
||||
var numericValue = Number(value.replace(/,/g, ''));
|
||||
if (!Number.isNaN(numericValue) && value !== '') {
|
||||
return { type: 'number', value: numericValue };
|
||||
}
|
||||
|
||||
var dateValue = Date.parse(value);
|
||||
if (!Number.isNaN(dateValue)) {
|
||||
return { type: 'date', value: dateValue };
|
||||
}
|
||||
|
||||
return { type: 'string', value: value.toLowerCase() };
|
||||
}
|
||||
|
||||
function compareSortValues(leftValue, rightValue) {
|
||||
if (leftValue.type === 'empty' && rightValue.type === 'empty') {
|
||||
return 0;
|
||||
}
|
||||
if (leftValue.type === 'empty') {
|
||||
return 1;
|
||||
}
|
||||
if (rightValue.type === 'empty') {
|
||||
return -1;
|
||||
}
|
||||
if (leftValue.type === rightValue.type) {
|
||||
if (leftValue.value < rightValue.value) {
|
||||
return -1;
|
||||
}
|
||||
if (leftValue.value > rightValue.value) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return String(leftValue.value).localeCompare(String(rightValue.value), undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function getSortableTableState(table) {
|
||||
if (!table._sortableState) {
|
||||
table._sortableState = {
|
||||
columnIndex: null,
|
||||
direction: 'asc'
|
||||
};
|
||||
}
|
||||
return table._sortableState;
|
||||
}
|
||||
|
||||
function updateSortableHeaderState(table) {
|
||||
var state = getSortableTableState(table);
|
||||
var headerCells = table.tHead && table.tHead.rows && table.tHead.rows.length ? Array.prototype.slice.call(table.tHead.rows[0].cells || []) : [];
|
||||
headerCells.forEach(function (headerCell, index) {
|
||||
if (!headerCell) {
|
||||
return;
|
||||
}
|
||||
headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable');
|
||||
headerCell.removeAttribute('aria-sort');
|
||||
headerCell.removeAttribute('role');
|
||||
headerCell.removeAttribute('tabindex');
|
||||
|
||||
if (isSortableTableColumn(table, index)) {
|
||||
headerCell.classList.add('sortable');
|
||||
headerCell.setAttribute('role', 'button');
|
||||
headerCell.setAttribute('tabindex', '0');
|
||||
if (state.columnIndex === index) {
|
||||
headerCell.classList.add(state.direction === 'desc' ? 'sort-desc' : 'sort-asc');
|
||||
headerCell.setAttribute('aria-sort', state.direction === 'desc' ? 'descending' : 'ascending');
|
||||
} else {
|
||||
headerCell.setAttribute('aria-sort', 'none');
|
||||
}
|
||||
} else {
|
||||
headerCell.classList.add('unsortable');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isSortableTableColumn(table, columnIndex) {
|
||||
var headerCell = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0].cells[columnIndex] : null;
|
||||
if (!headerCell) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/actions?|buttons?/i.test(getTableHeaderText(headerCell))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var bodies = table.tBodies ? Array.prototype.slice.call(table.tBodies) : [];
|
||||
for (var i = 0; i < bodies.length; i += 1) {
|
||||
var rows = Array.prototype.slice.call(bodies[i].rows || []);
|
||||
for (var j = 0; j < rows.length; j += 1) {
|
||||
var cell = rows[j].cells ? rows[j].cells[columnIndex] : null;
|
||||
if (cell && cellHasButtonContent(cell)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function sortTable(table, columnIndex, direction) {
|
||||
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
|
||||
if (!tbody) {
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = Array.prototype.slice.call(tbody.rows || []);
|
||||
if (!rows.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var multiplier = direction === 'desc' ? -1 : 1;
|
||||
rows.sort(function (leftRow, rightRow) {
|
||||
var leftCell = leftRow.cells ? leftRow.cells[columnIndex] : null;
|
||||
var rightCell = rightRow.cells ? rightRow.cells[columnIndex] : null;
|
||||
var leftComparable = getComparableSortValue(getCellSortValue(leftCell));
|
||||
var rightComparable = getComparableSortValue(getCellSortValue(rightCell));
|
||||
return compareSortValues(leftComparable, rightComparable) * multiplier;
|
||||
});
|
||||
|
||||
rows.forEach(function (row) {
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function applyTableSort(table) {
|
||||
var state = getSortableTableState(table);
|
||||
if (state.columnIndex === null || state.columnIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
sortTable(table, state.columnIndex, state.direction);
|
||||
updateSortableHeaderState(table);
|
||||
}
|
||||
|
||||
function initSortableTables() {
|
||||
var tables = Array.prototype.slice.call(document.querySelectorAll('table'));
|
||||
tables.forEach(function (table) {
|
||||
var headerRow = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0] : null;
|
||||
if (!headerRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(headerRow.cells, function (headerCell, index) {
|
||||
if (!isSortableTableColumn(table, index)) {
|
||||
return;
|
||||
}
|
||||
|
||||
headerCell.addEventListener('click', function () {
|
||||
var state = getSortableTableState(table);
|
||||
var nextDirection = state.columnIndex === index && state.direction === 'asc' ? 'desc' : 'asc';
|
||||
state.columnIndex = index;
|
||||
state.direction = nextDirection;
|
||||
sortTable(table, index, nextDirection);
|
||||
updateSortableHeaderState(table);
|
||||
});
|
||||
|
||||
headerCell.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
headerCell.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
updateSortableHeaderState(table);
|
||||
});
|
||||
}
|
||||
|
||||
function renderClientActionCell(client) {
|
||||
var paused = Boolean(client.paused);
|
||||
var pauseButtonClass = 'button-link list-action' + (paused ? ' is-paused' : '');
|
||||
@@ -230,7 +39,7 @@
|
||||
var blackoutButtonClass = 'button-link list-action' + (blackout ? ' is-blackout' : '');
|
||||
var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout';
|
||||
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
|
||||
var blackoutCommandValue = blackout ? '0' : '1';
|
||||
var blackoutCommandValue = blackout ? 'false' : 'true';
|
||||
|
||||
return '<div class="actions"><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="inline-form" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="button-link list-action is-danger" data-action="reload">Reload</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="inline-form" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="button-link list-action list-action--nav" data-action="previous" aria-label="Previous slide">◀</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="inline-form" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="button-link list-action list-action--nav" data-action="next" aria-label="Next slide">▶</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="inline-form" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause">' + pauseButtonLabel + '</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="inline-form" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout">' + blackoutButtonLabel + '</button></form></div>';
|
||||
}
|
||||
@@ -296,7 +105,7 @@
|
||||
}
|
||||
var blackoutStateInput = blackoutForm.querySelector('input[name="blackout"]');
|
||||
if (blackoutStateInput) {
|
||||
blackoutStateInput.value = blackout ? '0' : '1';
|
||||
blackoutStateInput.value = blackout ? 'false' : 'true';
|
||||
}
|
||||
var blackoutConnectionInput = blackoutForm.querySelector('input[name="connectionId"]');
|
||||
if (blackoutConnectionInput) {
|
||||
@@ -458,7 +267,7 @@
|
||||
tbody.removeChild(tbody.lastElementChild);
|
||||
}
|
||||
|
||||
applyTableSort(document.getElementById('dashboard-clients-table'));
|
||||
window.applyTableSort(document.getElementById('dashboard-clients-table'));
|
||||
}
|
||||
|
||||
function updateScreenTable(state) {
|
||||
@@ -475,7 +284,7 @@
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = state.screens.map(renderScreenRow).join('');
|
||||
applyTableSort(table);
|
||||
window.applyTableSort(table);
|
||||
}
|
||||
|
||||
function updateDashboardQuickActions(state) {
|
||||
@@ -495,7 +304,7 @@
|
||||
blackoutButton.textContent = label;
|
||||
blackoutButton.className = 'button-link list-action' + (allBlackout ? ' is-blackout' : '');
|
||||
if (blackoutInput) {
|
||||
blackoutInput.value = allBlackout ? '0' : '1';
|
||||
blackoutInput.value = allBlackout ? 'false' : 'true';
|
||||
}
|
||||
if (blackoutForm) {
|
||||
blackoutForm.setAttribute('data-confirm-message', allBlackout ? 'Restore all connected clients?' : 'Blackout all connected clients?');
|
||||
@@ -511,171 +320,9 @@
|
||||
updateScreenTable(state);
|
||||
updateClientTable(state);
|
||||
updateDashboardQuickActions(state);
|
||||
updateSidebarStatus(Boolean(state.playerServiceConnected), 'Live player feed');
|
||||
}
|
||||
|
||||
function updateSidebarStatus(status, label) {
|
||||
var dot = document.getElementById('sidebar-status-dot');
|
||||
var text = document.getElementById('sidebar-status-text');
|
||||
var pill = document.getElementById('sidebar-status-pill');
|
||||
if (!dot && !text && !pill) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedStatus = status === 'online' || status === 'offline' || status === 'unknown'
|
||||
? status
|
||||
: (status ? 'online' : 'offline');
|
||||
var statusLabel = normalizedStatus === 'online'
|
||||
? (label || 'Connected to player feed')
|
||||
: normalizedStatus === 'offline'
|
||||
? 'Disconnected from player feed'
|
||||
: (label || 'Connecting to player feed');
|
||||
var pillLabel = normalizedStatus === 'online' ? 'Connected' : normalizedStatus === 'offline' ? 'Disconnected' : 'Connecting';
|
||||
|
||||
if (dot) {
|
||||
dot.classList.remove('status-dot--unknown', 'status-dot--online', 'status-dot--offline');
|
||||
dot.classList.add(normalizedStatus === 'online' ? 'status-dot--online' : normalizedStatus === 'offline' ? 'status-dot--offline' : 'status-dot--unknown');
|
||||
dot.setAttribute('aria-label', statusLabel);
|
||||
dot.setAttribute('title', statusLabel);
|
||||
}
|
||||
|
||||
if (text) {
|
||||
text.textContent = statusLabel;
|
||||
}
|
||||
|
||||
if (pill) {
|
||||
pill.classList.remove('status-pill--unknown', 'status-pill--online', 'status-pill--offline');
|
||||
pill.classList.add(normalizedStatus === 'online' ? 'status-pill--online' : normalizedStatus === 'offline' ? 'status-pill--offline' : 'status-pill--unknown');
|
||||
pill.textContent = pillLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function connectDashboardSocket() {
|
||||
if (!window.WebSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
var socket = null;
|
||||
var reconnectTimer = null;
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) {
|
||||
return;
|
||||
}
|
||||
reconnectTimer = window.setTimeout(function () {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/admin/dashboard');
|
||||
updateSidebarStatus('unknown', 'Connecting to player feed');
|
||||
|
||||
socket.onmessage = function (event) {
|
||||
try {
|
||||
var payload = JSON.parse(String(event.data || '{}'));
|
||||
if (payload && payload.type === 'dashboard-state') {
|
||||
handleDashboardState(payload.state);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore malformed dashboard payloads.
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = function () {
|
||||
updateSidebarStatus('offline');
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = function () {
|
||||
updateSidebarStatus('offline');
|
||||
try {
|
||||
socket.close();
|
||||
} catch (_error) {
|
||||
// ignore close errors
|
||||
}
|
||||
};
|
||||
|
||||
socket.onopen = function () {
|
||||
updateSidebarStatus('unknown', 'Connecting to player feed');
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
}
|
||||
|
||||
function dismissToast(toast) {
|
||||
toast.classList.add('toast-hide');
|
||||
window.setTimeout(function () {
|
||||
if (toast && toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 220);
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
var text = String(message || '').trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
var existingToast = document.getElementById('app-toast');
|
||||
var existingBody = existingToast ? existingToast.querySelector('.toast-body') : null;
|
||||
if (existingToast && existingBody) {
|
||||
existingBody.textContent = text;
|
||||
existingToast.classList.remove('toast-hide');
|
||||
window.clearTimeout(existingToast._dismissTimer);
|
||||
existingToast._dismissTimer = window.setTimeout(function () {
|
||||
dismissToast(existingToast);
|
||||
}, 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
toast.innerHTML = '<div class="toast-body"></div><button type="button" class="toast-close" aria-label="Dismiss notification">×</button>';
|
||||
toast.querySelector('.toast-body').textContent = text;
|
||||
|
||||
var closeButton = toast.querySelector('.toast-close');
|
||||
closeButton.addEventListener('click', function () {
|
||||
dismissToast(toast);
|
||||
});
|
||||
|
||||
document.body.appendChild(toast);
|
||||
toast._dismissTimer = window.setTimeout(function () {
|
||||
dismissToast(toast);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function initToast() {
|
||||
var toast = document.getElementById('app-toast');
|
||||
var closeButton = document.getElementById('app-toast-close');
|
||||
if (!toast || !closeButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var url = new URL(window.location.href);
|
||||
if (url.searchParams.has('message')) {
|
||||
url.searchParams.delete('message');
|
||||
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore URL cleanup failures
|
||||
}
|
||||
|
||||
closeButton.addEventListener('click', function () {
|
||||
dismissToast(toast);
|
||||
});
|
||||
|
||||
window.setTimeout(function () {
|
||||
dismissToast(toast);
|
||||
}, 4000);
|
||||
}
|
||||
window.webuiHandleDashboardState = handleDashboardState;
|
||||
|
||||
function initConfirmForms() {
|
||||
document.addEventListener('submit', function (event) {
|
||||
@@ -790,6 +437,10 @@
|
||||
throw new Error(text || 'Unable to save changes.');
|
||||
});
|
||||
}
|
||||
if (form.hasAttribute && form.hasAttribute('data-async-save-reload')) {
|
||||
window.location.replace(response.url || window.location.href);
|
||||
return;
|
||||
}
|
||||
return response.text().then(function (text) {
|
||||
var savedMessage = '';
|
||||
try {
|
||||
@@ -823,120 +474,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
function initSidebarToggle() {
|
||||
var body = document.body;
|
||||
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]'));
|
||||
var backdrop = document.querySelector('[data-sidebar-backdrop]');
|
||||
|
||||
if (!toggleButtons.length || !backdrop) {
|
||||
return;
|
||||
}
|
||||
|
||||
function setSidebarOpen(isOpen) {
|
||||
body.classList.toggle('is-sidebar-open', isOpen);
|
||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
||||
toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
setSidebarOpen(!body.classList.contains('is-sidebar-open'));
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
||||
toggleButton.addEventListener('click', function () {
|
||||
toggleSidebar();
|
||||
});
|
||||
});
|
||||
|
||||
backdrop.addEventListener('click', function () {
|
||||
setSidebarOpen(false);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape') {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
});
|
||||
if (window.initSortableTables) {
|
||||
window.initSortableTables();
|
||||
}
|
||||
|
||||
function getPreferredTheme() {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
function getStoredTheme() {
|
||||
try {
|
||||
var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (storedTheme === 'dark' || storedTheme === 'light') {
|
||||
return storedTheme;
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setStoredTheme(theme) {
|
||||
try {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
var normalizedTheme = theme === 'dark' ? 'dark' : 'light';
|
||||
var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark';
|
||||
var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode';
|
||||
|
||||
document.documentElement.dataset.theme = normalizedTheme;
|
||||
document.documentElement.style.colorScheme = normalizedTheme;
|
||||
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) {
|
||||
var icon = toggleButton.querySelector('.theme-toggle__icon');
|
||||
toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false');
|
||||
toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase());
|
||||
if (icon) {
|
||||
icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun');
|
||||
icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon');
|
||||
}
|
||||
});
|
||||
|
||||
return normalizedTheme;
|
||||
}
|
||||
|
||||
function initThemeToggle() {
|
||||
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
|
||||
var storedTheme = getStoredTheme();
|
||||
var theme = storedTheme || getPreferredTheme();
|
||||
|
||||
applyTheme(theme);
|
||||
|
||||
if (!toggleButtons.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
||||
toggleButton.addEventListener('click', function () {
|
||||
var nextTheme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
setStoredTheme(nextTheme);
|
||||
applyTheme(nextTheme);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initToast();
|
||||
initConfirmForms();
|
||||
initAsyncCommandForms();
|
||||
initAsyncSaveForms();
|
||||
initSubmitOnChange();
|
||||
initThemeToggle();
|
||||
initSidebarToggle();
|
||||
initSortableTables();
|
||||
if (document.getElementById('sidebar-status-pill') || document.getElementById('sidebar-status-dot') || document.getElementById('sidebar-status-text')) {
|
||||
connectDashboardSocket();
|
||||
}
|
||||
}());
|
||||
|
||||
@@ -25,54 +25,6 @@
|
||||
var editorInstances = new Map();
|
||||
var submitting = false;
|
||||
|
||||
function dismissToast(toast) {
|
||||
if (!toast) {
|
||||
return;
|
||||
}
|
||||
toast.classList.add('toast-hide');
|
||||
window.setTimeout(function () {
|
||||
if (toast && toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 220);
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
var text = String(message || '').trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
var existingToast = document.getElementById('app-toast');
|
||||
var existingBody = existingToast ? existingToast.querySelector('.toast-body') : null;
|
||||
if (existingToast && existingBody) {
|
||||
existingBody.textContent = text;
|
||||
existingToast.classList.remove('toast-hide');
|
||||
window.clearTimeout(existingToast._dismissTimer);
|
||||
existingToast._dismissTimer = window.setTimeout(function () {
|
||||
dismissToast(existingToast);
|
||||
}, 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
toast.innerHTML = '<div class="toast-body"></div><button type="button" class="toast-close" aria-label="Dismiss notification">×</button>';
|
||||
toast.querySelector('.toast-body').textContent = text;
|
||||
|
||||
var closeButton = toast.querySelector('.toast-close');
|
||||
closeButton.addEventListener('click', function () {
|
||||
dismissToast(toast);
|
||||
});
|
||||
|
||||
document.body.appendChild(toast);
|
||||
toast._dismissTimer = window.setTimeout(function () {
|
||||
dismissToast(toast);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
|
||||
@@ -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();
|
||||
}());
|
||||
@@ -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;
|
||||
}());
|
||||
@@ -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;
|
||||
}());
|
||||
@@ -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();
|
||||
}());
|
||||
@@ -0,0 +1,82 @@
|
||||
(function () {
|
||||
function dismissToast(toast) {
|
||||
if (!toast) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.classList.add('toast-hide');
|
||||
window.setTimeout(function () {
|
||||
if (toast && toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 220);
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
var text = String(message || '').trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
var existingToast = document.getElementById('app-toast');
|
||||
var existingBody = existingToast ? existingToast.querySelector('.toast-body') : null;
|
||||
if (existingToast && existingBody) {
|
||||
existingBody.textContent = text;
|
||||
existingToast.classList.remove('toast-hide');
|
||||
window.clearTimeout(existingToast._dismissTimer);
|
||||
existingToast._dismissTimer = window.setTimeout(function () {
|
||||
dismissToast(existingToast);
|
||||
}, 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
toast.innerHTML = '<div class="toast-body"></div><button type="button" class="toast-close" aria-label="Dismiss notification">×</button>';
|
||||
toast.querySelector('.toast-body').textContent = text;
|
||||
|
||||
var closeButton = toast.querySelector('.toast-close');
|
||||
closeButton.addEventListener('click', function () {
|
||||
dismissToast(toast);
|
||||
});
|
||||
|
||||
document.body.appendChild(toast);
|
||||
toast._dismissTimer = window.setTimeout(function () {
|
||||
dismissToast(toast);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function initToast() {
|
||||
var toast = document.getElementById('app-toast');
|
||||
var closeButton = document.getElementById('app-toast-close');
|
||||
if (!toast || !closeButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var url = new URL(window.location.href);
|
||||
if (url.searchParams.has('message')) {
|
||||
url.searchParams.delete('message');
|
||||
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore URL cleanup failures
|
||||
}
|
||||
|
||||
closeButton.addEventListener('click', function () {
|
||||
dismissToast(toast);
|
||||
});
|
||||
|
||||
window.setTimeout(function () {
|
||||
dismissToast(toast);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
window.dismissToast = dismissToast;
|
||||
window.showToast = showToast;
|
||||
window.initToast = initToast;
|
||||
|
||||
initToast();
|
||||
}());
|
||||
@@ -110,7 +110,6 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
||||
selectableSlides: selectableSlides,
|
||||
playlistCanvasSignature: playlistCanvasSignature,
|
||||
playlistCanvasMismatch: playlistCanvasMismatch,
|
||||
stylesheets: ['css/admin-editor.css'],
|
||||
scripts: ['js/playlist-schedule.js']
|
||||
});
|
||||
};
|
||||
|
||||
@@ -59,7 +59,6 @@ module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide,
|
||||
...day,
|
||||
checked: selectedDays.has(day.value)
|
||||
})),
|
||||
stylesheets: ['css/admin-editor.css'],
|
||||
scripts: ['js/playlist-schedule.js']
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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']
|
||||
});
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<section class="stat"><strong id="dashboard-playlist-count">{{playlists.length}}</strong><span>Playlists</span></section>
|
||||
<section class="stat"><strong id="dashboard-slide-count">{{slides.length}}</strong><span>Slides</span></section>
|
||||
<section class="stat"><strong id="dashboard-screen-count">{{screens.length}}</strong><span>Screens</span></section>
|
||||
<section class="stat"><strong id="dashboard-client-count">{{connectedClientsCount}}</strong><span>Connected clients</span></section>
|
||||
<section class="stat"><strong id="dashboard-client-count">{{connectedClientsCount}}</strong><span>Clients</span></section>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -21,7 +21,7 @@
|
||||
</form>
|
||||
<form method="post" action="/admin/commands" class="inline-form" data-confirm-message="Blackout all connected clients?" data-async-command>
|
||||
<input type="hidden" name="command" value="blackout" />
|
||||
<input type="hidden" name="blackout" value="1" />
|
||||
<input type="hidden" name="blackout" value="true" />
|
||||
<button type="submit" class="button-link list-action" id="dashboard-blackout-all-button">Blackout all clients</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -4,27 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{title}} - Signage</title>
|
||||
<script>
|
||||
(function () {
|
||||
var storageKey = 'webui-theme';
|
||||
var theme = 'light';
|
||||
try {
|
||||
var storedTheme = window.localStorage.getItem(storageKey);
|
||||
if (storedTheme === 'dark' || storedTheme === 'light') {
|
||||
theme = storedTheme;
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
theme = 'dark';
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
theme = 'dark';
|
||||
}
|
||||
}
|
||||
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}());
|
||||
</script>
|
||||
<script src="/assets/js/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/assets/css/admin.css" />
|
||||
{{#if stylesheets.length}}
|
||||
{{#each stylesheets}}
|
||||
@@ -55,6 +35,8 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/js/theme-sidebar.js"></script>
|
||||
<script src="/assets/js/toast.js"></script>
|
||||
<script src="/assets/js/admin-page.js"></script>
|
||||
{{#if scripts.length}}
|
||||
{{#each scripts}}
|
||||
|
||||
@@ -4,27 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>{{title}} - Signage</title>
|
||||
<script>
|
||||
(function () {
|
||||
var storageKey = 'webui-theme';
|
||||
var theme = 'light';
|
||||
try {
|
||||
var storedTheme = window.localStorage.getItem(storageKey);
|
||||
if (storedTheme === 'dark' || storedTheme === 'light') {
|
||||
theme = storedTheme;
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
theme = 'dark';
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
theme = 'dark';
|
||||
}
|
||||
}
|
||||
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}());
|
||||
</script>
|
||||
<script src="/assets/js/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/assets/css/admin.css" />
|
||||
{{#if stylesheets.length}}
|
||||
{{#each stylesheets}}
|
||||
@@ -116,7 +96,11 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/js/theme-sidebar.js"></script>
|
||||
<script src="/assets/js/toast.js"></script>
|
||||
<script src="/assets/js/table-sort.js"></script>
|
||||
<script src="/assets/js/admin-page.js"></script>
|
||||
<script src="/assets/js/system-status.js"></script>
|
||||
{{/if}}
|
||||
{{#if scripts.length}}
|
||||
{{#each scripts}}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<a class="button-link" href="/admin/playlists">Back to playlists</a>
|
||||
</div>
|
||||
|
||||
<form id="playlist-edit-form" method="post" action="/admin/playlists/{{playlist.id}}" data-async-save>
|
||||
<form id="playlist-edit-form" method="post" action="/admin/playlists/{{playlist.id}}" data-async-save data-async-save-reload>
|
||||
<div class="card playlist-name-card">
|
||||
<div class="row playlist-edit-header">
|
||||
<label class="playlist-name-field">Name
|
||||
|
||||
Reference in New Issue
Block a user