Compare commits

...
25 Commits
Author SHA1 Message Date
lzstealth 9f08ccfea1 Bump version to 1.4.6 2026-07-22 01:26:04 +01:00
lzstealth 370ec81c33 Bump version to 1.4.5 2026-07-21 21:53:55 +01:00
lzstealth 5e7df5e55c Render 404 pages for unmatched routes 2026-07-21 21:41:35 +01:00
lzstealth cfca5bfe3b Fix RBAC client action visibility 2026-07-21 21:39:56 +01:00
lzstealth 5eff70755b Fix duplicate permissions cleanup 2026-07-21 21:24:08 +01:00
lzstealth 0666d5d07c Bump version to 1.4.1 2026-07-21 21:14:47 +01:00
lzstealth 8b479283e1 Fix schema migration audit columns 2026-07-21 21:14:41 +01:00
lzstealth 13e13d0d68 Bump version to 1.4.0 2026-07-21 21:07:32 +01:00
lzstealth e051958bea Implement RBAC roles system 2026-07-21 21:05:02 +01:00
lzstealth 7973ee0ea4 Fix duplicate onboarding client names 2026-07-21 02:03:09 +01:00
lzstealth 6416dbfd99 Release v1.3.3 2026-07-21 01:55:10 +01:00
lzstealth 6fb413cb6d Bump version to 1.3.2 2026-07-21 01:21:05 +01:00
lzstealth 8393923c5a Bump version to 1.4.1 and tighten client handling 2026-07-21 01:20:12 +01:00
lzstealth 2ea8d389fa This PR breaks the large web and player bootstrap files into smaller modules with clearer ownership.
Web changes:

Split shared helpers, bootstrap logic, route groups, and upload-sync behavior out of web.js.
Kept web.js focused on wiring and server startup.
Fixed screen playlist reassignment so changing a screen’s playlist now triggers a refresh.
Fixed single-slide playlist refresh behavior so updates do not get stuck behind the current slide.
Player changes:

Split websocket/runtime handling into runtime.js.
Split playlist assembly and revision hashing into playlist.js.
Split onboarding and player HTTP routes into dedicated modules.
Split render utilities and template loading into render-helpers.js.
Kept player.js mostly as startup/orchestration.
Validation:

Rebuilt both services with Docker Compose.
Smoke-checked web and player routes after the refactor.
Verified get_errors was clean on the touched modules.
2026-07-20 23:58:27 +01:00
lzstealth 480ccdbe9c Text pasting fix 2026-07-15 02:38:24 +01:00
lzstealth 8020a12408 Fix admin table and playlist reorder UI 2026-07-15 02:16:48 +01:00
lzstealth b7f1d800ef Release v1.1.3: playlist drag sorting, local SortableJS, and playlist UI updates 2026-07-15 00:45:40 +01:00
lzstealth b0649d838e Release v1.1.2 2026-07-14 21:30:40 +01:00
lzstealth 9f3fcbdaf2 Refresh favicon and layout assets 2026-07-14 21:10:27 +01:00
lzstealth 31b10e6fd1 Refresh player and admin UI 2026-07-14 20:52:39 +01:00
lzstealth 4e0e87c86c Refactor web server and release workflow 2026-07-14 18:35:24 +01:00
lzstealth 9070ded66d Update README.md
Docker tags need  to be lowercase.
2026-07-14 15:52:15 +01:00
lzstealth e65a43b13c Handle player redirects on screen slug change 2026-07-14 15:30:05 +01:00
lzstealth 440b8683f7 Make screen slugs editable 2026-07-14 14:49:08 +01:00
lzstealth 1043f26b3e Document tag-based Docker publishing 2026-07-14 14:40:47 +01:00
396 changed files with 17793 additions and 9537 deletions
+4 -6
View File
@@ -1,6 +1,4 @@
node_modules
npm-debug.log
uploads
.git
.gitignore
*.tmp
*
!package.json
!src/
!src/**
-1
View File
@@ -11,7 +11,6 @@ MYSQL_ROOT_PASSWORD=root_password
PLAYER_INTERNAL_BASE_URL=http://player:3001
PLAYER_PUBLIC_BASE_URL=http://localhost:3001
UPLOAD_DIR=/app/uploads
SESSION_MAX_AGE_DAYS=14
DASHBOARD_REFRESH_INTERVAL_MS=2000
+3 -4
View File
@@ -2,8 +2,6 @@ name: Publish Docker Image
on:
push:
branches:
- main
tags:
- 'v*'
workflow_dispatch:
@@ -32,9 +30,10 @@ jobs:
with:
images: git.lzstealth.com/LZStealth/pulse-signage
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=latest
type=ref,event=tag
type=sha
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
- name: Build and push image
uses: docker/build-push-action@v6
+2
View File
@@ -1,5 +1,7 @@
node_modules/
uploads/
docker-compose.dev.yml
.vscode/
.env
npm-debug.log*
yarn-debug.log*
+2 -2
View File
@@ -3,7 +3,7 @@ FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src
@@ -11,4 +11,4 @@ RUN mkdir -p /app/uploads
EXPOSE 3000
CMD ["npm", "run", "start:webui"]
CMD ["npm", "run", "start:web"]
+14 -23
View File
@@ -13,9 +13,12 @@ It runs as two connected services:
- Create and organize playlists and slides
- Design reusable templates and canvas sizes
- Register screens and assign playlists to them
- Manage roles and permissions for the admin web UI
- Upload images and other media for use in slides and templates
- View live screen connections and send player commands
Admin permissions are split into CRUD actions per section, so you can grant read-only, editor, creator, or delete access separately.
## 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.
@@ -34,6 +37,8 @@ When the app starts for the first time, it creates the database tables it needs
- Username: `admin`
- Password: `admin`
The first admin account is placed into the built-in `Administrators` role, which has full web-admin access through the CRUD permissions.
You can change the initial admin credentials with these optional environment variables:
- `DEFAULT_ADMIN_USERNAME`
@@ -57,39 +62,25 @@ The app reads its settings from environment variables.
- `WEB_PORT` - admin app port, default `3000`
- `PLAYER_INTERNAL_BASE_URL` - player address used by the server, default `http://player:3001`
- `PLAYER_PUBLIC_BASE_URL` - player address shown in browser links, default `http://localhost:3001`
- `UPLOAD_DIR` - storage location for uploaded files, default `./uploads`
### Player App
- `PLAYER_PORT` - player port, default `3001`
## Using Docker
## Docker Compose
The recommended way to run the project is with Docker Compose.
The repository includes a `docker-compose.yml` file that starts three services:
```bash
npm run docker:up
```
- `web` - the admin app on port `3000`
- `player` - the signage player on port `3001`
- `mysql` - the database on port `3306`
The main Compose file now uses the published Docker image and still reads values from the repository `.env` file. `DB_HOST` comes from that file for the app containers, so you can point the web UI and player at an external database host or change it to `mysql` if you want to use the bundled MySQL service.
By default, `web` and `player` use the published image from `git.lzstealth.com/lzstealth/pulse-signage:latest`. You can point both services at a specific release by setting `PULSE_SIGNAGE_IMAGE` to a tagged image such as `git.lzstealth.com/lzstealth/pulse-signage:v1.0.0`.
If you only want to test the image build from the current checkout without using Compose, run:
The Compose file also defines a shared `uploads` volume for media and a `mysql_data` volume for database persistence.
```bash
npm run docker:build
```
To publish the Docker image to the Gitea container registry, push to `main` or create a `v*` tag. The workflow in [.gitea/workflows/docker-publish.yml](.gitea/workflows/docker-publish.yml) builds `git.lzstealth.com/LZStealth/pulse-signage` and pushes `latest`, `sha`, and tagged releases.
This starts:
- the web admin app on port `3000`
- the player app on port `3001`
- MySQL on port `3306`
The web app and player app share the same uploaded media volume, so files uploaded in the admin UI are available to the player.
If you want to stop the stack later, run `npm run docker:down`. To follow logs, run `npm run docker:logs`.
In Docker, upload storage is controlled by the `uploads` volume mount at `/app/uploads`.
Outside Docker, the web app and player do not have to use the same upload location or even the same server, as long as each service can access its own configured media path.
## Important Notes
+4 -5
View File
@@ -1,7 +1,7 @@
services:
webui:
web:
image: ${PULSE_SIGNAGE_IMAGE:-git.lzstealth.com/lzstealth/pulse-signage:latest}
container_name: signage-webui
container_name: signage-web
restart: unless-stopped
ports:
- "3000:3000"
@@ -15,7 +15,6 @@ services:
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:3001}
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001}
UPLOAD_DIR: ${UPLOAD_DIR:-/app/uploads}
SESSION_MAX_AGE_DAYS: ${SESSION_MAX_AGE_DAYS:-14}
DASHBOARD_REFRESH_INTERVAL_MS: ${DASHBOARD_REFRESH_INTERVAL_MS:-2000}
DEFAULT_ADMIN_USERNAME: ${DEFAULT_ADMIN_USERNAME:-admin}
@@ -24,7 +23,7 @@ services:
PASSWORD_HASH_ITERATIONS: ${PASSWORD_HASH_ITERATIONS:-310000}
volumes:
- uploads:/app/uploads
command: ["npm", "run", "start:webui"]
command: ["npm", "run", "start:web"]
depends_on:
mysql:
condition: service_healthy
@@ -40,12 +39,12 @@ services:
environment:
NODE_ENV: ${NODE_ENV:-production}
PLAYER_PORT: ${PLAYER_PORT:-3001}
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001}
DB_HOST: ${DB_HOST:-mysql}
DB_PORT: ${DB_PORT:-3306}
DB_NAME: ${DB_NAME:-signage}
DB_USER: ${DB_USER:-signage_user}
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
UPLOAD_DIR: ${UPLOAD_DIR:-/app/uploads}
volumes:
- uploads:/app/uploads
command: ["npm", "run", "start:player"]
-1604
View File
File diff suppressed because it is too large Load Diff
+12 -10
View File
@@ -1,26 +1,28 @@
{
"name": "pulse-signage",
"version": "1.0.0",
"private": true,
"version": "1.4.6",
"private": false,
"description": "Pulse Signage application with MySQL and media uploads",
"repository": {
"type": "git",
"url": "https://git.lzstealth.com/LZStealth/pulse-signage.git"
},
"main": "src/common.js",
"scripts": {
"start": "node -r dotenv/config src/webui.js",
"start:webui": "node -r dotenv/config src/webui.js",
"start": "node -r dotenv/config src/web.js",
"start:web": "node -r dotenv/config src/web.js",
"start:player": "node -r dotenv/config src/player.js",
"dev:webui": "nodemon -r dotenv/config src/webui.js",
"dev:player": "nodemon -r dotenv/config src/player.js",
"docker:build": "docker build -t pulse-signage:test .",
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down",
"docker:logs": "docker-compose logs -f --tail=100"
"dev:web": "nodemon -r dotenv/config src/web.js",
"dev:player": "nodemon -r dotenv/config src/player.js"
},
"dependencies": {
"bootstrap-icons": "1.11.3",
"dotenv": "^17.4.2",
"express": "^4.21.2",
"handlebars": "^4.7.8",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.14.3",
"qrcode": "^1.5.4",
"ws": "^8.21.0"
},
"devDependencies": {
+118
View File
@@ -0,0 +1,118 @@
const crypto = require('crypto');
function normalizeClientName(value) {
return String(value || '').trim();
}
function normalizeDeviceId(value) {
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
}
function collectLiveConnections(liveConnections) {
return Array.isArray(liveConnections) ? liveConnections : [];
}
async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConnections) {
const normalizedName = normalizeClientName(clientName);
if (!normalizedName) {
return false;
}
const normalizedDeviceId = normalizeDeviceId(excludeDeviceId);
const live = collectLiveConnections(liveConnections);
const lowerName = normalizedName.toLowerCase();
try {
if (pool) {
const [deviceRows] = await pool.query(
`SELECT device_id
FROM player_onboarding_devices
WHERE client_name IS NOT NULL
AND TRIM(client_name) <> ''
AND LOWER(TRIM(client_name)) = LOWER(TRIM(?))
AND device_id <> ?
LIMIT 1`,
[normalizedName, normalizedDeviceId]
);
if (deviceRows.length) {
return false;
}
}
for (const connection of live) {
const existingName = normalizeClientName(connection && connection.clientName ? connection.clientName : '');
if (!existingName || existingName.toLowerCase() !== lowerName) {
continue;
}
const existingDeviceId = normalizeDeviceId(connection && (connection.deviceId || connection.clientId) ? (connection.deviceId || connection.clientId) : '');
if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) {
continue;
}
return false;
}
return true;
} catch (_error) {
for (const connection of live) {
const existingName = normalizeClientName(connection && connection.clientName ? connection.clientName : '');
if (!existingName || existingName.toLowerCase() !== lowerName) {
continue;
}
const existingDeviceId = normalizeDeviceId(connection && (connection.deviceId || connection.clientId) ? (connection.deviceId || connection.clientId) : '');
if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) {
continue;
}
return false;
}
return true;
}
}
function buildClientNameLockName(clientName) {
return `ps_client_name_${crypto.createHash('sha1').update(String(clientName || '').trim().toLowerCase()).digest('hex')}`;
}
async function withClientNameReservation(pool, clientName, handler) {
if (!pool || typeof pool.getConnection !== 'function') {
return handler();
}
const normalizedName = normalizeClientName(clientName);
if (!normalizedName) {
return handler();
}
const connection = await pool.getConnection();
const lockName = buildClientNameLockName(normalizedName);
let lockAcquired = false;
try {
const [lockRows] = await connection.query('SELECT GET_LOCK(?, 5) AS lock_result', [lockName]);
const lockResult = lockRows && lockRows[0] ? Number(lockRows[0].lock_result) : 0;
if (lockResult !== 1) {
const error = new Error('Client name is busy. Please try again.');
error.statusCode = 409;
throw error;
}
lockAcquired = true;
return await handler();
} finally {
if (lockAcquired) {
try {
await connection.query('SELECT RELEASE_LOCK(?)', [lockName]);
} catch (_error) {}
}
connection.release();
}
}
module.exports = {
normalizeClientName: normalizeClientName,
normalizeDeviceId: normalizeDeviceId,
collectLiveConnections: collectLiveConnections,
isClientNameAvailable: isClientNameAvailable,
withClientNameReservation: withClientNameReservation
};
+3 -1
View File
@@ -22,5 +22,7 @@ module.exports = {
extractTemplateRegions: data.extractTemplateRegions,
buildTemplatePayload: data.buildTemplatePayload,
mediaKind: player.mediaKind,
renderPlayerPage: player.renderPlayerPage
renderPlayerPage: player.renderPlayerPage,
renderPlayerOnboardingLandingPage: player.renderPlayerOnboardingLandingPage,
renderPlayerOnboardingFormPage: player.renderPlayerOnboardingFormPage
};
+1 -1
View File
@@ -2,7 +2,7 @@ async function fetchAdminData(pool) {
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,
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
+8 -2
View File
@@ -7,12 +7,18 @@ function slugify(value) {
.replace(/-{2,}/g, '-');
}
async function uniqueScreenSlug(pool, baseSlug) {
async function uniqueScreenSlug(pool, baseSlug, excludeId) {
const start = baseSlug || `screen-${Date.now()}`;
let candidate = start;
let counter = 2;
while (true) {
const [rows] = await pool.query('SELECT id FROM screens WHERE slug = ?', [candidate]);
const params = [candidate];
let sql = 'SELECT id FROM screens WHERE slug = ?';
if (excludeId !== undefined && excludeId !== null) {
sql += ' AND id <> ?';
params.push(excludeId);
}
const [rows] = await pool.query(sql, params);
if (!rows.length) {
return candidate;
}
+40 -3
View File
@@ -2,14 +2,44 @@ const { parseJsonSafe, readFormArray } = require('./utils');
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html'];
function sanitizeBackgroundColor(value) {
const raw = String(value || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
return raw;
}
return '#111111';
}
function normalizeTemplateRegionType(value) {
const rawType = String(value || 'text').trim();
return ALLOWED_TEMPLATE_REGION_TYPES.includes(rawType) ? rawType : 'text';
}
function normalizeTemplateRegionName(value) {
return String(value || '').trim();
}
function ensureUniqueTemplateRegionNames(regions) {
const seen = new Map();
for (let i = 0; i < regions.length; i += 1) {
const region = regions[i];
const regionName = normalizeTemplateRegionName(region.region_key || region.label);
if (!regionName) {
continue;
}
const normalized = regionName.toLowerCase();
if (seen.has(normalized)) {
const error = new Error('Region names must be unique on this template.');
error.statusCode = 400;
throw error;
}
seen.set(normalized, true);
}
}
async function fetchTemplateById(pool, id) {
const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at,
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
@@ -26,7 +56,7 @@ async function fetchTemplateById(pool, id) {
async function fetchTemplatesData(pool) {
const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at,
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
@@ -106,9 +136,13 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
let regions = extractTemplateRegions(req.body);
const filesByField = getFilesByField(req.files || []);
const backgroundImage = filesByField.background_image;
const removeBackgroundImage = Boolean(req.body.remove_background_image);
const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color));
const backgroundImagePath = backgroundImage
? `/uploads/${backgroundImage.filename}`
: String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null;
: removeBackgroundImage
? null
: String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null;
if (!name) {
const error = new Error('Template name is required.');
@@ -150,12 +184,15 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
}];
}
ensureUniqueTemplateRegionNames(regions);
return {
name,
canvasSizeId: resolvedCanvasSizeId,
canvasSizeWidth: canvasWidth,
canvasSizeHeight: canvasHeight,
backgroundImagePath,
backgroundColor,
regions
};
}
+499 -19
View File
@@ -1,5 +1,6 @@
const mysql = require('mysql2/promise');
const { hashPassword } = require('./auth');
const { PERMISSIONS, DEFAULT_ROLE, normalizePermissionKeys } = require('./rbac');
function createPool() {
return mysql.createPool({
@@ -31,6 +32,391 @@ async function addColumnIfMissing(pool, tableName, columnName, columnDefinition)
await pool.query(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${columnDefinition}`);
}
async function dropColumnIfPresent(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS column_count
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?
AND column_name = ?`,
[tableName, columnName]
);
if (!rows.length || Number(rows[0].column_count) === 0) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` DROP COLUMN \`${columnName}\``);
}
async function addForeignKeyIfMissing(pool, tableName, columnName, constraintName, referencedTable, referencedColumn, onDeleteAction) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS constraint_count
FROM information_schema.table_constraints
WHERE table_schema = DATABASE()
AND table_name = ?
AND constraint_name = ?`,
[tableName, constraintName]
);
if (rows.length && Number(rows[0].constraint_count) > 0) {
return;
}
await pool.query(
`ALTER TABLE \`${tableName}\`
ADD CONSTRAINT \`${constraintName}\`
FOREIGN KEY (\`${columnName}\`) REFERENCES \`${referencedTable}\`(\`${referencedColumn}\`)
ON DELETE ${onDeleteAction}
ON UPDATE CASCADE`
);
}
async function addUserAuditColumns(pool, tableName) {
await addColumnIfMissing(pool, tableName, 'created_by', 'INT NULL');
await addColumnIfMissing(pool, tableName, 'modified_by', 'INT NULL');
await pool.query(
`UPDATE \`${tableName}\` t
LEFT JOIN users created_user ON created_user.id = t.created_by
SET t.created_by = NULL
WHERE t.created_by IS NOT NULL
AND created_user.id IS NULL`
);
await pool.query(
`UPDATE \`${tableName}\` t
LEFT JOIN users modified_user ON modified_user.id = t.modified_by
SET t.modified_by = NULL
WHERE t.modified_by IS NOT NULL
AND modified_user.id IS NULL`
);
await addForeignKeyIfMissing(pool, tableName, 'created_by', `fk_${tableName}_created_by`, 'users', 'id', 'SET NULL');
await addForeignKeyIfMissing(pool, tableName, 'modified_by', `fk_${tableName}_modified_by`, 'users', 'id', 'SET NULL');
}
async function hasSingleColumnUniqueIndex(pool, tableName, columnName) {
const [rows] = await pool.query(
`SELECT INDEX_NAME, COUNT(*) AS column_count
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = ?
AND non_unique = 0
AND column_name = ?
GROUP BY INDEX_NAME`,
[tableName, columnName]
);
return (rows || []).some(function (row) {
return Number(row.column_count) === 1;
});
}
async function addUniqueIndexIfMissing(pool, tableName, columnName, indexName) {
const hasUniqueIndex = await hasSingleColumnUniqueIndex(pool, tableName, columnName);
if (hasUniqueIndex) {
return;
}
await pool.query(`ALTER TABLE \`${tableName}\` ADD UNIQUE KEY \`${indexName}\` (\`${columnName}\`)`);
}
async function dedupePermissionRows(pool) {
const [rows] = await pool.query('SELECT id, permission_key FROM permissions ORDER BY id ASC');
const canonicalIdByKey = new Map();
const duplicateRowsByKey = new Map();
for (const row of rows || []) {
const permissionKey = getPermissionKey(row);
const permissionId = Number(row.id);
if (!permissionKey || !Number.isInteger(permissionId) || permissionId <= 0) {
continue;
}
if (!canonicalIdByKey.has(permissionKey)) {
canonicalIdByKey.set(permissionKey, permissionId);
continue;
}
if (!duplicateRowsByKey.has(permissionKey)) {
duplicateRowsByKey.set(permissionKey, []);
}
duplicateRowsByKey.get(permissionKey).push(permissionId);
}
if (!duplicateRowsByKey.size) {
return;
}
for (const [permissionKey, duplicateIds] of duplicateRowsByKey.entries()) {
const canonicalId = canonicalIdByKey.get(permissionKey);
for (const duplicateId of duplicateIds) {
await pool.query(
'UPDATE IGNORE role_permissions SET permission_id = ? WHERE permission_id = ?',
[canonicalId, duplicateId]
);
}
}
const duplicateIds = [];
for (const duplicateList of duplicateRowsByKey.values()) {
duplicateIds.push.apply(duplicateIds, duplicateList);
}
if (duplicateIds.length) {
await pool.query('DELETE FROM permissions WHERE id IN (?)', [duplicateIds]);
}
}
async function pruneStaleOnboardingDevices(pool) {
await pool.query(
`DELETE FROM player_onboarding_devices
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
);
}
async function getTableColumnNames(pool, tableName) {
const [rows] = await pool.query(
`SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ?`,
[tableName]
);
return new Set((rows || []).map(function (row) {
return String(row.COLUMN_NAME || row.column_name || '').trim().toLowerCase();
}).filter(Boolean));
}
function getPermissionKey(row) {
return String((row && row.permission_key) || '').trim().toLowerCase();
}
function getLegacyPermissionTargets(permissionKey) {
const normalizedKey = String(permissionKey || '').trim().toLowerCase();
const parts = normalizedKey.split('.');
if (parts.length !== 2) {
return [normalizedKey].filter(Boolean);
}
const sectionKey = parts[0];
const actionKey = parts[1];
if (normalizedKey === 'screens.allow') {
return ['clients.allow'];
}
if (actionKey === 'edit') {
return [`${sectionKey}.update`];
}
if (actionKey === 'update') {
return [`${sectionKey}.update`];
}
if (actionKey === 'view') {
return [`${sectionKey}.read`];
}
if (actionKey === 'manage') {
return [`${sectionKey}.read`, `${sectionKey}.create`, `${sectionKey}.update`, `${sectionKey}.delete`];
}
return [normalizedKey].filter(Boolean);
}
function buildPermissionSeedColumns(columnNames) {
const columns = [];
if (columnNames.has('permission_key')) {
columns.push('permission_key');
}
if (columnNames.has('name')) {
columns.push('name');
}
if (columnNames.has('section_name')) {
columns.push('section_name');
}
if (columnNames.has('description')) {
columns.push('description');
}
if (columnNames.has('created_by')) {
columns.push('created_by');
}
if (columnNames.has('modified_by')) {
columns.push('modified_by');
}
return columns;
}
async function backfillLegacyRbacSchema(pool) {
const permissionColumnNames = await getTableColumnNames(pool, 'permissions');
const [permissionRows] = await pool.query('SELECT id, permission_key, name, section_name FROM permissions ORDER BY id ASC');
const [rolePermissionRows] = await pool.query(
`SELECT rp.role_id, p.permission_key
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id`
);
const permissionIdByKey = new Map();
for (const row of permissionRows || []) {
const currentKey = getPermissionKey(row);
if (currentKey) {
permissionIdByKey.set(currentKey, Number(row.id));
}
}
const rolePermissionTargets = new Map();
const desiredPermissionKeys = new Set(PERMISSIONS.map(function (permission) {
return permission.key;
}));
const legacyPermissionRowIds = [];
function addRoleTarget(roleId, permissionKey) {
const normalizedPermissionKey = String(permissionKey || '').trim().toLowerCase();
if (!normalizedPermissionKey) {
return;
}
if (!rolePermissionTargets.has(roleId)) {
rolePermissionTargets.set(roleId, new Set());
}
rolePermissionTargets.get(roleId).add(normalizedPermissionKey);
}
for (const row of rolePermissionRows || []) {
const currentKey = getPermissionKey(row);
const targetKeys = getLegacyPermissionTargets(currentKey);
if (currentKey.endsWith('.view') || currentKey.endsWith('.manage') || currentKey.endsWith('.edit') || currentKey === 'screens.allow') {
for (const targetKey of targetKeys) {
addRoleTarget(Number(row.role_id), targetKey);
}
} else {
addRoleTarget(Number(row.role_id), currentKey);
}
}
for (const row of permissionRows || []) {
const currentKey = getPermissionKey(row);
const permissionId = Number(row.id);
if (currentKey.endsWith('.edit')) {
const targetKey = currentKey.replace(/\.edit$/, '.update');
const targetPermissionId = permissionIdByKey.get(targetKey);
if (targetPermissionId) {
legacyPermissionRowIds.push(permissionId);
} else if (targetKey) {
await pool.query('UPDATE permissions SET permission_key = ? WHERE id = ?', [targetKey, permissionId]);
permissionIdByKey.delete(currentKey);
permissionIdByKey.set(targetKey, permissionId);
}
continue;
}
if (currentKey.endsWith('.view') || currentKey.endsWith('.manage') || currentKey.endsWith('.edit') || currentKey === 'screens.allow') {
legacyPermissionRowIds.push(permissionId);
}
}
const roleRows = await pool.query('SELECT id, role_key, name FROM roles ORDER BY id ASC').then(function (result) {
return result[0] || [];
});
const defaultRoleRow = roleRows.find(function (row) {
return String(row.role_key || '').trim().toLowerCase() === DEFAULT_ROLE.key;
}) || null;
if (defaultRoleRow) {
if (!rolePermissionTargets.has(Number(defaultRoleRow.id))) {
rolePermissionTargets.set(Number(defaultRoleRow.id), new Set());
}
const defaultPermissions = rolePermissionTargets.get(Number(defaultRoleRow.id));
for (const permission of PERMISSIONS) {
defaultPermissions.add(permission.key);
}
}
const seedColumns = buildPermissionSeedColumns(permissionColumnNames);
if (!seedColumns.length) {
throw new Error('permissions table is missing required columns.');
}
for (const permission of PERMISSIONS) {
const seedValues = [];
if (permissionColumnNames.has('permission_key')) {
seedValues.push(permission.key);
}
if (permissionColumnNames.has('name')) {
seedValues.push(permission.name);
}
if (permissionColumnNames.has('section_name')) {
seedValues.push(permission.sectionName);
}
if (permissionColumnNames.has('description')) {
seedValues.push(permission.description || null);
}
if (permissionColumnNames.has('created_by')) {
seedValues.push(null);
}
if (permissionColumnNames.has('modified_by')) {
seedValues.push(null);
}
const updateAssignments = [];
if (permissionColumnNames.has('name')) {
updateAssignments.push('name = VALUES(name)');
}
if (permissionColumnNames.has('section_name')) {
updateAssignments.push('section_name = VALUES(section_name)');
}
if (permissionColumnNames.has('description')) {
updateAssignments.push('description = VALUES(description)');
}
if (permissionColumnNames.has('permission_key')) {
updateAssignments.push('permission_key = VALUES(permission_key)');
}
await pool.query(
`INSERT INTO permissions (${seedColumns.join(', ')})
VALUES (${seedColumns.map(function () { return '?'; }).join(', ')})
ON DUPLICATE KEY UPDATE ${updateAssignments.join(', ')}`,
seedValues
);
}
if (legacyPermissionRowIds.length) {
await pool.query('DELETE FROM permissions WHERE id IN (?)', [legacyPermissionRowIds]);
}
const [currentPermissionRows] = await pool.query('SELECT id, permission_key FROM permissions');
const permissionIdByKeyAfterBackfill = new Map();
for (const row of currentPermissionRows || []) {
const currentKey = getPermissionKey(row);
if (currentKey) {
permissionIdByKeyAfterBackfill.set(currentKey, Number(row.id));
}
}
await pool.query('DELETE FROM role_permissions');
for (const [roleId, permissionKeys] of rolePermissionTargets.entries()) {
const expandedPermissionKeys = normalizePermissionKeys(Array.from(permissionKeys.values()));
for (const permissionKey of expandedPermissionKeys) {
const permissionId = permissionIdByKeyAfterBackfill.get(permissionKey);
if (!permissionId) {
continue;
}
await pool.query(
'INSERT IGNORE INTO role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)',
[Number(roleId), permissionId, null, null]
);
}
}
const [roleRowsAfter] = await pool.query('SELECT id, role_key, name FROM roles ORDER BY id ASC');
for (const row of roleRowsAfter || []) {
const currentKey = String(row.role_key || '').trim();
const isAdministratorsRole = String(row.name || '').trim().toLowerCase() === DEFAULT_ROLE.name.toLowerCase();
const expectedKey = isAdministratorsRole ? DEFAULT_ROLE.key : `role-${row.id}`;
if (!currentKey || currentKey !== expectedKey) {
await pool.query(
'UPDATE roles SET role_key = ?, name = ?, description = COALESCE(description, ?) WHERE id = ?',
[expectedKey, String(row.name || '').trim() || DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, row.id]
);
}
}
}
async function ensureSchema(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS canvas_sizes (
@@ -44,8 +430,7 @@ async function ensureSchema(pool) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'canvas_sizes', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_by', 'INT NULL');
await addUserAuditColumns(pool, 'canvas_sizes');
await pool.query(`
CREATE TABLE IF NOT EXISTS playlists (
@@ -58,8 +443,7 @@ async function ensureSchema(pool) {
`);
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'playlists', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'playlists', 'modified_by', 'INT NULL');
await addUserAuditColumns(pool, 'playlists');
await pool.query(`
CREATE TABLE IF NOT EXISTS slide_templates (
@@ -67,6 +451,7 @@ async function ensureSchema(pool) {
name VARCHAR(255) NOT NULL,
canvas_size_id INT NULL,
background_image_path VARCHAR(512) NULL,
background_color VARCHAR(32) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
@@ -74,9 +459,9 @@ async function ensureSchema(pool) {
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'slide_templates', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'slide_templates', 'modified_by', 'INT NULL');
await addUserAuditColumns(pool, 'slide_templates');
await pool.query(`
INSERT IGNORE INTO canvas_sizes (name, width, height) VALUES
@@ -122,8 +507,7 @@ async function ensureSchema(pool) {
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'slide_template_regions', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_by', 'INT NULL');
await addUserAuditColumns(pool, 'slide_template_regions');
await pool.query(`
CREATE TABLE IF NOT EXISTS slides (
@@ -145,8 +529,7 @@ async function ensureSchema(pool) {
await addColumnIfMissing(pool, 'slides', 'media_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slides', 'media_type', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'slides', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'slides', 'modified_by', 'INT NULL');
await addUserAuditColumns(pool, 'slides');
await pool.query(`
CREATE TABLE IF NOT EXISTS playlist_slides (
@@ -176,8 +559,7 @@ async function ensureSchema(pool) {
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_time', 'TIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_days_json', 'JSON NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'playlist_slides', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'modified_by', 'INT NULL');
await addUserAuditColumns(pool, 'playlist_slides');
await pool.query(`
CREATE TABLE IF NOT EXISTS screens (
@@ -193,8 +575,22 @@ async function ensureSchema(pool) {
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'screens', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'screens', 'modified_by', 'INT NULL');
await addUserAuditColumns(pool, 'screens');
await pool.query(`
CREATE TABLE IF NOT EXISTS player_onboarding_devices (
device_id VARCHAR(128) PRIMARY KEY,
client_name VARCHAR(255) NULL,
screen_id INT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_player_onboarding_devices_screen FOREIGN KEY (screen_id) REFERENCES screens(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'player_onboarding_devices');
await pool.query(`
CREATE TABLE IF NOT EXISTS users (
@@ -213,8 +609,71 @@ async function ensureSchema(pool) {
await addColumnIfMissing(pool, 'users', 'password_salt', 'VARCHAR(64) NOT NULL');
await addColumnIfMissing(pool, 'users', 'password_iterations', 'INT NOT NULL');
await addColumnIfMissing(pool, 'users', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addColumnIfMissing(pool, 'users', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'users', 'modified_by', 'INT NULL');
await addUserAuditColumns(pool, 'users');
await pool.query(`
CREATE TABLE IF NOT EXISTS roles (
id INT AUTO_INCREMENT PRIMARY KEY,
role_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'roles', 'role_key', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'roles', 'description', 'TEXT NULL');
await addColumnIfMissing(pool, 'roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'roles');
await pool.query(`
CREATE TABLE IF NOT EXISTS permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
permission_key VARCHAR(100) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
section_name VARCHAR(255) NOT NULL,
description TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'permissions', 'permission_key', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'permissions', 'name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'permissions', 'section_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'permissions', 'description', 'TEXT NULL');
await addColumnIfMissing(pool, 'permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'permissions');
await dropColumnIfPresent(pool, 'permissions', 'perm_key');
await dedupePermissionRows(pool);
await addUniqueIndexIfMissing(pool, 'permissions', 'permission_key', 'uq_permissions_permission_key');
await pool.query(`
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INT NOT NULL,
permission_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (role_id, permission_id),
CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'role_permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'role_permissions');
await pool.query(`
CREATE TABLE IF NOT EXISTS user_roles (
user_id INT NOT NULL,
role_id INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, role_id),
CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'user_roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addUserAuditColumns(pool, 'user_roles');
await pool.query(`
CREATE TABLE IF NOT EXISTS auth_sessions (
@@ -226,8 +685,7 @@ async function ensureSchema(pool) {
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await addColumnIfMissing(pool, 'auth_sessions', 'created_by', 'INT NULL');
await addColumnIfMissing(pool, 'auth_sessions', 'modified_by', 'INT NULL');
await addUserAuditColumns(pool, 'auth_sessions');
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
@@ -242,9 +700,31 @@ async function ensureSchema(pool) {
}
await pool.query('UPDATE users SET name = username WHERE name IS NULL OR name = ""');
await pool.query(
`INSERT INTO roles (role_key, name, description, created_by, modified_by)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`,
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
);
await backfillLegacyRbacSchema(pool);
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null;
if (defaultRoleId) {
await pool.query(
`INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by)
SELECT id, ?, NULL, NULL FROM users`,
[defaultRoleId]
);
}
}
}
module.exports = {
createPool,
ensureSchema
ensureSchema,
pruneStaleOnboardingDevices
};
+64 -530
View File
@@ -1,430 +1,51 @@
const express = require('express');
const fs = require('fs');
const http = require('http');
const crypto = require('crypto');
const path = require('path');
const { WebSocketServer, WebSocket } = require('ws');
const common = require('./common');
async function buildScreenPlaylist(pool, 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: [],
revision: getPlaylistRevision(screen, null, [], [], [])
};
}
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,
ps.position, ps.duration_seconds AS duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
st.name AS template_name, 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 playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE ps.playlist_id = ?
ORDER BY ps.position ASC, ps.id ASC
`, [screen.playlist_id]);
const templateIds = slideRows
.filter(function (slide) { return slide.template_id; })
.map(function (slide) { return slide.template_id; });
const templatesById = {};
let templateRows = [];
let regionRows = [];
if (templateIds.length) {
[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]);
[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;
});
}
const slides = slideRows.map(function (slide) {
return {
id: slide.id,
title: slide.title,
body: slide.body,
duration_seconds: slide.duration_seconds,
schedule_mode: slide.schedule_mode,
schedule_start_datetime: slide.schedule_start_datetime,
schedule_end_datetime: slide.schedule_end_datetime,
schedule_start_time: slide.schedule_start_time,
schedule_end_time: slide.schedule_end_time,
schedule_days_json: slide.schedule_days_json,
media_url: slide.media_path,
media_type: slide.media_type,
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) || {}
};
});
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');
}
const { createPlayerRuntime } = require('./player/runtime');
const { createPlayerPlaylistService } = require('./player/playlist');
const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding');
const { createOnboardingStore } = require('./player/onboarding-store');
const { registerPlayerRoutes } = require('./player/routes');
const { pruneStaleOnboardingDevices } = require('./db');
// Player runtime, upload API, and websocket wiring.
async function start() {
const app = express();
const pool = common.createPool();
const PORT = Number(process.env.PLAYER_PORT || 3001);
const ASSET_DIR = path.join(__dirname, 'player', 'public');
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
const connectionsBySlug = new Map();
const dashboardListenersBySlug = new Map();
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
const ONBOARDING_QUEUE_FILE = path.join(UPLOAD_DIR, 'player-onboarding-queue.json');
const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000);
const onboardingStore = createOnboardingStore(ONBOARDING_QUEUE_FILE);
const playerRuntime = createPlayerRuntime({
pool: pool,
normalizeDeviceId: normalizeDeviceId
});
const playerPlaylistService = createPlayerPlaylistService({
pool: pool,
common: common
});
const server = http.createServer(app);
const wss = new WebSocketServer({ noServer: true });
playerRuntime.installWebsocket(server);
app.use(express.json());
app.use('/assets', express.static(ASSET_DIR));
app.use('/uploads', express.static(UPLOAD_DIR));
function getConnectionBucket(slug) {
const key = String(slug || '').trim();
if (!key) {
return null;
}
if (!connectionsBySlug.has(key)) {
connectionsBySlug.set(key, new Map());
}
return connectionsBySlug.get(key);
}
function removeConnection(slug, connectionId) {
const bucket = connectionsBySlug.get(slug);
if (!bucket) {
return;
}
bucket.delete(connectionId);
if (!bucket.size) {
connectionsBySlug.delete(slug);
}
}
function getDashboardListenerBucket(slug) {
const key = String(slug || '').trim();
if (!key) {
return null;
}
if (!dashboardListenersBySlug.has(key)) {
dashboardListenersBySlug.set(key, new Set());
}
return dashboardListenersBySlug.get(key);
}
function removeDashboardListener(slug, socket) {
const key = String(slug || '').trim();
const bucket = dashboardListenersBySlug.get(key);
if (!bucket) {
return;
}
bucket.delete(socket);
if (!bucket.size) {
dashboardListenersBySlug.delete(key);
}
}
function buildClientLabel(connection) {
const clientId = String(connection.clientId || '').trim();
const userAgent = String(connection.userAgent || '').trim();
const clientIp = String(connection.clientIp || '').trim();
const viewport = connection.viewport && typeof connection.viewport === 'object'
? connection.viewport
: null;
const labelParts = [];
if (userAgent) {
labelParts.push(userAgent.length > 72 ? `${userAgent.slice(0, 72)}...` : userAgent);
}
if (clientId) {
labelParts.push(`id ${clientId.slice(-6)}`);
}
if (clientIp) {
labelParts.push(clientIp);
}
if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) {
labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`);
}
if (!labelParts.length) {
return connection.remoteAddress || 'connected client';
}
return labelParts.join(' • ');
}
function snapshotConnections(slug) {
const bucket = connectionsBySlug.get(String(slug || '').trim());
if (!bucket) {
return [];
}
return Array.from(bucket.values()).map(function (connection) {
return {
id: connection.id,
clientId: connection.clientId || null,
label: connection.label,
userAgent: connection.userAgent || null,
viewport: connection.viewport || null,
page: connection.page || null,
currentSlide: connection.currentSlide || null,
paused: Boolean(connection.paused),
blackout: Boolean(connection.blackout),
currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null,
currentSlideTitle: connection.currentSlide && connection.currentSlide.title ? connection.currentSlide.title : null,
clientIp: connection.clientIp || null,
remoteAddress: connection.remoteAddress || null,
connectedAt: connection.connectedAt ? connection.connectedAt.toISOString() : null,
lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null
};
});
}
function broadcastConnectionSnapshot(slug) {
const key = String(slug || '').trim();
const bucket = dashboardListenersBySlug.get(key);
if (!bucket || !bucket.size) {
return;
}
const payload = JSON.stringify({
type: 'snapshot',
slug: key,
connections: snapshotConnections(slug),
sentAt: new Date().toISOString()
});
bucket.forEach(function (socket) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(payload);
}
});
}
function sendCommandToConnection(slug, connectionId, commandOrPayload) {
const bucket = connectionsBySlug.get(String(slug || '').trim());
if (!bucket || !bucket.size) {
return 0;
}
const target = bucket.get(String(connectionId || '').trim());
if (!target || target.socket.readyState !== WebSocket.OPEN) {
return 0;
}
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
? Object.assign({}, commandOrPayload)
: { command: commandOrPayload };
payload.type = 'command';
payload.targetConnectionId = target.id;
payload.sentAt = new Date().toISOString();
target.socket.send(JSON.stringify(payload));
return 1;
}
function broadcastCommand(slug, commandOrPayload) {
const bucket = connectionsBySlug.get(String(slug || '').trim());
if (!bucket || !bucket.size) {
return 0;
}
let sent = 0;
bucket.forEach(function (connection) {
if (connection.socket.readyState !== WebSocket.OPEN) {
return;
}
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
? Object.assign({}, commandOrPayload)
: { command: commandOrPayload };
payload.type = 'command';
payload.sentAt = new Date().toISOString();
connection.socket.send(JSON.stringify(payload));
sent += 1;
});
return sent;
}
app.get('/', function (_req, res) {
res.send('Pulse Signage player service');
registerPlayerOnboardingRoutes(app, {
pool: pool,
common: common,
playerRuntime: playerRuntime,
onboardingStore: onboardingStore,
QRCode: require('qrcode')
});
app.get('/screen/:slug', function (req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
buildScreenPlaylist(pool, req.params.slug).then(function (data) {
res.send(common.renderPlayerPage(req.params.slug, data));
}).catch(function (error) {
console.error(error);
res.status(500).send('Internal server error');
});
});
app.get('/api/screens/:slug/playlist', async function (req, res, next) {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
const data = await buildScreenPlaylist(pool, req.params.slug);
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);
}
});
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], async function (req, res, next) {
try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
if (!screenRows.length) {
return res.status(404).json({ error: 'Screen not found' });
}
const connections = snapshotConnections(req.params.slug);
res.json({
screen: screenRows[0],
screenSlug: req.params.slug,
count: connections.length,
connections: connections
});
} catch (error) {
next(error);
}
});
app.post('/api/screens/:slug/commands', async function (req, res, next) {
try {
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
? req.body.blackout
: req.query.blackout;
if (!command) {
return res.status(400).json({ error: 'Command is required' });
}
if (['refresh', 'reload', 'pause', 'blackout', 'previous', 'next', 'left', 'right'].indexOf(command) === -1) {
return res.status(400).json({ error: 'Unsupported command' });
}
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
if (!screenRows.length) {
return res.status(404).json({ error: 'Screen not found' });
}
const commandPayload = command === 'blackout' && blackoutValue !== undefined
? {
command: command,
blackout: blackoutValue
}
: command;
const sent = connectionId
? sendCommandToConnection(req.params.slug, connectionId, commandPayload)
: broadcastCommand(req.params.slug, commandPayload);
res.json({
screen: screenRows[0],
screenSlug: req.params.slug,
command: command,
connectionId: connectionId || null,
sent: sent
});
} catch (error) {
next(error);
}
registerPlayerRoutes(app, {
pool: pool,
common: common,
uploadDir: UPLOAD_DIR,
assetDir: ASSET_DIR,
playerRuntime: playerRuntime,
playerPlaylistService: playerPlaylistService
});
app.use(function (error, _req, res, _next) {
@@ -432,129 +53,41 @@ async function start() {
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
});
await common.ensureSchema(pool);
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
server.on('upgrade', function (request, socket, head) {
let pathname = '';
try {
pathname = new URL(request.url, 'http://localhost').pathname;
} catch (_error) {
socket.destroy();
return;
}
const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/);
const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/);
if (!dashboardMatch && !playerMatch) {
socket.destroy();
return;
}
const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]);
wss.handleUpgrade(request, socket, head, function (ws) {
wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : 'player');
});
});
wss.on('connection', function (socket, request, slug, role) {
if (role === 'dashboard') {
const listenerBucket = getDashboardListenerBucket(slug);
if (!listenerBucket) {
socket.close();
return;
}
listenerBucket.add(socket);
socket.send(JSON.stringify({
type: 'snapshot',
slug: String(slug || '').trim(),
connections: snapshotConnections(slug),
sentAt: new Date().toISOString()
}));
socket.on('close', function () {
removeDashboardListener(slug, socket);
});
socket.on('error', function () {
removeDashboardListener(slug, socket);
});
return;
}
const remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null;
const forwardedFor = String(request.headers['x-forwarded-for'] || '').split(',')[0].trim();
const connectionId = crypto.randomUUID();
const connection = {
id: connectionId,
slug: slug,
socket: socket,
clientId: null,
userAgent: null,
viewport: null,
page: null,
paused: false,
blackout: false,
clientIp: forwardedFor || remoteAddress,
remoteAddress: remoteAddress,
label: forwardedFor || remoteAddress || 'connected client',
connectedAt: new Date(),
lastSeenAt: new Date()
};
const bucket = getConnectionBucket(slug);
if (!bucket) {
socket.close();
return;
}
bucket.set(connectionId, connection);
socket.on('message', function (rawMessage) {
connection.lastSeenAt = new Date();
let payload = null;
try {
payload = JSON.parse(String(rawMessage || ''));
} catch (_error) {
return;
}
if (!payload || (payload.type !== 'hello' && payload.type !== 'state')) {
return;
}
connection.clientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId;
connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent;
connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport;
connection.page = payload.page ? String(payload.page).trim() : connection.page;
connection.paused = Boolean(payload.paused);
connection.blackout = Boolean(payload.blackout);
connection.clientIp = payload.clientIp ? String(payload.clientIp).trim() : connection.clientIp;
connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? {
id: payload.currentSlide.id || null,
title: payload.currentSlide.title || '',
kind: payload.currentSlide.kind || '',
playlistSignature: payload.currentSlide.playlistSignature || ''
} : connection.currentSlide;
connection.label = buildClientLabel(connection);
connection.lastSeenAt = new Date();
broadcastConnectionSnapshot(slug);
});
socket.on('close', function () {
removeConnection(slug, connectionId);
broadcastConnectionSnapshot(slug);
});
socket.on('error', function () {
removeConnection(slug, connectionId);
broadcastConnectionSnapshot(slug);
});
});
server.listen(PORT, function () {
console.log(`Pulse Signage app listening on port ${PORT}`);
});
async function syncDatabaseState() {
try {
await common.ensureSchema(pool);
if (playerRuntime.snapshotAllConnections().length > 0) {
await pruneStaleOnboardingDevices(pool);
}
await onboardingStore.flushBindings(function (entry) {
return commitDeviceBinding(
pool,
entry.deviceId,
entry.clientName,
entry.screenSlug,
playerRuntime.isClientNameAvailableOnScreen,
playerRuntime.snapshotAllConnections()
);
});
} catch (error) {
console.error(error);
}
}
await syncDatabaseState();
setInterval(function () {
syncDatabaseState().catch(function (error) {
console.error(error);
});
}, DB_SYNC_INTERVAL_MS);
}
module.exports = { start };
@@ -565,3 +98,4 @@ if (require.main === module) {
process.exit(1);
});
}
+98
View File
@@ -0,0 +1,98 @@
const fs = require('fs');
const path = require('path');
function isTransientDbError(error) {
const code = String(error && error.code ? error.code : '').trim();
return [
'ECONNREFUSED',
'ECONNRESET',
'ETIMEDOUT',
'EPIPE',
'ENOTFOUND',
'PROTOCOL_CONNECTION_LOST',
'POOL_CLOSED',
'ERR_POOL_CLOSED'
].indexOf(code) !== -1;
}
function createOnboardingStore(filePath) {
const normalizedFilePath = String(filePath || '').trim();
async function readEntries() {
try {
const raw = await fs.promises.readFile(normalizedFilePath, 'utf8');
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
if (error && error.code === 'ENOENT') {
return [];
}
throw error;
}
}
async function writeEntries(entries) {
await fs.promises.mkdir(path.dirname(normalizedFilePath), { recursive: true });
const tempPath = `${normalizedFilePath}.tmp`;
await fs.promises.writeFile(tempPath, JSON.stringify(Array.isArray(entries) ? entries : [], null, 2), 'utf8');
await fs.promises.rename(tempPath, normalizedFilePath);
}
async function enqueueBinding(entry) {
const normalizedEntry = {
deviceId: String(entry && entry.deviceId ? entry.deviceId : '').trim(),
clientName: String(entry && entry.clientName ? entry.clientName : '').trim(),
screenSlug: String(entry && entry.screenSlug ? entry.screenSlug : '').trim(),
queuedAt: String(entry && entry.queuedAt ? entry.queuedAt : new Date().toISOString())
};
if (!normalizedEntry.deviceId || !normalizedEntry.clientName || !normalizedEntry.screenSlug) {
return readEntries();
}
const entries = await readEntries();
const nextEntries = entries.filter(function (queuedEntry) {
return String(queuedEntry && queuedEntry.deviceId ? queuedEntry.deviceId : '').trim() !== normalizedEntry.deviceId;
});
nextEntries.push(normalizedEntry);
await writeEntries(nextEntries);
return nextEntries;
}
async function flushBindings(applyBinding) {
const entries = await readEntries();
if (!entries.length) {
return { flushed: 0, remaining: 0 };
}
const remaining = [];
let flushed = 0;
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index];
try {
await applyBinding(entry);
flushed += 1;
} catch (error) {
if (isTransientDbError(error)) {
remaining.push.apply(remaining, entries.slice(index));
break;
}
remaining.push.apply(remaining, entries.slice(index + 1));
}
}
await writeEntries(remaining);
return { flushed: flushed, remaining: remaining.length };
}
return {
enqueueBinding: enqueueBinding,
flushBindings: flushBindings,
readEntries: readEntries
};
}
module.exports = {
createOnboardingStore: createOnboardingStore,
isTransientDbError: isTransientDbError
};
+205
View File
@@ -0,0 +1,205 @@
const { isClientNameAvailable, withClientNameReservation } = require('../client-name-check');
const { isTransientDbError } = require('./onboarding-store');
function normalizeDeviceId(value) {
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
}
function getPublicBaseUrl(req) {
const configured = String(process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
if (configured) {
return configured;
}
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
const protocol = forwardedProto || (req.socket && req.socket.encrypted ? 'https' : 'http');
const forwardedHost = String(req.headers['x-forwarded-host'] || '').trim().split(',')[0];
const host = forwardedHost || String(req.headers.host || '').trim();
return `${protocol}://${host}`.replace(/\/$/, '');
}
async function getOnboardingStatus(pool, deviceId) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
if (!normalizedDeviceId) {
return null;
}
const [rows] = await pool.query(
`SELECT d.device_id, d.client_name, d.screen_id, s.name AS screen_name, s.slug AS screen_slug, s.playlist_id
FROM player_onboarding_devices d
LEFT JOIN screens s ON s.id = d.screen_id
WHERE d.device_id = ?`,
[normalizedDeviceId]
);
return rows[0] || null;
}
async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections) {
const normalizedDeviceId = normalizeDeviceId(deviceId);
const normalizedClientName = String(clientName || '').trim();
const normalizedScreenSlug = String(screenSlug || '').trim();
if (!normalizedDeviceId) {
throw new Error('Device ID is required.');
}
if (!normalizedClientName) {
throw new Error('Client name is required.');
}
if (!normalizedScreenSlug) {
throw new Error('Screen is required.');
}
return withClientNameReservation(pool, normalizedClientName, async function () {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]);
if (!screenRows.length) {
throw new Error('Screen not found.');
}
const screen = screenRows[0];
const available = await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections);
if (!available) {
const error = new Error('Client name already exists.');
error.statusCode = 400;
throw error;
}
await pool.query(
'INSERT INTO player_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP',
[normalizedDeviceId, normalizedClientName, screen.id]
);
return getOnboardingStatus(pool, normalizedDeviceId);
});
}
async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) {
const liveConnections = playerRuntime && typeof playerRuntime.snapshotAllConnections === 'function'
? playerRuntime.snapshotAllConnections()
: [];
try {
return await commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, liveConnections);
} catch (error) {
if (!isTransientDbError(error)) {
throw error;
}
if (onboardingStore && typeof onboardingStore.enqueueBinding === 'function') {
await onboardingStore.enqueueBinding({
deviceId: deviceId,
clientName: clientName,
screenSlug: screenSlug,
queuedAt: new Date().toISOString()
});
}
return {
device_id: normalizeDeviceId(deviceId),
client_name: String(clientName || '').trim(),
screen_slug: String(screenSlug || '').trim(),
queued: true
};
}
}
function registerPlayerOnboardingRoutes(app, options) {
const pool = options && options.pool ? options.pool : null;
const common = options && options.common ? options.common : null;
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const QRCode = options && options.QRCode ? options.QRCode : null;
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
if (!app || !pool || !common || !playerRuntime || !QRCode) {
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, playerRuntime, and QRCode.');
}
app.get('/', function (_req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.send(common.renderPlayerOnboardingLandingPage());
});
app.get('/onboard', function (req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || '').trim()));
});
app.get('/api/onboarding/status', async function (req, res, next) {
try {
const status = await getOnboardingStatus(pool, req.query.deviceId);
res.json({
deviceId: normalizeDeviceId(req.query.deviceId),
onboarded: Boolean(status && status.screen_id),
clientName: status ? status.client_name : null,
screenId: status ? status.screen_id : null,
screenSlug: status ? status.screen_slug : null,
screenName: status ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : null
});
} catch (error) {
next(error);
}
});
app.get('/api/onboarding/screens', async function (_req, res, next) {
try {
const [rows] = await pool.query('SELECT id, name, slug FROM screens ORDER BY name ASC, id ASC');
res.json({ screens: rows });
} catch (error) {
next(error);
}
});
app.get('/api/onboarding/qr', async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.query.deviceId);
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
const onboardingUrl = `${getPublicBaseUrl(req)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
const svg = await QRCode.toString(onboardingUrl, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store');
res.send(svg);
} catch (error) {
next(error);
}
});
app.post('/api/onboarding', async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.body && req.body.deviceId);
const clientName = String((req.body && req.body.clientName) || '').trim();
const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
if (!clientName) {
return res.status(400).json({ error: 'Client name is required' });
}
if (!screenSlug) {
return res.status(400).json({ error: 'Screen is required' });
}
const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
res.json({
deviceId: deviceId,
clientName: status ? status.client_name : clientName,
screenId: status && status.screen_id ? status.screen_id : null,
screenSlug: status ? status.screen_slug : screenSlug,
screenName: status && status.screen_name ? status.screen_name : null,
playerUrl: status && status.screen_slug ? `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(status.screen_slug)}` : `${getPublicBaseUrl(req)}/screen/${encodeURIComponent(screenSlug)}`,
queued: Boolean(status && status.queued)
});
} catch (error) {
next(error);
}
});
}
module.exports = {
normalizeDeviceId: normalizeDeviceId,
getPublicBaseUrl: getPublicBaseUrl,
getOnboardingStatus: getOnboardingStatus,
commitDeviceBinding: commitDeviceBinding,
bindDeviceToScreen: bindDeviceToScreen,
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
};
+98
View File
@@ -0,0 +1,98 @@
<script>
let onboardingClientName = null;
let onboardingClientNameSyncPromise = null;
const onboardingClientNameStorageKey = 'pulse-signage-player-client-name';
const onboardingDeviceIdStorageKey = 'pulse-signage-player-device-id';
function getOnboardingDeviceId() {
try {
var storedDeviceId = window.localStorage.getItem(onboardingDeviceIdStorageKey) || '';
return String(storedDeviceId || '').trim();
} catch (_error) {
return '';
}
}
// Return the onboarding client name when one was assigned, otherwise a stable client id.
function getOnboardingClientName() {
if (onboardingClientName) {
return onboardingClientName;
}
try {
var storedClientName = window.localStorage.getItem(onboardingClientNameStorageKey);
if (storedClientName) {
onboardingClientName = storedClientName;
try {
window.localStorage.setItem('pulse-signage-player-client-name', storedClientName);
} catch (_mirrorError) {
// ignore storage errors
}
return onboardingClientName;
}
var genericClientName = window.localStorage.getItem('pulse-signage-player-client-name');
if (genericClientName) {
onboardingClientName = genericClientName;
try {
window.localStorage.setItem(onboardingClientNameStorageKey, genericClientName);
} catch (_error) {
// ignore storage errors
}
return onboardingClientName;
}
} catch (_error) {
// fall through to client id generation
}
return '';
}
function applyOnboardingClientName(renamedClientName, socket) {
var normalizedName = String(renamedClientName || '').trim();
if (!normalizedName) {
return;
}
onboardingClientName = normalizedName;
try {
window.localStorage.setItem('pulse-signage-player-client-name', normalizedName);
window.localStorage.setItem(onboardingClientNameStorageKey, normalizedName);
} catch (_error) {
// ignore storage errors
}
if (socket && socket.readyState === WebSocket.OPEN) {
sendCommandHello(socket);
}
}
function syncOnboardingClientNameFromServer(socket) {
var deviceId = getOnboardingDeviceId();
if (!deviceId) {
return Promise.resolve(getOnboardingClientName());
}
if (onboardingClientNameSyncPromise) {
return onboardingClientNameSyncPromise;
}
onboardingClientNameSyncPromise = fetch('/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId), {
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
return null;
}
return response.json().catch(function () {
return null;
});
}).then(function (payload) {
var serverName = payload && payload.clientName ? String(payload.clientName).trim() : '';
if (serverName) {
applyOnboardingClientName(serverName, null);
}
return onboardingClientName || getOnboardingClientName();
}).catch(function () {
return onboardingClientName || getOnboardingClientName();
}).finally(function () {
onboardingClientNameSyncPromise = null;
});
return onboardingClientNameSyncPromise;
}
</script>
@@ -0,0 +1,88 @@
<script>
(function () {
var deviceKey = "pulse-signage-player-device-id";
var clientNameKey = "pulse-signage-player-client-name";
var screenKey = "pulse-signage-player-screen-slug";
var deviceId = {{DEVICE_ID_JSON}};
var form = document.getElementById("onboarding-form");
var message = document.getElementById("onboarding-message");
var screenSelect = document.getElementById("onboarding-screen-select");
function setMessage(value) { if (message) { message.textContent = value || ""; } }
function parseResponseError(response) {
return response.text().then(function (text) {
var fallbackMessage = text && text.trim() ? text.trim() : "Unable to save onboarding.";
try {
var payload = JSON.parse(text);
return payload && payload.error ? payload.error : fallbackMessage;
} catch (_error) {
return fallbackMessage;
}
});
}
function loadScreens() {
return fetch("/api/onboarding/screens", { cache: "no-store" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (payload) {
var screens = payload && Array.isArray(payload.screens) ? payload.screens : [];
if (!screenSelect) { return screens; }
while (screenSelect.firstChild) { screenSelect.removeChild(screenSelect.firstChild); }
var placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "Select a screen";
screenSelect.appendChild(placeholder);
screens.forEach(function (screen) {
var option = document.createElement("option");
option.value = String(screen && screen.slug ? screen.slug : "");
option.textContent = String(screen && (screen.name || screen.slug) ? (screen.name || screen.slug) : "Screen");
screenSelect.appendChild(option);
});
return screens;
});
}
if (!deviceId) { setMessage("Missing device id. Scan the QR code from the player screen again."); return; }
try { window.localStorage.setItem(deviceKey, deviceId); } catch (_error) {}
loadScreens().then(function () {
try {
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
var clientNameInput = form.querySelector("input[name=\"clientName\"]");
if (clientNameInput && storedClientName) { clientNameInput.value = storedClientName; }
if (screenSelect && storedScreenSlug) { screenSelect.value = storedScreenSlug; }
} catch (_error) {}
});
form.addEventListener("submit", function (event) {
event.preventDefault();
var formData = new FormData(form);
var clientName = String(formData.get("clientName") || "").trim();
var screenSlug = String(formData.get("screenSlug") || "").trim();
if (!clientName) { setMessage("Client name is required."); return; }
if (!screenSlug) { setMessage("Screen is required."); return; }
setMessage("Saving client...");
fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug })
})
.then(function (response) {
if (response.ok) {
return response.json();
}
return parseResponseError(response).then(function (messageText) {
throw new Error(messageText);
});
})
.then(function (payload) {
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
setMessage("Onboarding complete.");
if (form) {
Array.prototype.slice.call(form.querySelectorAll("input, select, button")).forEach(function (control) {
control.disabled = true;
});
}
})
.catch(function (error) { setMessage(error && error.message ? error.message : "Unable to save onboarding."); });
});
}());
</script>
@@ -0,0 +1,141 @@
<script>
(function () {
var deviceKey = "pulse-signage-player-device-id";
var clientNameKey = "pulse-signage-player-client-name";
function getClientNameStorageKey(_screenSlug) {
return clientNameKey;
}
var screenKey = "pulse-signage-player-screen-slug";
var qr = document.getElementById("onboarding-qr");
var status = document.getElementById("onboarding-status");
var localForm = document.getElementById("onboarding-local-form");
var localMessage = document.getElementById("onboarding-message");
var localScreenSelect = document.getElementById("onboarding-screen-select");
function parseResponseError(response) {
return response.text().then(function (text) {
var fallbackMessage = text && text.trim() ? text.trim() : "Unable to save onboarding.";
try {
var payload = JSON.parse(text);
return payload && payload.error ? payload.error : fallbackMessage;
} catch (_error) {
return fallbackMessage;
}
});
}
function getDeviceId() {
var stored = "";
try { stored = window.localStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; }
if (stored) { return stored; }
var next = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "device-" + Date.now() + "-" + Math.random().toString(16).slice(2));
try { window.localStorage.setItem(deviceKey, next); } catch (_error2) {}
return next;
}
function setStatus(message) { if (status) { status.textContent = message; } }
function setLocalMessage(message) { if (localMessage) { localMessage.textContent = message || ""; } }
function setSelectOptions(select, screens, selectedSlug) {
if (!select) { return; }
while (select.firstChild) { select.removeChild(select.firstChild); }
var placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "Select a screen";
select.appendChild(placeholder);
(Array.isArray(screens) ? screens : []).forEach(function (screen) {
var option = document.createElement("option");
option.value = String(screen && screen.slug ? screen.slug : "");
option.textContent = String(screen && (screen.name || screen.slug) ? (screen.name || screen.slug) : "Screen");
if (selectedSlug && String(option.value) === String(selectedSlug)) {
option.selected = true;
}
select.appendChild(option);
});
}
function loadScreens(selectedSlug) {
return fetch("/api/onboarding/screens", { cache: "no-store" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (payload) {
var screens = payload && Array.isArray(payload.screens) ? payload.screens : [];
setSelectOptions(localScreenSelect, screens, selectedSlug);
return screens;
})
.catch(function () { setSelectOptions(localScreenSelect, [], selectedSlug); return []; });
}
function loadQr(deviceId) {
if (qr) { qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId); }
}
function submitOnboarding(deviceId, clientName, screenSlug) {
return fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug })
})
.then(function (response) {
if (response.ok) {
return response.json();
}
return parseResponseError(response).then(function (messageText) {
throw new Error(messageText);
});
})
.then(function (payload) {
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
setLocalMessage("Onboarding complete.");
if (localForm) {
Array.prototype.slice.call(localForm.querySelectorAll("input, select, button")).forEach(function (control) {
control.disabled = true;
});
}
});
}
function redirectIfOnboarded(deviceId) {
return fetch("/api/onboarding/status?deviceId=" + encodeURIComponent(deviceId), { cache: "no-store" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (payload) {
if (payload && payload.onboarded && payload.screenSlug) {
if (payload.clientName) { try { window.localStorage.setItem(clientNameKey, payload.clientName); } catch (_error) {} }
if (payload.clientName && payload.screenSlug) { try { window.localStorage.setItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); } catch (_error2) {} }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug));
return true;
}
return false;
})
.catch(function () { return false; });
}
var deviceId = getDeviceId();
if (localForm) {
localForm.addEventListener("submit", function (event) {
event.preventDefault();
var formData = new FormData(localForm);
var clientName = String(formData.get("clientName") || "").trim();
var screenSlug = String(formData.get("screenSlug") || "").trim();
if (!clientName) { setLocalMessage("Client name is required."); return; }
if (!screenSlug) { setLocalMessage("Screen is required."); return; }
setLocalMessage("Saving client...");
submitOnboarding(deviceId, clientName, screenSlug).catch(function (error) {
setLocalMessage(error && error.message ? error.message : "Unable to save onboarding.");
});
});
}
loadScreens().then(function () {
try {
var storedScreenSlug = window.localStorage.getItem(screenKey) || "";
var storedClientName = window.localStorage.getItem(clientNameKey) || "";
if (!storedClientName && storedScreenSlug) { storedClientName = window.localStorage.getItem(getClientNameStorageKey(storedScreenSlug)) || ""; }
if (storedClientName && localForm) {
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
if (clientNameInput && !clientNameInput.value) { clientNameInput.value = storedClientName; }
}
if (storedScreenSlug && localScreenSelect) { localScreenSelect.value = storedScreenSlug; }
} catch (_error) {}
});
redirectIfOnboarded(deviceId).then(function (redirected) {
if (redirected) { return; }
loadQr(deviceId);
setStatus("Waiting for onboarding to finish.");
window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000);
});
}());
</script>
+101 -14
View File
@@ -226,9 +226,12 @@
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
var clientName = getOnboardingClientName();
socket.send(JSON.stringify({
type: 'hello',
clientId: getCommandClientId(),
clientName: clientName || null,
deviceId: getOnboardingDeviceId() || null,
userAgent: window.navigator.userAgent || '',
page: window.location.href,
viewport: getCurrentViewport(),
@@ -255,6 +258,8 @@
commandSocket.send(JSON.stringify({
type: 'state',
clientId: getCommandClientId(),
clientName: getOnboardingClientName() || null,
deviceId: getOnboardingDeviceId() || null,
userAgent: window.navigator.userAgent || '',
page: window.location.href,
viewport: getCurrentViewport(),
@@ -488,6 +493,16 @@
case 'refresh':
refresh();
return;
case 'setclientname':
if (payload.clientName) {
applyOnboardingClientName(payload.clientName, commandSocket);
}
return;
case 'redirect':
if (payload.url) {
window.location.replace(String(payload.url));
}
return;
case 'pause':
setPaused(!isPaused);
return;
@@ -537,6 +552,12 @@
commandSocket = socket;
socket.onopen = function () {
if (typeof syncOnboardingClientNameFromServer === 'function') {
syncOnboardingClientNameFromServer(socket).then(function () {
sendCommandHello(socket);
});
return;
}
sendCommandHello(socket);
};
@@ -623,26 +644,87 @@
};
}
// Remove unsafe markup while preserving simple formatting tags.
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
function sanitizeRichTextAttributes(tagName, attrText) {
const allowedAttributes = {
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
blockquote: ['class', 'style'],
div: ['class', 'style'],
figure: ['class', 'style'],
figcaption: ['class', 'style'],
h1: ['class', 'style'],
h2: ['class', 'style'],
h3: ['class', 'style'],
h4: ['class', 'style'],
h5: ['class', 'style'],
h6: ['class', 'style'],
li: ['class', 'style'],
ol: ['class', 'style', 'start'],
p: ['class', 'style'],
pre: ['class', 'style'],
span: ['class', 'style'],
table: ['class', 'style'],
td: ['class', 'style', 'colspan', 'rowspan'],
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
tr: ['class', 'style'],
ul: ['class', 'style']
};
const allowed = allowedAttributes[tagName] || [];
if (!allowed.length) {
return '';
}
const attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
const lowerKey = String(key || '').toLowerCase();
if (!allowed.includes(lowerKey)) {
return '';
}
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'target') {
const targetValue = String(value || '').trim();
if (targetValue === '_blank') {
attrs.push(' target="_blank"');
if (!attrs.includes(' rel="noreferrer noopener"')) {
attrs.push(' rel="noreferrer noopener"');
}
return '';
}
}
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
return '';
});
return attrs.join('');
}
// Remove unsafe markup while preserving richer CKEditor formatting.
function sanitizeRichText(html) {
var output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, function (tag) {
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i);
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
if (!match) {
return '';
}
var closing = Boolean(match[1]);
var name = String(match[2] || '').toLowerCase();
var allowed = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
if (allowed.indexOf(name) === -1) {
var attrText = String(match[3] || '');
if (ALLOWED_RICH_TEXT_TAGS.indexOf(name) === -1) {
return '';
}
if (name === 'br') {
return '<br>';
if (closing) {
return '</' + name + '>';
}
return closing ? '</' + name + '>' : '<' + name + '>';
return '<' + name + sanitizeRichTextAttributes(name, attrText) + '>';
});
}
@@ -708,7 +790,7 @@
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
}).join('') + '</tr>';
}).join('');
return '<table class="editorjs-table">' + tableRows + '</table>';
return '<table class="ck-content-table">' + tableRows + '</table>';
}
// Render Editor.js JSON or plain content safely.
@@ -951,12 +1033,16 @@
var width = (Number(region.width) / templateCanvas.width) * 100;
var height = (Number(region.height) / templateCanvas.height) * 100;
var baseStyle = 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';';
var pixelWidth = Math.max(1, Math.round(Number(region.width || 0) || 1));
var pixelHeight = Math.max(1, Math.round(Number(region.height || 0) || 1));
return {
regionKey: region.region_key,
regionType: region.region_type,
label: region.label,
baseStyle: baseStyle,
pixelWidth: pixelWidth,
pixelHeight: pixelHeight,
fontFamily: region.font_family || null,
fontSize: region.font_size || null,
fontColor: region.font_color || null,
@@ -968,6 +1054,7 @@
canvasWidth: canvasSize.width,
canvasHeight: canvasSize.height,
background: template.background_image_path ? '<img class="template-background" src="' + escapeHtml(template.background_image_path) + '" alt="" />' : '',
backgroundColor: template.background_color || '#111111',
regions: regions
};
@@ -1010,11 +1097,10 @@
return '<div class="template-region html" style="' + region.baseStyle + '">' + renderHtmlRegionContent(regionContent.value || '') + '</div>';
}
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
var fontSize = sanitizeFontSize(regionContent.font_size);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor);
var scaledFontSize = Math.max(1, Math.round(fontSize * region.canvasScale));
var style = region.baseStyle + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + scaledFontSize + 'px;color:' + escapeHtml(fontColor) + ';';
return '<div class="template-region text" style="' + style + '">' + renderEditorJsContent(regionContent.value || '') + '</div>';
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderEditorJsContent(regionContent.value || '') + '</div></div>';
}
};
@@ -1032,7 +1118,8 @@
const regionContent = content[region.regionKey] || {};
return plan.renderRegion(region, regionContent);
}).join('') : '';
return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '<div class="template-stage">' + (layout ? layout.background : '') + regions + '</div>');
const stageStyle = layout ? 'background-color:' + escapeHtml(layout.backgroundColor || '#111111') + ';' : '';
return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '<div class="template-stage" style="' + stageStyle + '">' + (layout ? layout.background : '') + regions + '</div>');
}
// Media rendering helpers.
@@ -1055,7 +1142,7 @@
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml) {
const body = slide.body ? '<div class="body">' + escapeHtml(slide.body) + '</div>' : '';
const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas';
return '<div class="slide"><div class="' + className + '" style="width:' + canvasWidth + ';height:' + canvasHeight + ';"><div class="overlay"><div>' + escapeHtml(slide.title) + '</div></div>' + innerHtml + body + '</div></div>';
return '<div class="slide"><div class="' + className + '" style="width:' + canvasWidth + ';height:' + canvasHeight + ';">' + innerHtml + body + '</div></div>';
}
// Build the cache key for rendered slide markup.
+3 -2
View File
@@ -4,10 +4,11 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{TITLE}}</title>
<link rel="icon" type="image/png" href="/assets/favicon.png" />
<link rel="stylesheet" href="/assets/css/player.css" />
</head>
<body>
<div id="app"><div class="empty">Loading screen...</div></div>
<body class="{{BODY_CLASS}}">
{{{BODY}}}
{{SCRIPT_BLOCK}}
</body>
</html>
+162
View File
@@ -0,0 +1,162 @@
const crypto = require('crypto');
function createPlayerPlaylistService(options) {
const pool = options && options.pool ? options.pool : null;
const common = options && options.common ? options.common : null;
if (!pool) {
throw new Error('pool is required');
}
if (!common) {
throw new Error('common is required');
}
async function buildScreenPlaylist(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: screen,
playlist: null,
slides: [],
revision: getPlaylistRevision(screen, null, [], [], [])
};
}
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,
ps.position, ps.duration_seconds AS duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
st.name AS template_name, 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 playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE ps.playlist_id = ?
ORDER BY ps.position ASC, ps.id ASC
`, [screen.playlist_id]);
const templateIds = slideRows
.filter(function (slide) { return slide.template_id; })
.map(function (slide) { return slide.template_id; });
const templatesById = {};
let templateRows = [];
let regionRows = [];
if (templateIds.length) {
[templateRows] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, 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]);
[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;
});
}
const slides = slideRows.map(function (slide) {
return {
id: slide.id,
title: slide.title,
body: slide.body,
duration_seconds: slide.duration_seconds,
schedule_mode: slide.schedule_mode,
schedule_start_datetime: slide.schedule_start_datetime,
schedule_end_datetime: slide.schedule_end_datetime,
schedule_start_time: slide.schedule_start_time,
schedule_end_time: slide.schedule_end_time,
schedule_days_json: slide.schedule_days_json,
media_url: slide.media_path,
media_type: slide.media_type,
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) || {}
};
});
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows);
return { screen: screen, playlist: playlist, slides: slides, revision: 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.background_color);
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');
}
return {
buildScreenPlaylist: buildScreenPlaylist
};
}
module.exports = {
createPlayerPlaylistService: createPlayerPlaylistService
};
+197 -15
View File
@@ -4,21 +4,194 @@ body {
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
background: #111;
color: #fff;
font-family: Arial, sans-serif;
}
body.onboarding-page {
background:
radial-gradient(circle at top, rgba(82, 144, 255, 0.28), transparent 32%),
radial-gradient(circle at bottom right, rgba(34, 197, 94, 0.18), transparent 26%),
linear-gradient(160deg, #09111f 0%, #0b1323 52%, #111827 100%);
overflow-x: hidden;
overflow-y: auto;
}
body.onboarding-page #app {
display: none;
}
#app {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #000;
background: #111;
position: relative;
}
.onboarding-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(16px, 3vw, 40px);
box-sizing: border-box;
}
.onboarding-card {
width: min(100%, 1040px);
padding: clamp(20px, 3vw, 40px);
border-radius: 30px;
background: rgba(10, 17, 30, 0.82);
border: 1px solid rgba(148, 163, 184, 0.18);
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(14px);
box-sizing: border-box;
}
.onboarding-card h1 {
margin: 0 0 12px;
font-size: clamp(2rem, 4vw, 3.2rem);
line-height: 1.05;
}
.onboarding-kicker {
margin: 0 0 12px;
text-transform: uppercase;
letter-spacing: 0.14em;
color: #8ab4ff;
font-size: 0.82rem;
}
.onboarding-copy {
margin: 0 0 28px;
color: #cbd5e1;
font-size: 1.03rem;
line-height: 1.5;
}
.onboarding-layout {
display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1fr);
gap: clamp(20px, 3vw, 32px);
align-items: stretch;
}
.onboarding-qr-pane {
display: grid;
gap: 16px;
align-content: start;
}
.onboarding-qr-frame {
display: flex;
justify-content: center;
padding: 22px;
border-radius: 26px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.onboarding-qr-frame img {
width: min(100%, 320px);
aspect-ratio: 1;
display: block;
background: #fff;
border-radius: 18px;
}
.onboarding-form {
display: grid;
gap: 14px;
}
.onboarding-form--local {
align-content: start;
padding: 22px;
border-radius: 26px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.onboarding-form label {
display: grid;
gap: 9px;
color: #e2e8f0;
}
.onboarding-form input[type="text"] {
width: 100%;
box-sizing: border-box;
min-height: 48px;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.28);
background: rgba(15, 23, 42, 0.9);
color: #f8fafc;
font-size: 1rem;
}
.onboarding-form select {
width: 100%;
box-sizing: border-box;
min-height: 48px;
padding: 12px 16px;
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.28);
background: rgba(15, 23, 42, 0.9);
color: #f8fafc;
font-size: 1rem;
}
.onboarding-form input[type="text"]::placeholder {
color: #94a3b8;
}
.onboarding-form button {
appearance: none;
border: 0;
border-radius: 12px;
background: linear-gradient(135deg, #60a5fa, #22c55e);
color: #08111f;
font-size: 1rem;
font-weight: 700;
min-height: 48px;
padding: 12px 18px;
cursor: pointer;
}
.onboarding-status {
margin-top: 8px;
min-height: 1.4em;
color: #cbd5e1;
font-size: 0.96rem;
}
.onboarding-card--landing .onboarding-status {
text-align: center;
}
@media (max-width: 860px), (orientation: portrait) {
.onboarding-shell {
align-items: center;
}
.onboarding-layout {
grid-template-columns: 1fr;
}
.onboarding-card {
width: 100%;
}
.onboarding-qr-frame img {
width: min(100%, 280px);
}
}
.slide-shell {
position: absolute;
inset: 0;
@@ -86,18 +259,6 @@ body.screen-blackout #app {
border: 0;
}
.overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
padding: 14px 18px;
background: rgba(0, 0, 0, 0.35);
font-size: 14px;
display: flex;
justify-content: space-between;
}
.body {
position: absolute;
left: 5%;
@@ -133,7 +294,6 @@ body.screen-blackout #app {
position: relative;
width: 100%;
height: 100%;
background: #111;
}
.template-stage .template-background {
@@ -162,6 +322,28 @@ body.screen-blackout #app {
line-height: 1.35;
}
.template-region.text .template-region-text-scale {
display: block;
transform-origin: top left;
}
.template-region.text .template-region-text-scale > * {
margin: 0;
}
.template-region.text .template-region-text-scale > * + * {
margin-top: 0.5em;
}
.template-region.text .template-region-text-scale ul,
.template-region.text .template-region-text-scale ol {
padding-left: 1.2em;
}
.template-region.text .template-region-text-scale code {
white-space: pre-wrap;
}
.template-region.text > * {
margin: 0;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

+345
View File
@@ -0,0 +1,345 @@
const fs = require('fs');
const path = require('path');
function mediaKind(mediaPath) {
const ext = path.extname(mediaPath || '').toLowerCase();
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'].includes(ext)) {
return 'image';
}
if (['.mp4', '.webm', '.ogg'].includes(ext)) {
return 'video';
}
if (ext === '.pdf') {
return 'pdf';
}
return 'file';
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function sanitizeFontFamily(value) {
return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim();
}
function sanitizeFontSize(value) {
return Math.max(8, Number(value || 0) || 24);
}
function sanitizeTextColor(value, fallback) {
const raw = String(value || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
return raw;
}
return fallback || '#000000';
}
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
function sanitizeRichTextAttributes(tagName, attrText) {
const allowedAttributes = {
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
blockquote: ['class', 'style'],
div: ['class', 'style'],
figure: ['class', 'style'],
figcaption: ['class', 'style'],
h1: ['class', 'style'],
h2: ['class', 'style'],
h3: ['class', 'style'],
h4: ['class', 'style'],
h5: ['class', 'style'],
h6: ['class', 'style'],
li: ['class', 'style'],
ol: ['class', 'style', 'start'],
p: ['class', 'style'],
pre: ['class', 'style'],
span: ['class', 'style'],
table: ['class', 'style'],
td: ['class', 'style', 'colspan', 'rowspan'],
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
tr: ['class', 'style'],
ul: ['class', 'style']
};
const allowed = allowedAttributes[tagName] || [];
if (!allowed.length) {
return '';
}
const attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
const lowerKey = String(key || '').toLowerCase();
if (!allowed.includes(lowerKey)) {
return '';
}
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'target') {
const targetValue = String(value || '').trim();
if (targetValue === '_blank') {
attrs.push(' target="_blank"');
if (!attrs.includes(' rel="noreferrer noopener"')) {
attrs.push(' rel="noreferrer noopener"');
}
return '';
}
}
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
return '';
});
return attrs.join('');
}
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\S]*?)(\/?)>$/i);
if (!match) {
return '';
}
const closing = Boolean(match[1]);
const name = String(match[2] || '').toLowerCase();
const attrText = String(match[3] || '');
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
return '';
}
if (closing) {
return `</${name}>`;
}
return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`;
});
}
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="ck-content-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 playerClientNameScriptPath = path.join(__dirname, 'player-client-name.script.html');
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
const playerOnboardingLandingScriptPath = path.join(__dirname, 'player-onboarding-landing.script.html');
const playerOnboardingFormScriptPath = path.join(__dirname, 'player-onboarding-form.script.html');
let playerPageTemplateCache = null;
let playerClientNameScriptCache = null;
let playerPageScriptCache = null;
let playerOnboardingLandingScriptCache = null;
let playerOnboardingFormScriptCache = null;
function loadTemplate(filePath, cache) {
const stat = fs.statSync(filePath);
if (cache.value && cache.mtimeMs === stat.mtimeMs) {
return cache.value;
}
const compiled = require('handlebars').compile(fs.readFileSync(filePath, 'utf8'));
cache.value = compiled;
cache.mtimeMs = stat.mtimeMs;
return compiled;
}
function getPlayerPageTemplate() {
return loadTemplate(playerPageTemplatePath, playerPageTemplateCache || (playerPageTemplateCache = {}));
}
function getPlayerClientNameScript() {
return loadTemplate(playerClientNameScriptPath, playerClientNameScriptCache || (playerClientNameScriptCache = {}));
}
function getPlayerPageScript() {
return loadTemplate(playerPageScriptPath, playerPageScriptCache || (playerPageScriptCache = {}));
}
function getPlayerOnboardingLandingScript() {
return loadTemplate(playerOnboardingLandingScriptPath, playerOnboardingLandingScriptCache || (playerOnboardingLandingScriptCache = {}));
}
function getPlayerOnboardingFormScript() {
return loadTemplate(playerOnboardingFormScriptPath, playerOnboardingFormScriptCache || (playerOnboardingFormScriptCache = {}));
}
module.exports = {
mediaKind: mediaKind,
escapeHtml: escapeHtml,
sanitizeFontFamily: sanitizeFontFamily,
sanitizeFontSize: sanitizeFontSize,
sanitizeTextColor: sanitizeTextColor,
sanitizeRichTextAttributes: sanitizeRichTextAttributes,
safeJsonForScript: safeJsonForScript,
parseMaybeJson: parseMaybeJson,
normalizeContentValue: normalizeContentValue,
normalizeSlide: normalizeSlide,
sanitizeRichText: sanitizeRichText,
renderEditorJsBlock: renderEditorJsBlock,
renderEditorJsListItem: renderEditorJsListItem,
renderEditorJsTable: renderEditorJsTable,
renderEditorJsContent: renderEditorJsContent,
renderHtmlRegionContent: renderHtmlRegionContent,
fitCanvasSize: fitCanvasSize,
loadTemplate: loadTemplate,
getPlayerPageTemplate: getPlayerPageTemplate,
getPlayerClientNameScript: getPlayerClientNameScript,
getPlayerPageScript: getPlayerPageScript,
getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript,
getPlayerOnboardingFormScript: getPlayerOnboardingFormScript
};
+97 -234
View File
@@ -1,260 +1,123 @@
const fs = require('fs');
const path = require('path');
const Handlebars = require('handlebars');
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageScript, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
function mediaKind(mediaPath) {
const ext = path.extname(mediaPath || '').toLowerCase();
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'].includes(ext)) {
return 'image';
}
if (['.mp4', '.webm', '.ogg'].includes(ext)) {
return 'video';
}
if (ext === '.pdf') {
return 'pdf';
}
return 'file';
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function sanitizeFontFamily(value) {
return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim();
}
function sanitizeFontSize(value) {
return Math.max(8, Number(value || 0) || 24);
}
function sanitizeTextColor(value, fallback) {
const raw = String(value || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
return raw;
}
return fallback || '#000000';
}
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
function safeJsonForScript(value) {
return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c');
}
function parseMaybeJson(value) {
if (typeof value !== 'string') {
return value;
}
const raw = value.trim();
if (!raw) {
return value;
}
if (raw[0] !== '{' && raw[0] !== '[') {
return value;
}
try {
return JSON.parse(raw);
} catch (_error) {
return value;
}
}
function normalizeContentValue(value) {
if (!value || typeof value !== 'object') {
return {
type: 'text',
value: parseMaybeJson(value)
};
}
const normalized = {};
Object.keys(value).forEach(function (key) {
normalized[key] = value[key];
});
if (normalized.value !== undefined) {
normalized.value = parseMaybeJson(normalized.value);
} else if (normalized.font_family !== undefined && normalized.font_family !== null) {
normalized.font_family = sanitizeFontFamily(normalized.font_family);
} else if (normalized.font_size !== undefined && normalized.font_size !== null) {
normalized.font_size = sanitizeFontSize(normalized.font_size);
} else if (normalized.font_color !== undefined && normalized.font_color !== null) {
normalized.font_color = sanitizeTextColor(normalized.font_color);
} else if (!normalized.type) {
normalized.type = 'text';
}
return normalized;
}
function normalizeSlide(slide) {
const normalized = {};
Object.keys(slide || {}).forEach(function (key) {
normalized[key] = slide[key];
});
normalized.content = {};
const content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
Object.keys(content).forEach(function (regionKey) {
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
});
return normalized;
}
function sanitizeRichText(html) {
let output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, (tag) => {
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i);
if (!match) {
return '';
}
const closing = Boolean(match[1]);
const name = String(match[2] || '').toLowerCase();
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
return '';
}
if (name === 'br') {
return '<br>';
}
return closing ? `</${name}>` : `<${name}>`;
function renderPage(template, options) {
return template({
TITLE: options.title,
BODY_CLASS: options.bodyClass || '',
BODY: new Handlebars.SafeString(options.body || ''),
SCRIPT_BLOCK: new Handlebars.SafeString(options.script || '')
});
}
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 renderOnboardingLandingBody() {
return [
'<main class="onboarding-shell">',
' <section class="onboarding-card onboarding-card--landing">',
' <p class="onboarding-kicker">Pulse Signage</p>',
' <h1>Onboard this player</h1>',
' <p class="onboarding-copy">Choose an existing screen, name the client, and either scan the QR code or finish right here with a keyboard and mouse.</p>',
' <div class="onboarding-layout">',
' <div class="onboarding-qr-pane">',
' <div class="onboarding-qr-frame">',
' <img id="onboarding-qr" alt="Onboarding QR code" />',
' </div>',
' <div id="onboarding-status" class="onboarding-status">Preparing onboarding link...</div>',
' </div>',
' <form id="onboarding-local-form" class="onboarding-form onboarding-form--local">',
' <label>',
' <span>Client name</span>',
' <input name="clientName" type="text" maxlength="255" required placeholder="Lobby player" autocomplete="off" />',
' </label>',
' <label>',
' <span>Screen</span>',
' <select name="screenSlug" id="onboarding-screen-select" required>',
' <option value="">Loading screens...</option>',
' </select>',
' </label>',
' <button type="submit">Save client</button>',
' <div id="onboarding-message" class="onboarding-status"></div>',
' </form>',
' </div>',
' </section>',
'</main>'
].join('');
}
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 renderOnboardingFormBody(deviceId) {
return [
'<main class="onboarding-shell">',
' <section class="onboarding-card onboarding-card--form">',
' <p class="onboarding-kicker">Pulse Signage</p>',
' <h1>Name this client</h1>',
' <p class="onboarding-copy">Pick an existing screen and give this player a friendly name that will persist after refreshes.</p>',
' <form id="onboarding-form" class="onboarding-form">',
' <label>',
' <span>Client name</span>',
' <input name="clientName" type="text" maxlength="255" required placeholder="Lobby player" />',
' </label>',
' <label>',
' <span>Screen</span>',
' <select name="screenSlug" id="onboarding-screen-select" required>',
' <option value="">Loading screens...</option>',
' </select>',
' </label>',
' <input type="hidden" name="deviceId" value="' + Handlebars.escapeExpression(deviceId || '') + '" />',
' <button type="submit">Save client</button>',
' <div id="onboarding-message" class="onboarding-status"></div>',
' </form>',
' </section>',
'</main>'
].join('');
}
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 renderOnboardingLandingScript() {
return getPlayerOnboardingLandingScript()();
}
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 renderOnboardingFormScript(deviceId) {
return getPlayerOnboardingFormScript()({
DEVICE_ID_JSON: new Handlebars.SafeString(JSON.stringify(deviceId || ''))
});
}
function renderPlayerPage(slug, initialData) {
const onboardingScript = getPlayerClientNameScript()();
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)
return renderPage(template, {
title: 'Screen ' + slug,
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
script: onboardingScript + script
});
}
function renderPlayerOnboardingLandingPage() {
return renderPage(getPlayerPageTemplate(), {
title: 'Onboard player',
bodyClass: 'onboarding-page',
body: renderOnboardingLandingBody(),
script: renderOnboardingLandingScript()
});
}
function renderPlayerOnboardingFormPage(deviceId) {
return renderPage(getPlayerPageTemplate(), {
title: 'Onboard screen',
bodyClass: 'onboarding-page',
body: renderOnboardingFormBody(deviceId),
script: renderOnboardingFormScript(deviceId)
});
}
module.exports = {
mediaKind,
renderPlayerPage
renderPlayerPage,
renderPlayerOnboardingLandingPage,
renderPlayerOnboardingFormPage
};
+159
View File
@@ -0,0 +1,159 @@
const fs = require('fs');
const express = require('express');
function registerPlayerRoutes(app, options) {
const pool = options && options.pool ? options.pool : null;
const common = options && options.common ? options.common : null;
const uploadDir = options && options.uploadDir ? options.uploadDir : null;
const assetDir = options && options.assetDir ? options.assetDir : null;
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
const playerPlaylistService = options && options.playerPlaylistService ? options.playerPlaylistService : null;
if (!app || !pool || !common || !uploadDir || !assetDir || !playerRuntime || !playerPlaylistService) {
throw new Error('registerPlayerRoutes requires app, pool, common, uploadDir, assetDir, playerRuntime, and playerPlaylistService.');
}
app.use('/assets', express.static(assetDir));
app.use('/uploads', express.static(uploadDir));
app.get('/api/uploads/config', function (_req, res) {
res.json({
uploadDir: uploadDir
});
});
app.put('/api/uploads/:filename', express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) {
try {
const filename = require('path').basename(String(req.params.filename || '').trim());
if (!filename) {
return res.status(400).json({ error: 'Filename is required' });
}
const filePath = require('path').join(uploadDir, filename);
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
await fs.promises.mkdir(uploadDir, { recursive: true });
await fs.promises.writeFile(filePath, body);
res.json({ ok: true, filename: filename });
} catch (error) {
next(error);
}
});
app.delete('/api/uploads/:filename', async function (req, res, next) {
try {
const filename = require('path').basename(String(req.params.filename || '').trim());
if (!filename) {
return res.status(400).json({ error: 'Filename is required' });
}
const filePath = require('path').join(uploadDir, filename);
try {
await fs.promises.unlink(filePath);
} catch (error) {
if (!error || error.code !== 'ENOENT') {
throw error;
}
}
res.json({ ok: true, filename: filename });
} catch (error) {
next(error);
}
});
app.get('/screen/:slug', function (req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
playerPlaylistService.buildScreenPlaylist(req.params.slug).then(function (data) {
res.send(common.renderPlayerPage(req.params.slug, data));
}).catch(function (error) {
console.error(error);
res.status(500).send('Internal server error');
});
});
app.get('/api/screens/:slug/playlist', async function (req, res, next) {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug);
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);
}
});
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], async function (req, res, next) {
try {
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
if (!screenRows.length) {
return res.status(404).json({ error: 'Screen not found' });
}
const connections = playerRuntime.snapshotConnections(req.params.slug);
res.json({
screen: screenRows[0],
screenSlug: req.params.slug,
count: connections.length,
connections: connections
});
} catch (error) {
next(error);
}
});
app.post('/api/screens/:slug/commands', async function (req, res, next) {
try {
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
? req.body.blackout
: req.query.blackout;
if (!command) {
return res.status(400).json({ error: 'Command is required' });
}
if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'left', 'right', 'setclientname'].indexOf(command) === -1) {
return res.status(400).json({ error: 'Unsupported command' });
}
const isRedirectCommand = command === 'redirect';
let screenRows = [];
if (!isRedirectCommand) {
[screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
if (!screenRows.length) {
return res.status(404).json({ error: 'Screen not found' });
}
}
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
? Object.assign({}, req.body, { command: command })
: command;
if (command === 'blackout' && blackoutValue !== undefined && commandPayload && typeof commandPayload === 'object') {
commandPayload.blackout = blackoutValue;
}
const sent = connectionId
? await playerRuntime.sendCommandToConnection(req.params.slug, connectionId, commandPayload)
: await playerRuntime.broadcastCommand(req.params.slug, commandPayload);
res.json({
screen: screenRows[0] || null,
screenSlug: req.params.slug,
command: command,
connectionId: connectionId || null,
sent: sent
});
} catch (error) {
next(error);
}
});
}
module.exports = {
registerPlayerRoutes: registerPlayerRoutes
};
+368
View File
@@ -0,0 +1,368 @@
const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws');
const { isClientNameAvailable } = require('../client-name-check');
function createPlayerRuntime(options) {
const pool = options && options.pool ? options.pool : null;
const normalizeDeviceId = typeof options.normalizeDeviceId === 'function'
? options.normalizeDeviceId
: function (value) {
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
};
const connectionsBySlug = new Map();
const dashboardListenersBySlug = new Map();
const wss = new WebSocketServer({ noServer: true });
function normalizeClientIp(value) {
const ip = String(value || '').trim();
if (!ip) {
return null;
}
if (ip.toLowerCase().startsWith('::ffff:')) {
return ip.slice(7).trim() || null;
}
return ip;
}
function getConnectionBucket(slug) {
const key = String(slug || '').trim();
if (!key) {
return null;
}
if (!connectionsBySlug.has(key)) {
connectionsBySlug.set(key, new Map());
}
return connectionsBySlug.get(key);
}
function removeConnection(slug, connectionId) {
const bucket = connectionsBySlug.get(String(slug || '').trim());
if (!bucket) {
return;
}
bucket.delete(connectionId);
if (!bucket.size) {
connectionsBySlug.delete(String(slug || '').trim());
}
}
function getDashboardListenerBucket(slug) {
const key = String(slug || '').trim();
if (!key) {
return null;
}
if (!dashboardListenersBySlug.has(key)) {
dashboardListenersBySlug.set(key, new Set());
}
return dashboardListenersBySlug.get(key);
}
function removeDashboardListener(slug, socket) {
const key = String(slug || '').trim();
const bucket = dashboardListenersBySlug.get(key);
if (!bucket) {
return;
}
bucket.delete(socket);
if (!bucket.size) {
dashboardListenersBySlug.delete(key);
}
}
function buildClientLabel(connection) {
const clientName = String(connection.clientName || '').trim();
const clientId = String(connection.clientId || '').trim();
const userAgent = String(connection.userAgent || '').trim();
const clientIp = String(connection.clientIp || '').trim();
const viewport = connection.viewport && typeof connection.viewport === 'object'
? connection.viewport
: null;
const labelParts = [];
if (userAgent) {
labelParts.push(userAgent.length > 72 ? `${userAgent.slice(0, 72)}...` : userAgent);
}
if (clientName) {
labelParts.push(clientName);
} else if (clientId) {
labelParts.push(`id ${clientId.slice(-6)}`);
}
if (clientIp) {
labelParts.push(clientIp);
}
if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) {
labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`);
}
if (!labelParts.length) {
return connection.remoteAddress || 'connected client';
}
return labelParts.join(' • ');
}
function snapshotConnections(slug) {
const bucket = connectionsBySlug.get(String(slug || '').trim());
if (!bucket) {
return [];
}
return Array.from(bucket.values()).map(function (connection) {
return {
id: connection.id,
clientId: connection.clientId || null,
clientName: connection.clientName || null,
deviceId: connection.deviceId || null,
label: connection.label,
userAgent: connection.userAgent || null,
viewport: connection.viewport || null,
page: connection.page || null,
currentSlide: connection.currentSlide || null,
paused: Boolean(connection.paused),
blackout: Boolean(connection.blackout),
currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null,
currentSlideTitle: connection.currentSlide && connection.currentSlide.title ? connection.currentSlide.title : null,
clientIp: connection.clientIp || null,
remoteAddress: connection.remoteAddress || null,
connectedAt: connection.connectedAt ? connection.connectedAt.toISOString() : null,
lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null
};
});
}
function snapshotAllConnections() {
const allConnections = [];
for (const bucket of connectionsBySlug.values()) {
if (!bucket || typeof bucket.values !== 'function') {
continue;
}
for (const connection of bucket.values()) {
allConnections.push({
clientId: connection.clientId || null,
clientName: connection.clientName || null,
deviceId: connection.deviceId || null
});
}
}
return allConnections;
}
async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) {
return isClientNameAvailable(poolArg || pool, clientName, excludeDeviceId, snapshotAllConnections());
}
function broadcastConnectionSnapshot(slug) {
const key = String(slug || '').trim();
const bucket = dashboardListenersBySlug.get(key);
if (!bucket || !bucket.size) {
return;
}
const payload = JSON.stringify({
type: 'snapshot',
slug: key,
connections: snapshotConnections(slug),
sentAt: new Date().toISOString()
});
bucket.forEach(function (socket) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(payload);
}
});
}
async function sendCommandToConnection(slug, connectionId, commandOrPayload) {
const bucket = connectionsBySlug.get(String(slug || '').trim());
if (!bucket || !bucket.size) {
return 0;
}
const target = bucket.get(String(connectionId || '').trim());
if (!target || target.socket.readyState !== WebSocket.OPEN) {
return 0;
}
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
? Object.assign({}, commandOrPayload)
: { command: commandOrPayload };
payload.type = 'command';
payload.targetConnectionId = target.id;
payload.sentAt = new Date().toISOString();
target.socket.send(JSON.stringify(payload));
return 1;
}
async function broadcastCommand(slug, commandOrPayload) {
const bucket = connectionsBySlug.get(String(slug || '').trim());
if (!bucket || !bucket.size) {
return 0;
}
let sent = 0;
bucket.forEach(function (connection) {
if (connection.socket.readyState !== WebSocket.OPEN) {
return;
}
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
? Object.assign({}, commandOrPayload)
: { command: commandOrPayload };
payload.type = 'command';
payload.sentAt = new Date().toISOString();
connection.socket.send(JSON.stringify(payload));
sent += 1;
});
return sent;
}
function handleUpgrade(request, socket, head) {
let pathname = '';
try {
pathname = new URL(request.url, 'http://localhost').pathname;
} catch (_error) {
socket.destroy();
return;
}
const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/);
const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/);
if (!dashboardMatch && !playerMatch) {
socket.destroy();
return;
}
const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]);
wss.handleUpgrade(request, socket, head, function (ws) {
wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : 'player');
});
}
wss.on('connection', function (socket, request, slug, role) {
if (role === 'dashboard') {
const listenerBucket = getDashboardListenerBucket(slug);
if (!listenerBucket) {
socket.close();
return;
}
listenerBucket.add(socket);
socket.send(JSON.stringify({
type: 'snapshot',
slug: String(slug || '').trim(),
connections: snapshotConnections(slug),
sentAt: new Date().toISOString()
}));
socket.on('close', function () {
removeDashboardListener(slug, socket);
});
socket.on('error', function () {
removeDashboardListener(slug, socket);
});
return;
}
const remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null;
const forwardedFor = normalizeClientIp(String(request.headers['x-forwarded-for'] || '').split(',')[0]);
const normalizedRemoteAddress = normalizeClientIp(remoteAddress);
const connectionId = crypto.randomUUID();
const connection = {
id: connectionId,
slug: slug,
socket: socket,
clientId: null,
clientName: null,
deviceId: null,
userAgent: null,
viewport: null,
page: null,
paused: false,
blackout: false,
clientIp: forwardedFor || normalizedRemoteAddress,
remoteAddress: normalizedRemoteAddress,
label: forwardedFor || normalizedRemoteAddress || 'connected client',
connectedAt: new Date(),
lastSeenAt: new Date()
};
const bucket = getConnectionBucket(slug);
if (!bucket) {
socket.close();
return;
}
bucket.set(connectionId, connection);
socket.on('message', function (rawMessage) {
connection.lastSeenAt = new Date();
let payload = null;
try {
payload = JSON.parse(String(rawMessage || ''));
} catch (_error) {
return;
}
if (!payload || (payload.type !== 'hello' && payload.type !== 'state')) {
return;
}
connection.clientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId;
connection.clientName = payload.clientName ? String(payload.clientName).trim() : connection.clientName;
connection.deviceId = payload.deviceId ? normalizeDeviceId(payload.deviceId) || connection.deviceId : connection.deviceId;
if (!connection.clientName && connection.clientId) {
connection.clientName = connection.clientId;
}
connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent;
connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport;
connection.page = payload.page ? String(payload.page).trim() : connection.page;
connection.paused = Boolean(payload.paused);
connection.blackout = Boolean(payload.blackout);
connection.clientIp = payload.clientIp ? normalizeClientIp(payload.clientIp) || connection.clientIp : connection.clientIp;
connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? {
id: payload.currentSlide.id || null,
title: payload.currentSlide.title || '',
kind: payload.currentSlide.kind || '',
playlistSignature: payload.currentSlide.playlistSignature || ''
} : connection.currentSlide;
connection.label = buildClientLabel(connection);
connection.lastSeenAt = new Date();
broadcastConnectionSnapshot(slug);
});
socket.on('close', function () {
removeConnection(slug, connectionId);
broadcastConnectionSnapshot(slug);
});
socket.on('error', function () {
removeConnection(slug, connectionId);
broadcastConnectionSnapshot(slug);
});
});
function installWebsocket(server) {
server.on('upgrade', handleUpgrade);
}
return {
installWebsocket: installWebsocket,
snapshotConnections: snapshotConnections,
snapshotAllConnections: snapshotAllConnections,
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
sendCommandToConnection: sendCommandToConnection,
broadcastCommand: broadcastCommand
};
}
module.exports = {
createPlayerRuntime: createPlayerRuntime
};
+225
View File
@@ -0,0 +1,225 @@
const PERMISSION_SECTIONS = [
{
key: 'dashboard',
order: 10,
name: 'Dashboard',
sectionName: 'Main navigation',
actions: [
{ key: 'read', name: 'Read', description: 'Access the dashboard overview.' },
{ key: 'allow', name: 'Allow', description: 'Send global player commands.' }
]
},
{
key: 'clients',
order: 20,
name: 'Connected clients',
sectionName: 'Main navigation',
actions: [
{ key: 'read', name: 'Read', description: 'View connected player clients and live status.' },
{ key: 'allow', name: 'Allow', description: 'Use the connected client command buttons.' }
]
},
{
key: 'screens',
order: 30,
name: 'Screens',
sectionName: 'Content',
actions: [
{ key: 'read', name: 'Read', description: 'View the screen list and open screen details.' },
{ key: 'create', name: 'Create', description: 'Create new screens.' },
{ key: 'update', name: 'Update', description: 'Update screens.' },
{ key: 'delete', name: 'Delete', description: 'Delete screens.' }
]
},
{
key: 'playlists',
order: 40,
name: 'Playlists',
sectionName: 'Content',
actions: [
{ key: 'read', name: 'Read', description: 'View playlists and playlist contents.' },
{ key: 'create', name: 'Create', description: 'Create new playlists.' },
{ key: 'update', name: 'Update', description: 'Update playlists and playlist slides.' },
{ key: 'delete', name: 'Delete', description: 'Delete playlists.' }
]
},
{
key: 'slides',
order: 50,
name: 'Slides',
sectionName: 'Content',
actions: [
{ key: 'read', name: 'Read', description: 'View slides.' },
{ key: 'create', name: 'Create', description: 'Create new slides.' },
{ key: 'update', name: 'Update', description: 'Update slide content.' },
{ key: 'delete', name: 'Delete', description: 'Delete slides.' }
]
},
{
key: 'templates',
order: 60,
name: 'Slide templates',
sectionName: 'Content',
actions: [
{ key: 'read', name: 'Read', description: 'View slide templates.' },
{ key: 'create', name: 'Create', description: 'Create new slide templates.' },
{ key: 'update', name: 'Update', description: 'Update slide templates.' },
{ key: 'delete', name: 'Delete', description: 'Delete slide templates.' }
]
},
{
key: 'canvas-sizes',
order: 70,
name: 'Canvas sizes',
sectionName: 'Content',
actions: [
{ key: 'read', name: 'Read', description: 'View canvas sizes.' },
{ key: 'create', name: 'Create', description: 'Create new canvas sizes.' },
{ key: 'update', name: 'Update', description: 'Update canvas sizes.' },
{ key: 'delete', name: 'Delete', description: 'Delete canvas sizes.' }
]
},
{
key: 'users',
order: 80,
name: 'Users',
sectionName: 'Settings',
actions: [
{ key: 'read', name: 'Read', description: 'View users and role assignments.' },
{ key: 'create', name: 'Create', description: 'Create new users.' },
{ key: 'update', name: 'Update', description: 'Update users, passwords, and role assignments.' },
{ key: 'delete', name: 'Delete', description: 'Delete users.' }
]
},
{
key: 'rbac',
order: 90,
name: 'Roles and permissions',
sectionName: 'Settings',
actions: [
{ key: 'read', name: 'Read', description: 'View roles and permissions.' },
{ key: 'create', name: 'Create', description: 'Create new roles.' },
{ key: 'update', name: 'Update', description: 'Update role details and permissions.' },
{ key: 'delete', name: 'Delete', description: 'Delete roles.' }
]
}
];
const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) {
return section.actions.map(function (action) {
return {
key: `${section.key}.${action.key}`,
name: section.name,
sectionOrder: section.order,
actionName: action.name,
sectionName: section.sectionName,
sectionKey: section.key,
actionKey: action.key,
description: action.description
};
});
});
const DEFAULT_ROLE = {
key: 'administrators',
name: 'Administrators',
description: 'Full access to the admin interface.'
};
function normalizePermissionKey(permissionKey) {
return String(permissionKey || '').trim();
}
function normalizePermissionKeys(permissionKeys) {
const normalized = [];
(Array.isArray(permissionKeys) ? permissionKeys : []).forEach(function (permissionKey) {
const normalizedPermissionKey = normalizePermissionKey(permissionKey);
if (!normalizedPermissionKey) {
return;
}
const parts = normalizedPermissionKey.split('.');
if (parts.length !== 2) {
normalized.push(normalizedPermissionKey);
return;
}
const sectionKey = parts[0];
const actionKey = parts[1];
normalized.push(`${sectionKey}.${actionKey}`);
if (actionKey === 'create' || actionKey === 'update' || actionKey === 'delete') {
normalized.push(`${sectionKey}.read`);
}
if (actionKey === 'manage') {
normalized.push(`${sectionKey}.read`);
normalized.push(`${sectionKey}.create`);
normalized.push(`${sectionKey}.update`);
normalized.push(`${sectionKey}.delete`);
}
if (actionKey === 'view') {
normalized.push(`${sectionKey}.read`);
}
});
return Array.from(new Set(normalized));
}
function hasPermission(currentUser, permissionKey) {
const normalizedPermissionKey = normalizePermissionKey(permissionKey);
if (!normalizedPermissionKey || !currentUser) {
return false;
}
const permissionKeys = Array.isArray(currentUser.permissionKeys)
? currentUser.permissionKeys
: Array.isArray(currentUser.permissions)
? currentUser.permissions
: [];
return normalizePermissionKeys(permissionKeys).includes(normalizedPermissionKey);
}
function hasAnyPermission(currentUser, permissionKeys) {
const normalizedPermissionKeys = normalizePermissionKeys(permissionKeys);
if (!normalizedPermissionKeys.length || !currentUser) {
return false;
}
return normalizedPermissionKeys.some(function (permissionKey) {
return hasPermission(currentUser, permissionKey);
});
}
function requirePermission(permissionKey) {
const normalizedPermissionKey = normalizePermissionKey(permissionKey);
if (!normalizedPermissionKey) {
throw new Error('requirePermission requires a permission key.');
}
return function (req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
}
if (hasPermission(req.currentUser, normalizedPermissionKey)) {
return next();
}
const error = new Error('You do not have permission to access this area.');
error.statusCode = 403;
error.expose = true;
next(error);
};
}
module.exports = {
PERMISSIONS,
PERMISSION_SECTIONS,
DEFAULT_ROLE,
hasPermission,
hasAnyPermission,
requirePermission,
normalizePermissionKeys
};
+298
View File
@@ -0,0 +1,298 @@
const express = require('express');
const http = require('http');
const multer = require('multer');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const common = require('./common');
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword } = require('./auth');
const pages = require('./web/routes');
const registerAuthRoutes = require('./web/routes/auth');
const registerAdminPagesRoutes = require('./web/routes/admin-pages');
const registerAdminAccountRoutes = require('./web/routes/admin-account');
const registerAdminUsersRoutes = require('./web/routes/admin-users');
const registerAdminManageRoutes = require('./web/routes/admin-manage');
const registerAdminScreenCommandRoutes = require('./web/routes/admin-client-commands');
const registerAdminContentRoutes = require('./web/routes/admin-content');
const { createWebBootstrap } = require('./web/bootstrap');
const { requirePermission } = require('./rbac');
const rbacData = require('./web/rbac-data');
const { createPlayerActionService } = require('./web/player-actions');
const { isClientNameAvailable, withClientNameReservation } = require('./client-name-check');
const { createSessionService } = require('./web/session');
const {
formatDashboardDate,
readArrayField,
parseDateTimeLocal,
parseTimeLocal,
normalizeScheduleMode,
getAuditUserId,
getCanvasSignature,
fetchPlaylistCanvasSignature,
fetchScreensByPlaylistId,
fetchScreensBySlideId,
fetchScreensByTemplateId,
fetchOrderedPlaylistSlides,
redirectAfterSave
} = require('./web/helpers');
const PLAYER_INTERNAL_BASE_URL = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:3001').replace(/\/$/, '');
const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const PLAYER_WS_BASE_URL = PLAYER_INTERNAL_BASE_URL.replace(/^http/, 'ws');
const SESSION_COOKIE_NAME = 'digital_signage_session';
const SESSION_MAX_AGE_DAYS = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
const SESSION_MAX_AGE_MS = (Number.isFinite(SESSION_MAX_AGE_DAYS) && SESSION_MAX_AGE_DAYS > 0 ? SESSION_MAX_AGE_DAYS : 14) * 24 * 60 * 60 * 1000;
async function start() {
const app = express();
const server = http.createServer(app);
const pool = common.createPool();
const PORT = Number(process.env.WEB_PORT || 3000);
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
const ASSET_DIR = path.join(__dirname, 'web', 'public');
const playerActionService = createPlayerActionService({
common: common,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL
});
const notifyPlayerScreens = function (slugs, commandOrPayload) {
const uniqueSlugs = Array.from(new Set((slugs || []).map(function (slug) {
return String(slug || '').trim();
}).filter(Boolean)));
if (!uniqueSlugs.length) {
return Promise.resolve(0);
}
return Promise.allSettled(uniqueSlugs.map(function (slug) {
return playerActionService.forwardPlayerCommand(slug, commandOrPayload || 'refresh');
})).then(function (results) {
return results.filter(function (result) {
return result.status === 'fulfilled';
}).length;
});
};
const webBootstrap = createWebBootstrap({
pool: pool,
common: common,
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
uploadDir: UPLOAD_DIR,
dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000),
formatDashboardDate: formatDashboardDate,
notifyPlayerScreens: notifyPlayerScreens
});
const upload = webBootstrap.upload;
const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide;
const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate;
const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload;
const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange;
const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer;
const broadcastDashboardState = webBootstrap.broadcastDashboardState;
const sessionService = createSessionService({
sessionCookieName: SESSION_COOKIE_NAME,
sessionMaxAgeMs: SESSION_MAX_AGE_MS,
hashSessionToken: hashSessionToken,
createSessionToken: createSessionToken
});
const parseCookies = sessionService.parseCookies;
const clearSessionCookie = sessionService.clearSessionCookie;
const setSessionCookie = sessionService.setSessionCookie;
const loadCurrentUser = sessionService.loadCurrentUser;
const createUserSession = sessionService.createUserSession;
const requireAuth = sessionService.requireAuth;
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use('/assets', express.static(ASSET_DIR));
app.use('/uploads', express.static(UPLOAD_DIR));
app.use(async function (req, _res, next) {
try {
req.currentUser = await loadCurrentUser(pool, req);
next();
} catch (error) {
next(error);
}
});
app.use('/admin', requireAuth);
registerAuthRoutes(app, {
pool: pool,
pages: pages,
createUserSession: createUserSession,
setSessionCookie: setSessionCookie,
clearSessionCookie: clearSessionCookie,
parseCookies: parseCookies,
hashSessionToken: hashSessionToken,
verifyPassword: verifyPassword,
sessionCookieName: SESSION_COOKIE_NAME
});
registerAdminPagesRoutes(app, {
pool: pool,
common: common,
pages: pages,
requirePermission: requirePermission,
buildDashboardState: webBootstrap.buildDashboardState
});
registerAdminAccountRoutes(app, {
pool: pool,
pages: pages,
formatDashboardDate: formatDashboardDate,
getAuditUserId: getAuditUserId,
verifyPassword: verifyPassword,
hashPassword: hashPassword,
createUserSession: createUserSession,
setSessionCookie: setSessionCookie
});
registerAdminUsersRoutes(app, {
pool: pool,
pages: pages,
formatDashboardDate: formatDashboardDate,
getAuditUserId: getAuditUserId,
hashPassword: hashPassword,
readArrayField: readArrayField,
rbacData: rbacData,
requirePermission: requirePermission
});
registerAdminManageRoutes(app, {
pool: pool,
common: common,
pages: pages,
fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides,
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
getCanvasSignature: getCanvasSignature,
normalizeScheduleMode: normalizeScheduleMode,
parseDateTimeLocal: parseDateTimeLocal,
parseTimeLocal: parseTimeLocal,
readArrayField: readArrayField,
getAuditUserId: getAuditUserId,
redirectAfterSave: redirectAfterSave,
notifyPlayerScreens: notifyPlayerScreens,
broadcastDashboardState: broadcastDashboardState,
getScreenDeleteBlockMessage: playerActionService.getScreenDeleteBlockMessage,
getScreenConnections: playerActionService.getScreenConnections,
getPlaylistDeleteBlockMessage: playerActionService.getPlaylistDeleteBlockMessage,
forwardPlayerCommand: playerActionService.forwardPlayerCommand,
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
requirePermission: requirePermission
});
registerAdminScreenCommandRoutes(app, {
pool: pool,
forwardPlayerCommand: playerActionService.forwardPlayerCommand,
getScreenConnections: playerActionService.getScreenConnections,
isClientNameAvailable: isClientNameAvailable,
withClientNameReservation: withClientNameReservation,
requirePermission: requirePermission
});
const registerAdminRbacRoutes = require('./web/routes/admin-rbac');
registerAdminRbacRoutes(app, {
pool: pool,
pages: pages,
getAuditUserId: getAuditUserId,
rbacData: rbacData,
readArrayField: readArrayField,
permissions: require('./rbac').PERMISSIONS,
normalizePermissionKeys: require('./rbac').normalizePermissionKeys,
requirePermission: requirePermission
});
registerAdminContentRoutes(app, {
pool: pool,
common: common,
pages: pages,
upload: upload,
uploadDir: UPLOAD_DIR,
fetchScreensBySlideId: fetchScreensBySlideId,
fetchScreensByTemplateId: fetchScreensByTemplateId,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
getAuditUserId: getAuditUserId,
redirectAfterSave: redirectAfterSave,
notifyPlayerScreens: notifyPlayerScreens,
broadcastDashboardState: broadcastDashboardState,
getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage,
getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage,
getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage,
requirePermission: requirePermission
});
app.use(function (req, res, next) {
const pathName = String(req.originalUrl || '');
const wantsHtml = !pathName.startsWith('/api/') && (!req.accepts || req.accepts('html'));
if (!wantsHtml) {
return next();
}
return res.status(404).send(pages.renderErrorPage({
statusCode: 404,
title: 'Not found',
errorTitle: 'Oops! Page not found.',
message: 'We could not find the page you were looking for.',
backUrl: req.currentUser ? '/admin' : '/login',
backLabel: req.currentUser ? 'Back to dashboard' : 'Sign in'
}, req.currentUser));
});
app.use(function (error, req, res, _next) {
console.error(error);
const statusCode = Number(error && (error.statusCode || error.status)) || 500;
const wantsHtml = !String(req.originalUrl || '').startsWith('/api/') && (!req.accepts || req.accepts('html'));
if (wantsHtml && pages.renderErrorPage) {
const isPermissionError = statusCode === 403;
const message = isPermissionError
? String(error && error.message ? error.message : 'You do not have permission to access this area.')
: String(error && error.message ? error.message : 'An unexpected error occurred.');
const title = isPermissionError
? 'Access denied'
: statusCode === 404
? 'Not found'
: 'Something went wrong';
return res.status(statusCode).send(pages.renderErrorPage({
statusCode: statusCode,
title: title,
errorTitle: title,
message: message,
detail: statusCode >= 500 ? 'The server could not complete the request.' : '',
backUrl: req.currentUser ? '/admin' : '/login',
backLabel: req.currentUser ? 'Back to dashboard' : 'Sign in'
}, req.currentUser));
}
res.status(statusCode).send(statusCode >= 500 ? 'Internal server error' : String(error && error.message ? error.message : 'Error'));
});
// Ensure schema and mirror uploads before the web service starts handling traffic.
await common.ensureSchema(pool);
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
syncExistingUploadsToPlayer(pool, UPLOAD_DIR).catch(function (error) {
console.warn('Unable to sync existing uploads to player:', error);
});
webBootstrap.installDashboardWebsocket(server, loadCurrentUser);
server.listen(PORT, function () {
console.log(`Pulse Signage app listening on port ${PORT}`);
});
}
module.exports = { start };
if (require.main === module) {
start().catch(function (error) {
console.error(error);
process.exit(1);
});
}
+208
View File
@@ -0,0 +1,208 @@
const { WebSocketServer, WebSocket } = require('ws');
const { createDashboardStateService } = require('./dashboard-state');
const { createUploadSyncService } = require('./upload-sync');
function createWebBootstrap(options) {
const pool = options && options.pool;
const common = options && options.common;
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, '');
const uploadDir = String(options && options.uploadDir || '').trim();
const dashboardRefreshIntervalMs = Number(options && options.dashboardRefreshIntervalMs || 2000);
const formatDashboardDate = options && options.formatDashboardDate;
const notifyPlayerScreens = options && options.notifyPlayerScreens;
if (!pool || !common || !uploadDir || typeof formatDashboardDate !== 'function' || typeof notifyPlayerScreens !== 'function') {
throw new Error('createWebBootstrap requires the web bootstrap dependencies.');
}
const dashboardWs = new WebSocketServer({ noServer: true });
const dashboardClients = new Set();
const playerSnapshotCache = new Map();
const playerSnapshotSockets = new Map();
let dashboardRefreshInFlight = null;
let broadcastDashboardState = null;
function getPlayerSnapshotSocketUrl(slug) {
const url = new URL(playerInternalBaseUrl.replace(/^http/, 'ws'));
url.pathname = `/ws/screens/${encodeURIComponent(slug)}/events`;
url.search = '';
return url.toString();
}
function storePlayerSnapshot(slug, connections) {
const normalizedSlug = String(slug || '').trim();
const normalizedConnections = Array.isArray(connections) ? connections : [];
playerSnapshotCache.set(normalizedSlug, {
slug: normalizedSlug,
count: normalizedConnections.length,
connections: normalizedConnections
});
}
function clearPlayerSnapshotSocket(slug) {
const key = String(slug || '').trim();
playerSnapshotSockets.delete(key);
}
function ensurePlayerSnapshotSubscription(slug) {
const key = String(slug || '').trim();
if (!key || playerSnapshotSockets.has(key)) {
return;
}
const socket = new WebSocket(getPlayerSnapshotSocketUrl(key));
playerSnapshotSockets.set(key, socket);
socket.onmessage = function (event) {
try {
const payload = JSON.parse(String(event.data || '{}'));
if (!payload || payload.type !== 'snapshot' || payload.slug !== key) {
return;
}
storePlayerSnapshot(key, payload.connections || []);
if (broadcastDashboardState) {
broadcastDashboardState().catch(function (error) {
console.error(error);
});
}
} catch (_error) {
// Ignore malformed player snapshot payloads.
}
};
socket.onclose = function () {
clearPlayerSnapshotSocket(key);
setTimeout(function () {
ensurePlayerSnapshotSubscription(key);
}, 2000);
};
socket.onerror = function () {
try {
socket.close();
} catch (_error) {
// ignore close errors
}
};
}
const dashboardStateService = createDashboardStateService({
pool: pool,
common: common,
playerSnapshotCache: playerSnapshotCache,
playerSnapshotSockets: playerSnapshotSockets,
ensurePlayerSnapshotSubscription: ensurePlayerSnapshotSubscription,
playerPublicBaseUrl: playerPublicBaseUrl,
formatDashboardDate: formatDashboardDate
});
const buildDashboardState = dashboardStateService.buildDashboardState;
const uploadSyncService = createUploadSyncService({
common: common,
playerInternalBaseUrl: playerInternalBaseUrl,
playerSnapshotCache: playerSnapshotCache,
notifyPlayerScreens: notifyPlayerScreens
});
const upload = uploadSyncService.createUploadMiddleware(uploadDir);
const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide;
const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate;
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
async function sendDashboardStateToSocket(socket) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
const state = await buildDashboardState();
socket.send(JSON.stringify({ type: 'dashboard-state', state: state }));
}
broadcastDashboardState = async function () {
if (dashboardRefreshInFlight) {
return dashboardRefreshInFlight;
}
dashboardRefreshInFlight = (async function () {
const state = await buildDashboardState();
const payload = JSON.stringify({ type: 'dashboard-state', state: state });
for (const socket of dashboardClients) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(payload);
}
}
return state;
})().finally(function () {
dashboardRefreshInFlight = null;
});
return dashboardRefreshInFlight;
};
function installDashboardWebsocket(server, loadCurrentUser) {
server.on('upgrade', async function (request, socket, head) {
let pathname = '';
try {
pathname = new URL(request.url, 'http://localhost').pathname;
} catch (_error) {
socket.destroy();
return;
}
if (pathname !== '/ws/admin/dashboard') {
socket.destroy();
return;
}
try {
const currentUser = await loadCurrentUser(pool, request);
if (!currentUser) {
socket.destroy();
return;
}
} catch (_error) {
socket.destroy();
return;
}
dashboardWs.handleUpgrade(request, socket, head, function (ws) {
dashboardWs.emit('connection', ws, request);
});
});
dashboardWs.on('connection', function (socket) {
dashboardClients.add(socket);
sendDashboardStateToSocket(socket);
socket.on('close', function () {
dashboardClients.delete(socket);
});
socket.on('error', function () {
dashboardClients.delete(socket);
});
});
setInterval(function () {
broadcastDashboardState().catch(function (error) {
console.error(error);
});
}, dashboardRefreshIntervalMs);
}
return {
upload: upload,
buildDashboardState: buildDashboardState,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
broadcastDashboardState: broadcastDashboardState,
installDashboardWebsocket: installDashboardWebsocket
};
}
module.exports = { createWebBootstrap };
+112
View File
@@ -0,0 +1,112 @@
const { WebSocket } = require('ws');
function normalizeClientName(value) {
return String(value || '').trim();
}
function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug) {
return (screens || []).map(function (screen) {
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
return Object.assign({}, screen, {
client_name: onboardingNameBySlug[screen.slug] || null,
player_connection_count: connectionState.count || 0,
player_connections: Array.isArray(connectionState.connections) ? connectionState.connections : []
});
});
}
function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate) {
return (screens || []).flatMap(function (screen) {
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
return (connectionState.connections || []).map(function (connection) {
const deviceId = String(connection.deviceId || '').trim();
return Object.assign({}, connection, {
screen_slug: screen.slug,
screen_name: screen.name,
client_name: (deviceId && onboardingNameByDeviceId && onboardingNameByDeviceId[deviceId]) || connection.clientName || onboardingNameBySlug[screen.slug] || String(connection.clientId || '').trim() || null,
playlist_name: screen.playlist_name || null,
connectedAtLabel: formatDashboardDate(connection.connectedAt),
lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt),
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
});
});
});
}
function createDashboardStateService(options) {
const pool = options && options.pool;
const common = options && options.common;
const playerSnapshotCache = options && options.playerSnapshotCache;
const playerSnapshotSockets = options && options.playerSnapshotSockets;
const ensurePlayerSnapshotSubscription = options && options.ensurePlayerSnapshotSubscription;
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, '');
const formatDashboardDate = options && options.formatDashboardDate;
if (!pool || !common || !playerSnapshotCache || !playerSnapshotSockets || typeof ensurePlayerSnapshotSubscription !== 'function' || typeof formatDashboardDate !== 'function') {
throw new Error('createDashboardStateService requires the dashboard dependencies.');
}
async function buildDashboardState() {
const data = await common.fetchAdminData(pool);
const screensData = data.screens || [];
screensData.forEach(function (screen) {
ensurePlayerSnapshotSubscription(screen.slug);
});
const [onboardingRows] = await pool.query(
`SELECT s.slug, pod.device_id, pod.client_name
FROM player_onboarding_devices pod
JOIN screens s ON s.id = pod.screen_id
WHERE pod.client_name IS NOT NULL
AND TRIM(pod.client_name) <> ''`
);
const onboardingNameBySlug = {};
const onboardingNameByDeviceId = {};
onboardingRows.forEach(function (row) {
const clientName = normalizeClientName(row.client_name);
const slug = normalizeClientName(row.slug);
const deviceId = normalizeClientName(row.device_id);
if (slug) {
onboardingNameBySlug[slug] = clientName;
}
if (deviceId) {
onboardingNameByDeviceId[deviceId] = clientName;
}
});
const connectionsBySlug = {};
screensData.forEach(function (screen) {
const cached = playerSnapshotCache.get(String(screen.slug || '').trim());
if (cached && Array.isArray(cached.connections)) {
connectionsBySlug[screen.slug] = cached;
}
});
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug).map(function (screen) {
return Object.assign({}, screen, {
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
});
});
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate);
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
return socket && socket.readyState === WebSocket.OPEN;
});
return {
playlists: data.playlists || [],
screens: screens,
clients: clients,
slides: data.slides || [],
playerServiceConnected: playerServiceConnected,
connectedClientsCount: screens.reduce(function (total, screen) {
return total + Number(screen.player_connection_count || 0);
}, 0)
};
}
return {
buildDashboardState: buildDashboardState
};
}
module.exports = { createDashboardStateService };
+192
View File
@@ -0,0 +1,192 @@
const dashboardDateFormatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
});
function normalizeUploadRoot(uploadDir) {
return require('path').resolve(String(uploadDir || '').trim());
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return dashboardDateFormatter.format(date);
}
function buildDashboardPayload(state) {
return JSON.stringify({
type: 'dashboard-state',
state: state
});
}
function readArrayField(body, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let i = 0; i < searchKeys.length; i += 1) {
const key = searchKeys[i];
const value = body && Object.prototype.hasOwnProperty.call(body, key) ? body[key] : undefined;
if (Array.isArray(value)) {
return value.filter(function (item) {
return item !== undefined && item !== null && String(item).trim() !== '';
}).map(function (item) {
return String(item);
});
}
if (value !== undefined && value !== null && String(value).trim() !== '') {
return [String(value)];
}
}
return [];
}
function parseDateTimeLocal(value) {
if (!value) {
return null;
}
const date = new Date(String(value));
return Number.isNaN(date.getTime()) ? null : date;
}
function parseTimeLocal(value) {
const raw = String(value || '').trim();
if (!raw) {
return null;
}
if (!/^\d{2}:\d{2}(:\d{2})?$/.test(raw)) {
return null;
}
return raw.length === 5 ? raw + ':00' : raw;
}
function normalizeScheduleMode(value) {
const mode = String(value || 'always');
if (mode === 'dates' || mode === 'times') {
return mode;
}
return 'always';
}
function getAuditUserId(req) {
return req && req.currentUser ? Number(req.currentUser.id) : null;
}
function getCanvasSignature(width, height) {
const normalizedWidth = Number(width);
const normalizedHeight = Number(height);
if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) {
return null;
}
return normalizedWidth + 'x' + normalizedHeight;
}
async function fetchPlaylistCanvasSignature(pool, playlistId) {
const [rows] = await pool.query(
`SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE ps.playlist_id = ?
AND cs.width IS NOT NULL
AND cs.height IS NOT NULL`,
[playlistId]
);
const signatures = Array.from(new Set(rows.map(function (row) {
return getCanvasSignature(row.canvas_width, row.canvas_height);
}).filter(Boolean)));
if (!signatures.length) {
return null;
}
return signatures.length === 1 ? signatures[0] : 'mismatch';
}
async function fetchScreensByPlaylistId(connection, playlistId) {
const [rows] = await connection.query(
'SELECT slug FROM screens WHERE playlist_id = ? AND slug IS NOT NULL',
[playlistId]
);
return rows.map(function (row) {
return row.slug;
});
}
async function fetchScreensBySlideId(connection, slideId) {
const [rows] = await connection.query(
`SELECT DISTINCT s.slug
FROM screens s
JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id
WHERE ps.slide_id = ?
AND s.slug IS NOT NULL`,
[slideId]
);
return rows.map(function (row) {
return row.slug;
});
}
async function fetchScreensByTemplateId(connection, templateId) {
const [rows] = await connection.query(
`SELECT DISTINCT s.slug
FROM screens s
JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id
JOIN slides sl ON sl.id = ps.slide_id
WHERE sl.template_id = ?
AND s.slug IS NOT NULL`,
[templateId]
);
return rows.map(function (row) {
return row.slug;
});
}
async function fetchOrderedPlaylistSlides(connection, playlistId) {
const [rows] = await connection.query(
'SELECT id, position FROM playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC',
[playlistId]
);
return rows;
}
function redirectAfterSave(req, res, defaultUrl, options) {
const safeOptions = options || {};
const action = String((req && req.body && req.body.action) || req.query.action || '').toLowerCase();
if (action === 'close') {
return res.redirect(safeOptions.closeUrl || defaultUrl);
}
if (action === 'new') {
return res.redirect(safeOptions.newUrl || defaultUrl);
}
const message = safeOptions.message || '';
if (message) {
const joiner = defaultUrl.indexOf('?') === -1 ? '?' : '&';
return res.redirect(defaultUrl + joiner + 'message=' + encodeURIComponent(message));
}
return res.redirect(defaultUrl);
}
module.exports = {
normalizeUploadRoot: normalizeUploadRoot,
formatDashboardDate: formatDashboardDate,
buildDashboardPayload: buildDashboardPayload,
readArrayField: readArrayField,
parseDateTimeLocal: parseDateTimeLocal,
parseTimeLocal: parseTimeLocal,
normalizeScheduleMode: normalizeScheduleMode,
getAuditUserId: getAuditUserId,
getCanvasSignature: getCanvasSignature,
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
fetchScreensBySlideId: fetchScreensBySlideId,
fetchScreensByTemplateId: fetchScreensByTemplateId,
fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides,
redirectAfterSave: redirectAfterSave
};
+109
View File
@@ -0,0 +1,109 @@
function createPlayerActionService(options) {
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const common = options && options.common;
if (!playerInternalBaseUrl || !common) {
throw new Error('createPlayerActionService requires the player action dependencies.');
}
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
? Object.assign({}, commandOrPayload)
: { command: commandOrPayload };
if (connectionId) {
payload.connectionId = connectionId;
}
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorText = await response.text().catch(function () { return ''; });
const error = new Error(errorText || `Unable to send command to player ${slug}.`);
error.statusCode = response.status;
throw error;
}
return response.json().catch(function () {
return { ok: true };
});
}
async function getScreenConnections(slug) {
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
method: 'GET',
headers: {
Accept: 'application/json'
}
});
if (!response.ok) {
const errorText = await response.text().catch(function () { return ''; });
const error = new Error(errorText || `Unable to load screen connections for ${slug}.`);
error.statusCode = response.status;
throw error;
}
return response.json().catch(function () {
return { connections: [] };
});
}
async function getScreenDeleteBlockMessage(pool, screen, getScreenConnections) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM player_onboarding_devices WHERE screen_id = ?', [screen.id]);
if (Number(rows[0] && rows[0].ref_count) > 0) {
return 'This screen is still linked to onboarding devices.';
}
if (typeof getScreenConnections === 'function' && String(screen && screen.slug ? screen.slug : '').trim()) {
try {
const response = await getScreenConnections(screen.slug);
const liveConnections = Array.isArray(response && response.connections) ? response.connections : [];
if (liveConnections.length > 0) {
return 'This screen is still in use by connected players.';
}
} catch (_error) {
// Keep the delete guard based on onboarding references if live connection lookup fails.
}
}
return '';
}
async function getSlideDeleteBlockMessage(pool, slide) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM playlist_slides WHERE slide_id = ?', [slide.id]);
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This slide is still used by one or more playlists.' : '';
}
async function getTemplateDeleteBlockMessage(pool, template) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM slides WHERE template_id = ?', [template.id]);
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This template is still used by one or more slides.' : '';
}
async function getCanvasSizeDeleteBlockMessage(pool, canvasSize) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM slide_templates WHERE canvas_size_id = ?', [canvasSize.id]);
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This canvas size is still used by one or more templates.' : '';
}
async function getPlaylistDeleteBlockMessage(pool, playlist) {
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM screens WHERE playlist_id = ?', [playlist.id]);
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This playlist is still assigned to one or more screens.' : '';
}
return {
forwardPlayerCommand: forwardPlayerCommand,
getScreenConnections: getScreenConnections,
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage,
getCanvasSizeDeleteBlockMessage: getCanvasSizeDeleteBlockMessage,
getPlaylistDeleteBlockMessage: getPlaylistDeleteBlockMessage
};
}
module.exports = { createPlayerActionService };
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

+349
View File
@@ -0,0 +1,349 @@
(function () {
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
var date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getClientDisplayName(client) {
if (client && client.client_name) {
return String(client.client_name).trim();
}
var clientId = String(client && client.clientId ? client.clientId : '').trim();
if (clientId) {
return clientId;
}
return '';
}
function setButtonVariant(button, classesToRemove, classToAdd) {
if (!button) {
return;
}
if (button.classList) {
classesToRemove.forEach(function (className) {
button.classList.remove(className);
});
if (classToAdd) {
button.classList.add(classToAdd);
}
return;
}
var className = String(button.className || '');
classesToRemove.forEach(function (removeClass) {
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
});
if (classToAdd) {
className += ' ' + classToAdd;
}
button.className = className.replace(/\s+/g, ' ').trim();
}
function normalizeDisplayIp(value) {
var ip = String(value || '').trim();
if (!ip) {
return '';
}
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
return ip.slice(7).trim();
}
return ip;
}
function initConfirmForms() {
document.addEventListener('submit', function (event) {
var form = event.target;
if (!form || !form.getAttribute) {
return;
}
if (form.hasAttribute && form.hasAttribute('data-async-command')) {
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
}
});
}
function markFormDirty(form) {
if (!form || !form.hasAttribute || form.hasAttribute('data-clean-on-load')) {
return;
}
form.dataset.dirty = 'true';
}
function clearFormDirty(form) {
if (!form) {
return;
}
form.dataset.dirty = 'false';
}
function isFormDirty(form) {
return Boolean(form && form.dataset && form.dataset.dirty === 'true');
}
function initDirtyTracking() {
document.addEventListener('input', function (event) {
var target = event.target;
if (!target || !target.form) {
return;
}
markFormDirty(target.form);
}, true);
document.addEventListener('change', function (event) {
var target = event.target;
if (!target || !target.form) {
return;
}
markFormDirty(target.form);
}, true);
}
function initCancelConfirm() {
document.addEventListener('click', function (event) {
var cancelTarget = event.target.closest('[data-confirm-unsaved]');
if (!cancelTarget) {
return;
}
var form = cancelTarget.form || cancelTarget.closest('form') || document.querySelector('form[data-dirty="true"]');
if (!isFormDirty(form)) {
return;
}
var message = cancelTarget.getAttribute('data-confirm-unsaved') || 'You have unsaved changes. Leave this page?';
if (!window.confirm(message)) {
event.preventDefault();
event.stopPropagation();
}
}, true);
}
function initAsyncCommandForms() {
document.addEventListener('submit', function (event) {
var form = event.target;
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-command')) {
return;
}
if (form.dataset && form.dataset.busy === 'true') {
event.preventDefault();
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
return;
}
event.preventDefault();
form.dataset.busy = 'true';
var formData = new FormData(form);
var body = new URLSearchParams();
formData.forEach(function (value, key) {
body.append(key, value);
});
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
body: body.toString(),
credentials: 'same-origin'
}).finally(function () {
delete form.dataset.busy;
});
}, true);
}
function initAsyncSaveForms() {
function setSaveActionValue(form, value) {
if (!form) {
return;
}
var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]');
if (!hiddenInput) {
hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'save_action';
form.appendChild(hiddenInput);
}
hiddenInput.value = String(value || '').trim().toLowerCase();
}
document.addEventListener('click', function (event) {
var target = event.target;
if (!target || !target.closest) {
return;
}
var button = target.closest('button[name="save_action"]');
if (!button) {
return;
}
var form = button.form || button.closest('form');
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
return;
}
setSaveActionValue(form, button.value || '');
form.dataset.submitterValue = String(button.value || '').trim().toLowerCase();
}, true);
document.addEventListener('submit', function (event) {
var form = event.target;
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
return;
}
if (form.dataset && form.dataset.busy === 'true') {
event.preventDefault();
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
return;
}
event.preventDefault();
form.dataset.busy = 'true';
var hiddenSaveAction = form.querySelector('input[type="hidden"][name="save_action"]');
var formData = new FormData(form);
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
if (event.submitter && event.submitter.name) {
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
formData.set(event.submitter.name, event.submitter.value || '');
}
var hasFileValue = false;
formData.forEach(function (value) {
if (value && typeof value === 'object' && typeof value.name === 'string') {
hasFileValue = true;
}
});
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
var body = isMultipart ? formData : new URLSearchParams();
if (!isMultipart) {
formData.forEach(function (value, key) {
body.append(key, value);
});
}
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
headers: Object.assign({
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/json, text/plain, */*'
}, isMultipart ? {} : {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
}),
body: isMultipart ? body : body.toString(),
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
throw new Error(text || 'Unable to save changes.');
});
}
if (submitterValue === 'close' || submitterValue === 'new') {
var redirectUrl = submitterValue === 'close'
? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href)
: String(form.dataset.asyncSaveNewUrl || response.url || window.location.href);
window.location.replace(redirectUrl);
return;
}
clearFormDirty(form);
return response.text().then(function (text) {
var savedMessage = '';
try {
var doc = new DOMParser().parseFromString(text || '', 'text/html');
var toastBody = doc.querySelector('.toast-body');
if (toastBody && toastBody.textContent) {
savedMessage = toastBody.textContent.trim();
}
} catch (_error) {
savedMessage = '';
}
showToast(savedMessage || 'Saved.', 'success');
});
}).catch(function (error) {
window.alert(error.message || 'Unable to save changes.');
}).finally(function () {
delete form.dataset.busy;
delete form.dataset.submitterValue;
if (hiddenSaveAction) {
hiddenSaveAction.value = '';
}
});
}, true);
}
function initSubmitOnChange() {
var fields = document.querySelectorAll('[data-submit-on-change]');
Array.prototype.forEach.call(fields, function (field) {
field.addEventListener('change', function () {
var form = field.form || field.closest('form');
if (form) {
form.submit();
}
});
});
}
if (window.initSortableTables) {
window.initSortableTables();
}
initConfirmForms();
initDirtyTracking();
initCancelConfirm();
initAsyncCommandForms();
initAsyncSaveForms();
initSubmitOnChange();
}());
@@ -0,0 +1,498 @@
(function () {
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
var date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getClientDisplayName(client) {
if (client && client.client_name) {
return String(client.client_name).trim();
}
var clientId = String(client && client.clientId ? client.clientId : '').trim();
if (clientId) {
return clientId;
}
return '';
}
function setButtonVariant(button, classesToRemove, classToAdd) {
if (!button) {
return;
}
if (button.classList) {
classesToRemove.forEach(function (className) {
button.classList.remove(className);
});
if (classToAdd) {
button.classList.add(classToAdd);
}
return;
}
var className = String(button.className || '');
classesToRemove.forEach(function (removeClass) {
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
});
if (classToAdd) {
className += ' ' + classToAdd;
}
button.className = className.replace(/\s+/g, ' ').trim();
}
function normalizeDisplayIp(value) {
var ip = String(value || '').trim();
if (!ip) {
return '';
}
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
return ip.slice(7).trim();
}
return ip;
}
function renderClientActionCell(client) {
var paused = Boolean(client.paused);
var pauseButtonClass = 'btn btn-sm btn-info';
var pauseButtonIcon = paused ? 'bi-play-fill' : 'bi-pause-fill';
var pauseButtonLabel = paused ? 'Resume' : 'Pause';
var blackout = Boolean(client.blackout);
var blackoutButtonClass = 'btn btn-sm ' + (blackout ? 'btn-success' : 'btn-secondary');
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout';
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
var blackoutCommandValue = blackout ? 'false' : 'true';
return '<div class="actions justify-content-end"><form method="post" action="/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" 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="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" 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"><i class="bi bi-' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" 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"><i class="bi bi-' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
}
function updateClientActionCell(cell, client) {
if (!cell) {
return;
}
var pauseButton = cell.querySelector('button[data-action="pause"]');
if (!pauseButton) {
cell.innerHTML = renderClientActionCell(client);
return;
}
var paused = Boolean(client.paused);
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info');
pauseButton.innerHTML = '<i class="bi bi-' + (paused ? 'play-fill' : 'pause-fill') + ' me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
var pauseForm = pauseButton.form;
if (pauseForm) {
var commandInput = pauseForm.querySelector('input[name="command"]');
if (commandInput) {
commandInput.value = 'pause';
}
var connectionInput = pauseForm.querySelector('input[name="connectionId"]');
if (connectionInput) {
connectionInput.value = client.id || '';
}
pauseForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
}
var reloadButton = cell.querySelector('button[data-action="reload"]');
if (reloadButton) {
reloadButton.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload';
setButtonVariant(reloadButton, ['btn-danger', 'btn-success', 'btn-outline-secondary', 'btn-outline-dark', 'btn-outline-primary', 'btn-secondary'], 'btn-danger');
var reloadForm = reloadButton.form;
if (reloadForm) {
var reloadInput = reloadForm.querySelector('input[name="connectionId"]');
if (reloadInput) {
reloadInput.value = client.id || '';
}
reloadForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
reloadForm.setAttribute('data-confirm-message', 'Reloading will restart the player page. Continue?');
}
}
var blackoutButton = cell.querySelector('button[data-action="blackout"]');
if (!blackoutButton) {
cell.innerHTML = renderClientActionCell(client);
return;
}
var blackout = Boolean(client.blackout);
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
setButtonVariant(blackoutButton, ['btn-success', 'btn-secondary'], blackout ? 'btn-success' : 'btn-secondary');
blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + (blackout ? 'Restore' : 'Blackout');
var blackoutForm = blackoutButton.form;
if (blackoutForm) {
var blackoutCommandInput = blackoutForm.querySelector('input[name="command"]');
if (blackoutCommandInput) {
blackoutCommandInput.value = 'blackout';
}
var blackoutStateInput = blackoutForm.querySelector('input[name="blackout"]');
if (blackoutStateInput) {
blackoutStateInput.value = blackout ? 'false' : 'true';
}
var blackoutConnectionInput = blackoutForm.querySelector('input[name="connectionId"]');
if (blackoutConnectionInput) {
blackoutConnectionInput.value = client.id || '';
}
blackoutForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
}
var previousButton = cell.querySelector('button[data-action="previous"]');
if (!previousButton) {
cell.innerHTML = renderClientActionCell(client);
return;
}
setButtonVariant(previousButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning');
previousButton.setAttribute('aria-label', 'Previous slide');
var previousForm = previousButton.form;
if (previousForm) {
var previousCommandInput = previousForm.querySelector('input[name="command"]');
if (previousCommandInput) {
previousCommandInput.value = 'previous';
}
var previousConnectionInput = previousForm.querySelector('input[name="connectionId"]');
if (previousConnectionInput) {
previousConnectionInput.value = client.id || '';
}
previousForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
}
var nextButton = cell.querySelector('button[data-action="next"]');
if (!nextButton) {
cell.innerHTML = renderClientActionCell(client);
return;
}
setButtonVariant(nextButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning');
nextButton.setAttribute('aria-label', 'Next slide');
var nextForm = nextButton.form;
if (nextForm) {
var nextCommandInput = nextForm.querySelector('input[name="command"]');
if (nextCommandInput) {
nextCommandInput.value = 'next';
}
var nextConnectionInput = nextForm.querySelector('input[name="connectionId"]');
if (nextConnectionInput) {
nextConnectionInput.value = client.id || '';
}
nextForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
}
}
function syncClientActionCell(row, client, hasActionsColumn) {
if (!row || !row.cells) {
return;
}
if (!hasActionsColumn) {
if (row.cells.length > 6) {
row.deleteCell(row.cells.length - 1);
}
return;
}
var actionCell = row.cells.length > 6 ? row.cells[6] : null;
if (!actionCell) {
actionCell = row.insertCell(-1);
actionCell.setAttribute('data-label', 'Actions');
actionCell.className = 'text-end';
}
updateClientActionCell(actionCell, client);
}
function renderClientRow(client, hasActionsColumn) {
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle connected-updated-secondary">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>';
var clientIpValue = normalizeDisplayIp(client.clientIp);
var clientIp = clientIpValue ? escapeHtml(clientIpValue) : '<span class="empty">Unknown</span>';
var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : '<span class="empty">Unknown</span>';
var clientNameValue = getClientDisplayName(client);
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
var screenName = client.screen_name ? escapeHtml(client.screen_name) : '<span class="empty">Unknown</span>';
var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : '<span class="empty">No slide currently showing</span>';
var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : '';
return [
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '">',
'<td data-label="Client"><div>' + clientName + '</div></td>',
'<td data-label="Current Screen"><div>' + screenName + '</div></td>',
'<td data-label="Current Slide">' + currentSlide + '</td>',
'<td data-label="IP">' + clientIp + '</td>',
'<td data-label="Viewport">' + viewport + '</td>',
'<td data-label="Connected/Updated">' + connectedAt + '</td>',
actionCell,
'</tr>'
].join('');
}
function renderScreenRow(screen) {
var clientCount = Number(screen.player_connection_count || 0);
return [
'<tr>',
'<td data-label="Name">' + escapeHtml(screen.name) + '</td>',
'<td data-label="Player URL"><a href="' + escapeHtml(screen.player_url || '') + '" target="_blank">' + escapeHtml(screen.player_url || '') + '</a></td>',
'<td data-label="Playlist">' + escapeHtml(screen.playlist_name || '') + '</td>',
'<td data-label="Connected clients">' + (clientCount ? '<div class="connection-count" data-screen-connection-count="' + escapeHtml(screen.slug) + '">' + clientCount + ' connected</div>' : '<span class="empty">No clients connected.</span>') + '</td>',
'</tr>'
].join('');
}
function updateStats(state) {
var clientCount = document.getElementById('dashboard-client-count');
var screenCount = document.getElementById('dashboard-screen-count');
var slideCount = document.getElementById('dashboard-slide-count');
var playlistCount = document.getElementById('dashboard-playlist-count');
if (playlistCount && Array.isArray(state.playlists)) {
playlistCount.textContent = String(state.playlists.length);
}
if (slideCount && Array.isArray(state.slides)) {
slideCount.textContent = String(state.slides.length);
}
if (screenCount && Array.isArray(state.screens)) {
screenCount.textContent = String(state.screens.length);
}
if (clientCount) {
clientCount.textContent = String(Number(state.connectedClientsCount || 0));
}
}
function updateClientTable(state) {
var tbody = document.getElementById('dashboard-clients-table-body');
if (!tbody || !Array.isArray(state.clients)) {
return;
}
var table = document.getElementById('dashboard-clients-table');
var hasActionsColumn = Boolean(table && String(table.getAttribute('data-has-actions-column') || '').toLowerCase() === 'true');
if (!state.clients.length) {
tbody.innerHTML = '<tr><td colspan="' + (hasActionsColumn ? '7' : '6') + '" class="empty">No connected clients yet.</td></tr>';
return;
}
var existingRows = {};
Array.prototype.slice.call(tbody.querySelectorAll('tr[data-client-key]')).forEach(function (row) {
existingRows[row.getAttribute('data-client-key')] = row;
});
Array.prototype.slice.call(tbody.querySelectorAll('tr')).forEach(function (row) {
if (!row.hasAttribute('data-client-key')) {
row.parentNode.removeChild(row);
}
});
state.clients.forEach(function (client, index) {
var rowKey = getClientRowKey(client);
var row = existingRows[rowKey];
if (!row) {
var tempBody = document.createElement('tbody');
tempBody.innerHTML = renderClientRow(client, hasActionsColumn);
row = tempBody.firstElementChild;
}
if (!row) {
return;
}
row.setAttribute('data-client-key', rowKey);
row.setAttribute('data-client-id', client.clientId || '');
row.setAttribute('data-client-device-id', client.deviceId || '');
row.setAttribute('data-client-screen-slug', client.screen_slug || '');
if (row.cells && row.cells.length >= 6) {
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle connected-updated-secondary">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>';
var clientIpValue = normalizeDisplayIp(client.clientIp);
var clientIp = clientIpValue ? escapeHtml(clientIpValue) : '<span class="empty">Unknown</span>';
var viewport = client.viewport && client.viewport.width && client.viewport.height ? escapeHtml(client.viewport.width + 'x' + client.viewport.height) : '<span class="empty">Unknown</span>';
var clientNameValue = getClientDisplayName(client);
var clientName = clientNameValue ? escapeHtml(clientNameValue) : '<span class="empty">Unknown</span>';
var screenName = client.screen_name ? escapeHtml(client.screen_name) : '<span class="empty">Unknown</span>';
var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : '<span class="empty">No slide currently showing</span>';
row.cells[0].innerHTML = '<div>' + clientName + '</div>';
row.cells[1].innerHTML = '<div>' + screenName + '</div>';
row.cells[2].innerHTML = currentSlide;
row.cells[3].innerHTML = clientIp;
row.cells[4].innerHTML = viewport;
row.cells[5].innerHTML = connectedAt;
syncClientActionCell(row, client, hasActionsColumn);
}
var referenceNode = tbody.children[index] || null;
if (referenceNode !== row) {
tbody.insertBefore(row, referenceNode);
}
});
while (tbody.children.length > state.clients.length) {
tbody.removeChild(tbody.lastElementChild);
}
window.applyTableSort(document.getElementById('dashboard-clients-table'));
}
function updateScreenTable(state) {
var table = document.getElementById('dashboard-screens-table');
if (!table || !Array.isArray(state.screens)) {
return;
}
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
if (!tbody) {
return;
}
if (!state.screens.length) {
tbody.innerHTML = '<tr><td colspan="4" class="empty">No screens yet.</td></tr>';
return;
}
tbody.innerHTML = state.screens.map(renderScreenRow).join('');
window.applyTableSort(table);
}
function updateDashboardQuickActions(state) {
var blackoutButton = document.getElementById('dashboard-blackout-all-button');
if (!blackoutButton || !state || !Array.isArray(state.clients)) {
return;
}
var hasClients = state.clients.length > 0;
var allBlackout = hasClients && state.clients.every(function (client) {
return Boolean(client && client.blackout);
});
var label = allBlackout ? 'Restore all clients' : 'Blackout all clients';
var blackoutForm = blackoutButton.form;
var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null;
var blackoutButtonIcon = allBlackout ? 'bi-eye' : 'bi-eye-slash';
blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(label);
setButtonVariant(blackoutButton, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], 'btn-secondary');
if (blackoutInput) {
blackoutInput.value = allBlackout ? 'false' : 'true';
}
if (blackoutForm) {
blackoutForm.setAttribute('data-confirm-message', allBlackout ? 'Restore all connected clients?' : 'Blackout all connected clients?');
}
blackoutButton.setAttribute('aria-label', label);
}
function handleDashboardState(state) {
if (!state) {
return;
}
updateStats(state);
updateScreenTable(state);
updateClientTable(state);
updateDashboardQuickActions(state);
}
function sendClientRename(screenSlug, connectionId, clientId, deviceId, clientName) {
var body = new URLSearchParams();
body.append('command', 'setClientName');
body.append('connectionId', String(connectionId || '').trim());
body.append('clientId', String(clientId || '').trim());
body.append('deviceId', String(deviceId || '').trim());
body.append('clientName', String(clientName || '').trim());
return fetch('/admin/clients/' + encodeURIComponent(String(screenSlug || '').trim()) + '/commands', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
body: body.toString(),
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
var message = text || 'Unable to rename client.';
try {
var payload = JSON.parse(text);
message = payload && (payload.error || payload.message) ? String(payload.error || payload.message) : message;
} catch (_error) {
// fall back to the raw text body
}
throw new Error(message);
});
}
return response.json().catch(function () {
return { ok: true };
});
});
}
function initClientRenameHandler() {
var table = document.getElementById('dashboard-clients-table');
var tbody = document.getElementById('dashboard-clients-table-body');
if (!table || !tbody) {
return;
}
tbody.addEventListener('dblclick', function (event) {
var cell = event.target && event.target.closest ? event.target.closest('td[data-label="Client"]') : null;
if (!cell || !tbody.contains(cell)) {
return;
}
var row = cell.parentElement;
if (!row) {
return;
}
var connectionId = String(row.getAttribute('data-client-key') || '').trim();
var clientId = String(row.getAttribute('data-client-id') || '').trim();
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
var screenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
var currentName = String(cell.textContent || '').trim();
var nextName = window.prompt('Rename connected client', currentName && currentName !== 'Unknown' ? currentName : '');
if (nextName === null) {
return;
}
nextName = String(nextName || '').trim();
if (!nextName) {
window.alert('Client name is required.');
return;
}
if (!connectionId || !screenSlug) {
window.alert('Unable to rename this client right now.');
return;
}
sendClientRename(screenSlug, connectionId, clientId, deviceId, nextName).then(function () {
if (row && row.cells && row.cells[0]) {
row.cells[0].innerHTML = '<div>' + escapeHtml(nextName) + '</div>';
}
}).catch(function (error) {
window.alert(error && error.message ? error.message : 'Unable to rename client.');
});
});
}
window.webHandleDashboardState = handleDashboardState;
initClientRenameHandler();
}());
@@ -23,43 +23,69 @@
function initPlaylistScheduleModal() {
var dialog = document.getElementById('slide-schedule-dialog');
var frame = document.getElementById('slide-schedule-frame');
var closeButton = document.getElementById('slide-schedule-close');
var content = document.getElementById('slide-schedule-content');
var triggers = document.querySelectorAll('[data-schedule-config]');
if (!dialog || !frame || !closeButton || !triggers.length) {
if (!dialog || !content || !triggers.length) {
return;
}
function initInjectedContent() {
if (typeof window.initPlaylistScheduleForm === 'function') {
window.initPlaylistScheduleForm(content);
}
}
function collectScheduleParams(row, rowKey) {
var params = new URLSearchParams();
var scheduleFields = [
'schedule_mode[]',
'schedule_start_datetime[]',
'schedule_end_datetime[]',
'schedule_start_time[]',
'schedule_end_time[]',
'schedule_days_json[]'
];
params.set('row_key', String(rowKey || ''));
scheduleFields.forEach(function (fieldName) {
var field = row && row.querySelector ? row.querySelector('[name="' + fieldName + '"]') : null;
var value = field && typeof field.value !== 'undefined' ? String(field.value || '') : '';
if (value) {
params.set(fieldName.replace(/\[\]$/, ''), value);
}
});
return params;
}
function openScheduleModal(url) {
frame.src = url;
content.innerHTML = '<div class="card card-outline card-secondary mb-0"><div class="card-body py-4 text-center text-secondary">Loading schedule...</div></div>';
if (typeof dialog.showModal === 'function') {
dialog.showModal();
} else {
dialog.setAttribute('open', 'open');
}
}
function resizeScheduleFrame() {
try {
if (!frame.contentWindow || !frame.contentWindow.document) {
return;
fetch(url, {
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
throw new Error(text || 'Unable to open schedule editor.');
});
}
var doc = frame.contentWindow.document;
var height = Math.max(
doc.body.scrollHeight,
doc.documentElement.scrollHeight,
doc.body.offsetHeight,
doc.documentElement.offsetHeight
);
frame.style.height = height + 'px';
} catch (_error) {
frame.style.height = '70vh';
}
return response.text();
}).then(function (html) {
content.innerHTML = html;
initInjectedContent();
}).catch(function (error) {
content.innerHTML = '<div class="card card-outline card-danger mb-0"><div class="card-body text-danger">' + String(error && error.message ? error.message : 'Unable to open schedule editor.') + '</div></div>';
});
}
function closeScheduleModal() {
frame.src = 'about:blank';
content.innerHTML = '';
if (typeof dialog.close === 'function') {
dialog.close();
} else {
@@ -68,22 +94,23 @@
}
window.openScheduleModal = openScheduleModal;
window.resizeScheduleFrame = resizeScheduleFrame;
window.closeScheduleModal = closeScheduleModal;
Array.prototype.forEach.call(triggers, function (trigger) {
trigger.addEventListener('click', function () {
var url = trigger.getAttribute('data-schedule-config');
var rowKey = trigger.getAttribute('data-schedule-config-row') || '';
if (rowKey && url.indexOf('row_key=') === -1) {
var row = trigger.closest('tr[data-playlist-slide-row]');
if (row) {
var params = collectScheduleParams(row, rowKey || row.getAttribute('data-row-key') || '');
url += (url.indexOf('?') === -1 ? '?' : '&') + params.toString();
} else if (rowKey && url.indexOf('row_key=') === -1) {
url += (url.indexOf('?') === -1 ? '?' : '&') + 'row_key=' + encodeURIComponent(rowKey);
}
openScheduleModal(url);
});
});
frame.addEventListener('load', resizeScheduleFrame);
closeButton.addEventListener('click', closeScheduleModal);
}
function scheduleSummary(values) {
@@ -104,15 +131,19 @@
return 'Always visible';
}
function initPlaylistScheduleForm() {
var form = document.querySelector('form[action*="/config"]');
var select = document.getElementById('schedule-mode-select');
function initPlaylistScheduleForm(root) {
var scope = root || document;
var form = scope.querySelector('form[action*="/config"]');
var select = scope.querySelector('#schedule-mode-select');
if (!form || !select) {
return;
}
var datesPanel = document.getElementById('schedule-dates-panel');
var timesPanel = document.getElementById('schedule-times-panel');
var DEFAULT_START_TIME = '00:00';
var DEFAULT_END_TIME = '23:59';
var datesPanel = scope.querySelector('#schedule-dates-panel');
var timesPanel = scope.querySelector('#schedule-times-panel');
var startDateInput = form.querySelector('[name="schedule_start_datetime"]');
var endDateInput = form.querySelector('[name="schedule_end_datetime"]');
var startTimeInput = form.querySelector('[name="schedule_start_time"]');
@@ -120,16 +151,89 @@
var rowKeyInput = form.querySelector('[name="row_key"]');
var dayCheckboxes = form.querySelectorAll('[name="schedule_days"]');
function notifyParentResize() {
if (window.parent && typeof window.parent.resizeScheduleFrame === 'function') {
window.parent.resizeScheduleFrame();
function clearScheduleValidity() {
[startDateInput, endDateInput, startTimeInput, endTimeInput].forEach(function (input) {
if (input) {
input.setCustomValidity('');
}
});
}
function setScheduleError(input, message) {
if (!input) {
return;
}
input.setCustomValidity(message);
}
function validateScheduleForm() {
var mode = select.value;
var firstDayCheckbox = dayCheckboxes.length ? dayCheckboxes[0] : null;
clearScheduleValidity();
if (mode === 'dates') {
if (!startDateInput.value) {
setScheduleError(startDateInput, 'Start datetime is required for this schedule mode.');
}
if (!endDateInput.value) {
setScheduleError(endDateInput, 'End datetime is required for this schedule mode.');
}
if (startDateInput.value && endDateInput.value && new Date(endDateInput.value) < new Date(startDateInput.value)) {
setScheduleError(endDateInput, 'End datetime must be after start datetime.');
}
}
if (mode === 'times') {
if (!startTimeInput.value) {
setScheduleError(startTimeInput, 'Start time is required for this schedule mode.');
}
if (!endTimeInput.value) {
setScheduleError(endTimeInput, 'End time is required for this schedule mode.');
}
if (startTimeInput.value && endTimeInput.value && endTimeInput.value < startTimeInput.value) {
setScheduleError(endTimeInput, 'End time must be after start time.');
}
if (!Array.prototype.some.call(dayCheckboxes, function (checkbox) {
return checkbox.checked;
})) {
setScheduleError(firstDayCheckbox, 'Select at least one day.');
}
}
return form.checkValidity();
}
[startDateInput, endDateInput, startTimeInput, endTimeInput].forEach(function (input) {
if (input) {
input.addEventListener('input', clearScheduleValidity);
}
});
Array.prototype.forEach.call(dayCheckboxes, function (checkbox) {
checkbox.addEventListener('change', clearScheduleValidity);
});
select.addEventListener('change', clearScheduleValidity);
function notifyParentResize() {
return;
}
function updateVisibility() {
var mode = select.value;
datesPanel.style.display = mode === 'dates' ? '' : 'none';
timesPanel.style.display = mode === 'times' ? '' : 'none';
if (datesPanel) {
datesPanel.classList.toggle('is-hidden', mode !== 'dates');
}
if (timesPanel) {
timesPanel.classList.toggle('is-hidden', mode !== 'times');
}
if (mode === 'times') {
if (!startTimeInput.value) {
startTimeInput.value = DEFAULT_START_TIME;
}
if (!endTimeInput.value) {
endTimeInput.value = DEFAULT_END_TIME;
}
}
window.requestAnimationFrame(function () {
notifyParentResize();
});
@@ -141,37 +245,12 @@
form.addEventListener('submit', function (event) {
event.preventDefault();
if (!validateScheduleForm()) {
form.reportValidity();
return;
}
var mode = select.value;
var datesIncomplete = mode === 'dates' && (!startDateInput.value || !endDateInput.value);
var timesIncomplete = mode === 'times' && (!startTimeInput.value || !endTimeInput.value || !Array.prototype.some.call(dayCheckboxes, function (checkbox) {
return checkbox.checked;
}));
if (mode === 'dates' && !datesIncomplete) {
if (new Date(endDateInput.value) < new Date(startDateInput.value)) {
alert('End datetime must be after start datetime.');
return;
}
}
if (mode === 'times' && !timesIncomplete) {
if (endTimeInput.value < startTimeInput.value) {
alert('End time must be after start time.');
return;
}
}
if (datesIncomplete || timesIncomplete) {
mode = 'always';
select.value = 'always';
startDateInput.value = '';
endDateInput.value = '';
startTimeInput.value = '';
endTimeInput.value = '';
Array.prototype.forEach.call(dayCheckboxes, function (checkbox) {
checkbox.checked = false;
});
updateVisibility();
}
var selectedDays = [];
Array.prototype.forEach.call(dayCheckboxes, function (checkbox) {
@@ -230,7 +309,6 @@
var addSlideForm = document.getElementById('playlist-add-slide-form');
var addSlideButton = document.getElementById('playlist-add-slide-button');
var addSlideSelect = document.getElementById('playlist-add-slide-select');
var addDurationInput = document.getElementById('playlist-add-duration');
var addSlideEmpty = document.getElementById('playlist-add-slide-empty');
if (!tbody || !form) {
@@ -265,6 +343,12 @@
}
}
function markPlaylistDirty() {
if (form && form.dataset) {
form.dataset.dirty = 'true';
}
}
function scheduleSummaryForRow(row) {
var modeInput = row.querySelector('[name="schedule_mode[]"]');
var startDatetime = row.querySelector('[name="schedule_start_datetime[]"]');
@@ -351,36 +435,62 @@
if (addSlideSelect) {
addSlideSelect.disabled = availableCount === 0;
}
if (addDurationInput) {
addDurationInput.disabled = availableCount === 0;
}
if (addSlideEmpty) {
addSlideEmpty.style.display = availableCount === 0 ? '' : 'none';
addSlideEmpty.classList.toggle('is-hidden', availableCount !== 0);
}
if (addSection && addSlideForm && addSlideSelect && addSlideButton) {
var controlsVisible = availableCount > 0;
addSlideForm.style.display = controlsVisible ? '' : 'none';
addSlideForm.classList.toggle('is-hidden', !controlsVisible);
}
}
function updateRowOrder() {
function updateRowOrder(markDirty) {
var rows = getRows();
rows.forEach(function (row, index) {
var orderCell = row.querySelector('.playlist-order-cell');
var moveUp = row.querySelector('[data-playlist-move="up"]');
var moveDown = row.querySelector('[data-playlist-move="down"]');
if (orderCell) {
orderCell.textContent = String(index + 1);
}
if (moveUp) {
moveUp.disabled = index === 0;
}
if (moveDown) {
moveDown.disabled = index === rows.length - 1;
var orderNumber = row.querySelector('.playlist-order-number');
if (orderNumber) {
orderNumber.textContent = String(index + 1);
}
});
syncAddSlideOptions();
updateEmptyState();
if (markDirty) {
markPlaylistDirty();
}
}
function lockDraggedRowWidths(row) {
if (!row) {
return;
}
var rowRect = row.getBoundingClientRect();
row.style.width = rowRect.width + 'px';
row.style.height = rowRect.height + 'px';
row.style.boxSizing = 'border-box';
Array.prototype.forEach.call(row.children, function (cell) {
var cellRect = cell.getBoundingClientRect();
cell.style.width = cellRect.width + 'px';
cell.style.height = cellRect.height + 'px';
cell.style.boxSizing = 'border-box';
});
}
function unlockDraggedRowWidths(row) {
if (!row) {
return;
}
row.style.width = '';
row.style.height = '';
row.style.boxSizing = '';
Array.prototype.forEach.call(row.children, function (cell) {
cell.style.width = '';
cell.style.height = '';
cell.style.boxSizing = '';
});
}
function setRowSchedule(row, values) {
@@ -413,39 +523,43 @@
if (summary) {
summary.textContent = values.summary || scheduleSummaryForRow(row);
}
markPlaylistDirty();
}
function createRow(values) {
var row = document.createElement('tr');
var rowKey = values.row_key || ('new-' + Date.now() + '-' + Math.random().toString(36).slice(2));
var playlistId = tbody.getAttribute('data-playlist-id') || '';
row.setAttribute('data-playlist-slide-row', '');
row.setAttribute('data-row-key', rowKey);
row.setAttribute('data-slide-id', String(values.slide_id));
row.setAttribute('data-canvas-signature', String(values.canvas_signature || ''));
row.innerHTML = '' +
'<td class="playlist-order-cell"></td>' +
'<td>' + values.title + '<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" /></td>' +
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required /></td>' +
'<td class="playlist-order-cell" data-label="Order">' +
'<div class="playlist-order-cell-inner">' +
'<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"><span class="playlist-drag-handle-icon" aria-hidden="true"><svg class="playlist-drag-handle-svg" viewBox="0 0 24 32" focusable="false" aria-hidden="true"><polygon points="12,2 20,9 4,9"></polygon><rect x="4" y="14" width="16" height="4" rx="2"></rect><polygon points="4,23 20,23 12,30"></polygon></svg></span></button>' +
'<span class="playlist-order-number"></span>' +
'</div>' +
'</td>' +
'<td>' + values.title + '<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" /></td>' +
'<td>' +
'<div class="playlist-schedule-summary">' + values.summary + '</div>' +
'<input type="hidden" name="schedule_mode[]" value="' + values.schedule_mode + '" />' +
'<input type="hidden" name="schedule_start_datetime[]" value="' + values.schedule_start_datetime + '" />' +
'<input type="hidden" name="schedule_end_datetime[]" value="' + values.schedule_end_datetime + '" />' +
'<input type="hidden" name="schedule_start_time[]" value="' + values.schedule_start_time + '" />' +
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" />' +
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" />' +
'<input type="hidden" name="schedule_mode[]" value="' + values.schedule_mode + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_start_datetime[]" value="' + values.schedule_start_datetime + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_end_datetime[]" value="' + values.schedule_end_datetime + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_start_time[]" value="' + values.schedule_start_time + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" form="playlist-edit-form" />' +
'</td>' +
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required form="playlist-edit-form" /></td>' +
'<td><div class="actions playlist-item-actions">' +
'<button type="button" class="secondary" data-playlist-move="up">Up</button>' +
'<button type="button" class="secondary" data-playlist-move="down">Down</button>' +
'<button type="button" class="schedule-config-button" disabled>Schedule</button>' +
'<button type="button" class="danger" data-playlist-remove-row>Remove</button>' +
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/admin/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
'</div></td>';
return row;
}
tbody.addEventListener('click', function (event) {
var moveButton = event.target.closest('[data-playlist-move]');
var removeButton = event.target.closest('[data-playlist-remove-row]');
var scheduleButton = event.target.closest('[data-schedule-config]');
var row = event.target.closest('tr[data-playlist-slide-row]');
@@ -454,22 +568,10 @@
return;
}
if (moveButton) {
event.preventDefault();
var direction = moveButton.getAttribute('data-playlist-move');
if (direction === 'up' && row.previousElementSibling && row.previousElementSibling.matches('[data-playlist-slide-row]')) {
tbody.insertBefore(row, row.previousElementSibling);
} else if (direction === 'down' && row.nextElementSibling && row.nextElementSibling.matches('[data-playlist-slide-row]')) {
tbody.insertBefore(row.nextElementSibling, row);
}
updateRowOrder();
return;
}
if (removeButton) {
event.preventDefault();
row.remove();
updateRowOrder();
updateRowOrder(true);
return;
}
@@ -481,7 +583,33 @@
}
});
if (addSlideButton && addSlideSelect && addDurationInput) {
if (window.Sortable) {
Sortable.create(tbody, {
animation: 180,
handle: '[data-playlist-drag-handle]',
draggable: 'tr[data-playlist-slide-row]',
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
dragClass: 'sortable-drag',
forceFallback: true,
fallbackOnBody: true,
fallbackTolerance: 3,
swapThreshold: 0.65,
invertedSwapThreshold: 0.65,
onChoose: function (event) {
lockDraggedRowWidths(event && event.item);
},
onUnchoose: function (event) {
unlockDraggedRowWidths(event && event.item);
},
onEnd: function () {
unlockDraggedRowWidths(tbody.querySelector('.sortable-drag'));
updateRowOrder(true);
}
});
}
if (addSlideButton && addSlideSelect) {
addSlideButton.addEventListener('click', function () {
var selectedOption = addSlideSelect.options[addSlideSelect.selectedIndex];
if (!selectedOption || selectedOption.disabled) {
@@ -496,7 +624,7 @@
slide_id: String(selectedOption.value),
canvas_signature: String(selectedOption.getAttribute('data-canvas-signature') || ''),
title: selectedOption.textContent || 'Slide',
duration_seconds: Math.max(1, Number(addDurationInput.value || 10)),
duration_seconds: 10,
schedule_mode: 'always',
schedule_start_datetime: '',
schedule_end_datetime: '',
@@ -506,7 +634,7 @@
summary: 'Always visible'
});
tbody.appendChild(row);
updateRowOrder();
updateRowOrder(true);
});
}
@@ -521,6 +649,8 @@
updateRowOrder();
}
window.initPlaylistScheduleForm = initPlaylistScheduleForm;
initPlaylistScheduleModal();
initPlaylistScheduleForm();
initPlaylistEditStaging();
+111
View File
@@ -0,0 +1,111 @@
(function () {
function getGroupCheckboxes(group) {
return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]'));
}
function getPermissionKey(checkbox) {
return String((checkbox && (checkbox.getAttribute('data-permission-key') || checkbox.value)) || '').trim().toLowerCase();
}
function getActionKey(checkbox) {
var permissionKey = getPermissionKey(checkbox);
var parts = permissionKey.split('.');
if (parts.length !== 2) {
return '';
}
return String(parts[1] || '').trim().toLowerCase();
}
function syncPermissionGroup(group) {
var checkboxes = getGroupCheckboxes(group);
if (!checkboxes.length) {
return;
}
var readCheckbox = null;
var nonReadChecked = false;
checkboxes.forEach(function (checkbox) {
if (getActionKey(checkbox) === 'read') {
readCheckbox = checkbox;
return;
}
if (checkbox.checked) {
nonReadChecked = true;
}
});
if (!readCheckbox) {
return;
}
if (!readCheckbox.checked && nonReadChecked) {
readCheckbox.checked = true;
}
if (!readCheckbox.checked) {
checkboxes.forEach(function (checkbox) {
if (getActionKey(checkbox) !== 'read') {
checkbox.checked = false;
}
});
}
}
function handleGroupChange(event) {
var checkbox = event.target && event.target.matches ? event.target : null;
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
return;
}
var actionKey = getActionKey(checkbox);
var group = checkbox.closest('.accordion-item');
if (!group) {
return;
}
var checkboxes = getGroupCheckboxes(group);
var readCheckbox = checkboxes.find(function (candidate) {
return getActionKey(candidate) === 'read';
}) || null;
if (!readCheckbox) {
return;
}
if (actionKey === 'read' && !checkbox.checked) {
checkboxes.forEach(function (candidate) {
if (candidate !== checkbox) {
candidate.checked = false;
}
});
return;
}
if (actionKey !== 'read' && checkbox.checked) {
readCheckbox.checked = true;
}
}
function initPermissionGroups() {
document.querySelectorAll('.accordion-item').forEach(function (group) {
syncPermissionGroup(group);
});
document.addEventListener('change', function (event) {
var checkbox = event.target;
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
return;
}
if (String(checkbox.getAttribute('name') || '') !== 'permission_keys[]') {
return;
}
handleGroupChange(event);
syncPermissionGroup(checkbox.closest('.accordion-item'));
});
}
initPermissionGroups();
}());
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,86 @@
export function createTemplateSelectorLockController(templateSelect) {
var locked = false;
var armed = false;
var lockInput = null;
function clearLockInput() {
if (lockInput && lockInput.parentNode) {
lockInput.parentNode.removeChild(lockInput);
}
lockInput = null;
}
function reset() {
locked = false;
armed = false;
clearLockInput();
if (!templateSelect) {
return;
}
templateSelect.disabled = false;
templateSelect.removeAttribute('aria-disabled');
templateSelect.removeAttribute('title');
templateSelect.classList.remove('is-locked');
}
function arm() {
armed = true;
}
function syncLockedValue() {
if (lockInput) {
lockInput.value = templateSelect.value;
}
}
function lock() {
if (!templateSelect || locked) {
return;
}
locked = true;
lockInput = lockInput || document.createElement('input');
lockInput.type = 'hidden';
lockInput.name = templateSelect.name;
lockInput.value = templateSelect.value;
templateSelect.insertAdjacentElement('afterend', lockInput);
templateSelect.disabled = true;
templateSelect.setAttribute('aria-disabled', 'true');
templateSelect.setAttribute('title', 'Template is locked after you edit its content.');
templateSelect.classList.add('is-locked');
}
function markEdited() {
if (!armed) {
return false;
}
lock();
syncLockedValue();
return locked;
}
function sync() {
if (!templateSelect) {
return false;
}
if (locked) {
syncLockedValue();
return true;
}
return false;
}
return {
reset: reset,
arm: arm,
markEdited: markEdited,
sync: sync,
isLocked: function () {
return locked;
}
};
}
File diff suppressed because it is too large Load Diff
@@ -64,8 +64,10 @@
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);
if (payload && payload.type === 'dashboard-state') {
if (typeof window.webHandleDashboardState === 'function') {
window.webHandleDashboardState(payload.state);
}
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed');
}
} catch (_error) {
@@ -69,6 +69,45 @@
return table._sortableState;
}
function ensureSortableHeaderIndicator(headerCell) {
var indicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
if (indicator) {
return indicator;
}
indicator = document.createElement('i');
indicator.className = 'table-sort-indicator bi bi-arrow-down-up ms-1';
indicator.setAttribute('aria-hidden', 'true');
headerCell.appendChild(indicator);
return indicator;
}
function updateSortableHeaderIndicator(headerCell, isSortable, isActive, direction) {
if (!isSortable) {
var hiddenIndicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
if (hiddenIndicator) {
hiddenIndicator.style.display = 'none';
}
return;
}
var indicator = ensureSortableHeaderIndicator(headerCell);
indicator.style.display = '';
indicator.className = 'table-sort-indicator bi ms-1';
if (isActive && direction === 'desc') {
indicator.classList.add('bi-caret-down-fill');
return;
}
if (isActive && direction === 'asc') {
indicator.classList.add('bi-caret-up-fill');
return;
}
indicator.classList.add('bi-arrow-down-up');
}
function isSortableTableColumn(table, columnIndex) {
var headerCell = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0].cells[columnIndex] : null;
if (!headerCell) {
@@ -100,13 +139,15 @@
if (!headerCell) {
return;
}
var sortable = isSortableTableColumn(table, index);
headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable');
headerCell.removeAttribute('aria-sort');
headerCell.removeAttribute('role');
headerCell.removeAttribute('tabindex');
if (isSortableTableColumn(table, index)) {
if (sortable) {
headerCell.classList.add('sortable');
updateSortableHeaderIndicator(headerCell, true, state.columnIndex === index, state.direction);
headerCell.setAttribute('role', 'button');
headerCell.setAttribute('tabindex', '0');
if (state.columnIndex === index) {
@@ -117,6 +158,7 @@
}
} else {
headerCell.classList.add('unsortable');
updateSortableHeaderIndicator(headerCell, false);
}
});
}
@@ -168,6 +210,8 @@
return;
}
ensureSortableHeaderIndicator(headerCell);
headerCell.addEventListener('click', function () {
var state = getSortableTableState(table);
var nextDirection = state.columnIndex === index && state.direction === 'asc' ? 'desc' : 'asc';
@@ -4,6 +4,8 @@
return;
}
var utils = window.templateDesignerUtils || {};
var templateData = {};
try {
templateData = JSON.parse(dataElement.getAttribute('data-json') || dataElement.textContent || '{}') || {};
@@ -24,25 +26,26 @@
var canvasWidthInput = document.getElementById('canvas-width');
var canvasHeightInput = document.getElementById('canvas-height');
var backgroundInput = document.getElementById('background-image');
var backgroundColorInput = document.getElementById('background-color');
var backgroundPreview = document.getElementById('background-preview');
var backgroundEmpty = document.getElementById('background-empty');
var addTextRegionButton = document.getElementById('add-text-region');
var addImageRegionButton = document.getElementById('add-image-region');
var removeBackgroundButton = document.getElementById('remove-background-image');
var removeBackgroundFlag = document.getElementById('remove-background-image-flag');
var addRegionButton = document.getElementById('add-region-button');
var regionAddModal = document.getElementById('region-add-modal');
var regionCardTemplate = document.getElementById('region-card-template');
var regionsJsonInput = document.getElementById('regions-json');
var templateForm = document.getElementById('template-form');
var draft = null;
var selectedIndex = -1;
var overlayRenderFrame = 0;
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
}
function valueOrDefault(value, fallback) {
return value === undefined || value === null || value === '' ? fallback : value;
return utils.valueOrDefault ? utils.valueOrDefault(value, fallback) : (value === undefined || value === null || value === '' ? fallback : value);
}
function getCanvasSize() {
@@ -53,7 +56,7 @@
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
return utils.clamp ? utils.clamp(value, min, max) : Math.max(min, Math.min(max, value));
}
function getCards() {
@@ -65,21 +68,62 @@
}
function getRegionName(card) {
return String(card.querySelector('[name="region_name[]"]').value || '').trim();
return utils.getRegionName ? utils.getRegionName(card) : String(card.querySelector('[name="region_name[]"]').value || '').trim();
}
function syncRegionIdentity(card, value) {
if (utils.syncRegionIdentity) {
utils.syncRegionIdentity(card, value);
return;
}
var next = String(value || '').trim();
card.querySelector('[name="region_name[]"]').value = next;
card.querySelector('[name="region_key[]"]').value = next;
card.querySelector('[name="region_label[]"]').value = next;
}
function validateRegionNames() {
var cards = getCards();
var names = {};
var hasDuplicate = false;
cards.forEach(function (card) {
var input = card.querySelector('[name="region_name[]"]');
if (!input) {
return;
}
var normalized = String(input.value || '').trim().toLowerCase();
if (!normalized) {
input.setCustomValidity('Region name is required.');
return;
}
if (!names[normalized]) {
names[normalized] = [];
}
names[normalized].push(input);
});
Object.keys(names).forEach(function (key) {
var inputs = names[key];
if (inputs.length > 1) {
hasDuplicate = true;
inputs.forEach(function (input) {
input.setCustomValidity('Region names must be unique on this template.');
});
} else {
inputs[0].setCustomValidity('');
}
});
return !hasDuplicate;
}
function readCard(card) {
var name = getRegionName(card);
return {
region_key: name,
label: name,
return utils.readCard ? utils.readCard(card) : {
region_key: getRegionName(card),
label: getRegionName(card),
region_type: card.querySelector('[name="region_type[]"]').value,
font_family: card.querySelector('[name="font_family[]"]').value,
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
@@ -91,6 +135,10 @@
}
function writeCard(card, values) {
if (utils.writeCard) {
utils.writeCard(card, values);
return;
}
if (values.region_name !== undefined) {
syncRegionIdentity(card, values.region_name);
} else if (values.region_key !== undefined) {
@@ -99,7 +147,7 @@
syncRegionIdentity(card, values.label);
}
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; updateRegionFieldVisibility(card); }
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
@@ -108,28 +156,22 @@
}
function getOverlayRect() {
return overlay.getBoundingClientRect();
return utils.getOverlayRect ? utils.getOverlayRect(overlay) : overlay.getBoundingClientRect();
}
function toCanvasPoint(event) {
var rect = getOverlayRect();
var size = getCanvasSize();
var x = clamp(event.clientX - rect.left, 0, rect.width);
var y = clamp(event.clientY - rect.top, 0, rect.height);
return {
x: Math.round((x / Math.max(rect.width, 1)) * size.width),
y: Math.round((y / Math.max(rect.height, 1)) * size.height)
return utils.toCanvasPoint ? utils.toCanvasPoint(event, overlay, getCanvasSize()) : {
x: 0,
y: 0
};
}
function canvasRectToPixels(region) {
var rect = getOverlayRect();
var size = getCanvasSize();
return {
left: (region.x / size.width) * rect.width,
top: (region.y / size.height) * rect.height,
width: (region.width / size.width) * rect.width,
height: (region.height / size.height) * rect.height
return utils.canvasRectToPixels ? utils.canvasRectToPixels(region, overlay, getCanvasSize()) : {
left: 0,
top: 0,
width: 0,
height: 0
};
}
@@ -168,6 +210,9 @@
if (!file) {
return;
}
if (removeBackgroundFlag) {
removeBackgroundFlag.checked = false;
}
var reader = new FileReader();
reader.onload = function () {
backgroundPreview.src = reader.result;
@@ -177,40 +222,84 @@
reader.readAsDataURL(file);
}
function updateRegionFieldVisibility(card) {
var typeSelect = card.querySelector('[name="region_type[]"]');
var fontField = card.querySelector('[name="font_family[]"]');
if (!typeSelect || !fontField) {
function updateStageBackgroundColor() {
if (!stage) {
return;
}
fontField.value = typeSelect.value === 'text' || typeSelect.value === 'html' ? 'Arial' : '';
stage.style.backgroundColor = backgroundColorInput && backgroundColorInput.value ? backgroundColorInput.value : '#111111';
}
function getRegionChipLabel(regionType) {
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : 'Text';
}
function populateRegionCard(card, region) {
var chip = card.querySelector('[data-region-chip]');
var title = card.querySelector('[data-region-title]');
var nameInput = card.querySelector('[name="region_name[]"]');
var fontFamilyInput = card.querySelector('[name="font_family[]"]');
var regionTypeInput = card.querySelector('[name="region_type[]"]');
var regionKeyInput = card.querySelector('[name="region_key[]"]');
var regionLabelInput = card.querySelector('[name="region_label[]"]');
if (title) {
title.textContent = region.label || region.region_key || 'Region';
}
if (chip) {
chip.textContent = getRegionChipLabel(region.region_type);
}
if (nameInput) {
nameInput.value = region.region_key || region.label || '';
}
if (fontFamilyInput) {
fontFamilyInput.value = region.region_type === 'image' ? '' : (region.font_family || 'Arial');
}
if (regionTypeInput) {
regionTypeInput.value = region.region_type || 'text';
}
if (regionKeyInput) {
regionKeyInput.value = region.region_key || region.label || '';
}
if (regionLabelInput) {
regionLabelInput.value = region.label || region.region_key || '';
}
card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80);
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
card.querySelector('[name="region_width[]"]').value = valueOrDefault(region.width, 300);
card.querySelector('[name="region_height[]"]').value = valueOrDefault(region.height, 120);
}
function updateRegionLabel(card) {
var label = getRegionName(card) || 'Region';
var cards = getCards();
var index = cards.indexOf(card);
var title = card.querySelector('.template-field-head strong');
if (title) {
title.textContent = label;
}
if (index >= 0 && regionSelect.options[index]) {
regionSelect.options[index].textContent = label;
}
}
function makeRegionCard(region) {
var card = document.createElement('div');
card.className = 'region-item';
var chipLabel = region.region_type === 'image' ? 'Image' : region.region_type === 'webpage' ? 'Webpage' : region.region_type === 'html' ? 'HTML' : 'Text';
card.innerHTML = '' +
'<label>Region<input name="region_name[]" value="' + escapeHtml(region.region_key || region.label || '') + '" placeholder="region_1" required /></label>' +
'<label>Type<select name="region_type[]"><option value="text"' + (region.region_type === 'image' || region.region_type === 'webpage' || region.region_type === 'html' ? '' : ' selected') + '>Text</option><option value="html"' + (region.region_type === 'html' ? ' selected' : '') + '>HTML</option><option value="image"' + (region.region_type === 'image' ? ' selected' : '') + '>Image</option><option value="webpage"' + (region.region_type === 'webpage' ? ' selected' : '') + '>Webpage</option></select></label>' +
'<input type="hidden" name="font_family[]" value="' + escapeHtml(region.region_type === 'image' ? '' : (region.font_family || 'Arial')) + '" />' +
'<input type="hidden" name="region_key[]" value="' + escapeHtml(region.region_key || region.label || '') + '" />' +
'<input type="hidden" name="region_label[]" value="' + escapeHtml(region.label || region.region_key || '') + '" />' +
'<div class="template-field-head"><strong>' + escapeHtml(region.label || region.region_key || '') + '</strong><span class="chip">' + escapeHtml(chipLabel) + '</span></div>' +
'<div class="row"><label style="flex:1">X<input type="number" name="region_x[]" value="' + escapeHtml(valueOrDefault(region.x, 80)) + '" required /></label><label style="flex:1">Y<input type="number" name="region_y[]" value="' + escapeHtml(valueOrDefault(region.y, 80)) + '" required /></label></div>' +
'<div class="row"><label style="flex:1">Width<input type="number" name="region_width[]" value="' + escapeHtml(valueOrDefault(region.width, 300)) + '" required /></label><label style="flex:1">Height<input type="number" name="region_height[]" value="' + escapeHtml(valueOrDefault(region.height, 120)) + '" required /></label><label style="flex:1">Z-Index<input type="number" name="region_z[]" value="' + escapeHtml(valueOrDefault(region.z_index, 1)) + '" required /></label></div>' +
'<div class="row"><button type="button" class="danger remove-region">Remove</button></div>';
var card;
if (regionCardTemplate && regionCardTemplate.content) {
card = regionCardTemplate.content.firstElementChild.cloneNode(true);
} else {
card = document.createElement('div');
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
}
populateRegionCard(card, region);
var nameInput = card.querySelector('[name="region_name[]"]');
var typeSelect = card.querySelector('[name="region_type[]"]');
nameInput.addEventListener('input', function () {
syncRegionIdentity(card, nameInput.value);
updateRegionLabel(card);
validateRegionNames();
renderRegionSidebar();
renderOverlay();
});
typeSelect.addEventListener('change', function () {
updateRegionFieldVisibility(card);
renderOverlay();
});
card.addEventListener('click', function (event) {
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) {
return;
@@ -228,7 +317,6 @@
renderRegionSidebar();
renderOverlay();
});
updateRegionFieldVisibility(card);
return card;
}
@@ -264,6 +352,7 @@
});
regionSelect.value = String(selectedIndex);
updateCanvasSizeLock();
validateRegionNames();
}
function renderOverlay() {
@@ -285,6 +374,17 @@
}
}
function requestOverlayRender() {
if (overlayRenderFrame) {
return;
}
overlayRenderFrame = window.requestAnimationFrame(function () {
overlayRenderFrame = 0;
renderOverlay();
});
}
function render() {
updateAspectRatio();
renderRegionSidebar();
@@ -313,6 +413,13 @@
setSelected(getCards().length - 1);
}
function openAddRegionModal() {
if (!regionAddModal || !window.bootstrap || !window.bootstrap.Modal) {
return;
}
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).show();
}
function createDefaultRegion(type) {
var count = getCards().length + 1;
var name = 'region_' + count;
@@ -351,7 +458,7 @@
renderOverlay();
function moveHandler(moveEvent) {
draft.end = toCanvasPoint(moveEvent);
renderOverlay();
requestOverlayRender();
}
function upHandler(upEvent) {
draft.end = toCanvasPoint(upEvent);
@@ -362,7 +469,7 @@
addRegion({ region_key: 'region_' + (getCards().length + 1), label: 'Region ' + (getCards().length + 1), region_type: 'text', x: region.x, y: region.y, width: region.width, height: region.height, z_index: 1 });
}
draft = null;
renderOverlay();
requestOverlayRender();
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
@@ -379,7 +486,7 @@
var dy = currentPoint.y - startPoint.y;
var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height });
writeCard(cardAt(index), { x: next.x, y: next.y });
renderOverlay();
requestOverlayRender();
}
function upHandler() {
document.removeEventListener('mousemove', moveHandler);
@@ -405,7 +512,7 @@
if (next.height < 12) { if (dir.indexOf('n') !== -1) { next.y -= 12 - next.height; } next.height = 12; }
next = clampRegion(next);
writeCard(cardAt(index), { x: next.x, y: next.y, width: next.width, height: next.height });
renderOverlay();
requestOverlayRender();
}
function upHandler() {
document.removeEventListener('mousemove', moveHandler);
@@ -415,15 +522,20 @@
document.addEventListener('mouseup', upHandler);
}
addTextRegionButton.addEventListener('click', function () { addRegion(createDefaultRegion('text')); });
var addHtmlRegionButton = document.getElementById('add-html-region');
if (addHtmlRegionButton) {
addHtmlRegionButton.addEventListener('click', function () { addRegion(createDefaultRegion('html')); });
}
addImageRegionButton.addEventListener('click', function () { addRegion(createDefaultRegion('image')); });
var addWebpageRegionButton = document.getElementById('add-webpage-region');
if (addWebpageRegionButton) {
addWebpageRegionButton.addEventListener('click', function () { addRegion(createDefaultRegion('webpage')); });
if (addRegionButton && regionAddModal) {
var addRegionTypeButtons = regionAddModal.querySelectorAll('[data-add-region-type]');
addRegionButton.addEventListener('click', function () {
openAddRegionModal();
});
Array.prototype.forEach.call(addRegionTypeButtons, function (button) {
button.addEventListener('click', function () {
var regionType = button.getAttribute('data-add-region-type');
addRegion(createDefaultRegion(regionType));
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).hide();
}
});
});
}
backgroundInput.addEventListener('change', function () {
var file = backgroundInput.files && backgroundInput.files[0];
@@ -431,6 +543,18 @@
updateBackgroundPreview(file);
}
});
if (removeBackgroundButton && removeBackgroundFlag) {
removeBackgroundButton.addEventListener('click', function () {
removeBackgroundFlag.checked = true;
backgroundInput.value = '';
backgroundPreview.removeAttribute('src');
backgroundPreview.style.display = 'none';
backgroundEmpty.style.display = 'block';
});
}
if (backgroundColorInput) {
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
}
canvasSizeSelect.addEventListener('change', function () { syncCanvasSizeSelection(); render(); });
canvasWidthInput.addEventListener('input', render);
canvasHeightInput.addEventListener('input', render);
@@ -456,12 +580,27 @@
setSelected(-1);
startDraw(event);
});
document.getElementById('template-form').addEventListener('submit', function () {
syncCanvasSizeSelection();
regionsJsonInput.value = JSON.stringify(getCards().map(readCard));
});
if (templateForm) {
templateForm.addEventListener('formdata', function (event) {
if (!validateRegionNames()) {
event.preventDefault();
return;
}
syncCanvasSizeSelection();
event.formData.set('regions_json', JSON.stringify(getCards().map(readCard)));
});
templateForm.addEventListener('submit', function () {
if (!validateRegionNames()) {
return;
}
syncCanvasSizeSelection();
regionsJsonInput.value = JSON.stringify(getCards().map(readCard));
});
}
renderRegionList(existingRegions);
syncCanvasSizeSelection();
updateStageBackgroundColor();
render();
})();
@@ -0,0 +1,117 @@
(function () {
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function valueOrDefault(value, fallback) {
return value === undefined || value === null || value === '' ? fallback : value;
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function getRegionName(card) {
return String(card.querySelector('[name="region_name[]"]').value || '').trim();
}
function syncRegionIdentity(card, value) {
var next = String(value || '').trim();
card.querySelector('[name="region_name[]"]').value = next;
card.querySelector('[name="region_key[]"]').value = next;
card.querySelector('[name="region_label[]"]').value = next;
}
function readCard(card) {
var name = getRegionName(card);
return {
region_key: name,
label: name,
region_type: card.querySelector('[name="region_type[]"]').value,
font_family: card.querySelector('[name="font_family[]"]').value,
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
height: Number(card.querySelector('[name="region_height[]"]').value || 0),
z_index: Number(card.querySelector('[name="region_z[]"]').value || 0)
};
}
function writeCard(card, values) {
if (values.region_name !== undefined) {
syncRegionIdentity(card, values.region_name);
} else if (values.region_key !== undefined) {
syncRegionIdentity(card, values.region_key);
} else if (values.label !== undefined) {
syncRegionIdentity(card, values.label);
}
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
if (values.height !== undefined) { card.querySelector('[name="region_height[]"]').value = Math.round(values.height); }
if (values.z_index !== undefined) { card.querySelector('[name="region_z[]"]').value = Math.round(values.z_index); }
}
function clampRegion(region, size, minSize) {
var bounds = size || { width: 1920, height: 1080 };
var minimum = minSize || 12;
var x = clamp(region.x, 0, bounds.width - minimum);
var y = clamp(region.y, 0, bounds.height - minimum);
var width = Math.max(minimum, region.width);
var height = Math.max(minimum, region.height);
if (x + width > bounds.width) {
width = bounds.width - x;
}
if (y + height > bounds.height) {
height = bounds.height - y;
}
return { x: Math.round(x), y: Math.round(y), width: Math.round(Math.max(minimum, width)), height: Math.round(Math.max(minimum, height)) };
}
function getOverlayRect(overlay) {
return overlay.getBoundingClientRect();
}
function toCanvasPoint(event, overlay, size) {
var rect = getOverlayRect(overlay);
var canvasSize = size || { width: 1920, height: 1080 };
var x = clamp(event.clientX - rect.left, 0, rect.width);
var y = clamp(event.clientY - rect.top, 0, rect.height);
return {
x: Math.round((x / Math.max(rect.width, 1)) * canvasSize.width),
y: Math.round((y / Math.max(rect.height, 1)) * canvasSize.height)
};
}
function canvasRectToPixels(region, overlay, size) {
var rect = getOverlayRect(overlay);
var canvasSize = size || { width: 1920, height: 1080 };
return {
left: (region.x / canvasSize.width) * rect.width,
top: (region.y / canvasSize.height) * rect.height,
width: (region.width / canvasSize.width) * rect.width,
height: (region.height / canvasSize.height) * rect.height
};
}
window.templateDesignerUtils = {
escapeHtml: escapeHtml,
valueOrDefault: valueOrDefault,
clamp: clamp,
getRegionName: getRegionName,
syncRegionIdentity: syncRegionIdentity,
readCard: readCard,
writeCard: writeCard,
clampRegion: clampRegion,
getOverlayRect: getOverlayRect,
toCanvasPoint: toCanvasPoint,
canvasRectToPixels: canvasRectToPixels
};
}());
@@ -0,0 +1,606 @@
(function () {
var dataElement = document.getElementById('template-editor-data');
if (!dataElement) {
return;
}
var utils = window.templateDesignerUtils || {};
var templateData = {};
try {
templateData = JSON.parse(dataElement.getAttribute('data-json') || dataElement.textContent || '{}') || {};
} catch (_error) {
templateData = {};
}
var existingRegions = Array.isArray(templateData.regions)
? templateData.regions
: (Array.isArray(templateData) ? templateData : []);
var stage = document.getElementById('designer-stage');
var overlay = document.getElementById('designer-overlay');
var regionList = document.getElementById('region-list');
var regionSelect = document.getElementById('region-select');
var canvasSizeSelect = document.getElementById('canvas-size-select');
var canvasSizeIdInput = document.getElementById('canvas-size-id');
var canvasSizeSummary = document.getElementById('canvas-size-summary');
var canvasWidthInput = document.getElementById('canvas-width');
var canvasHeightInput = document.getElementById('canvas-height');
var backgroundInput = document.getElementById('background-image');
var backgroundColorInput = document.getElementById('background-color');
var backgroundPreview = document.getElementById('background-preview');
var backgroundEmpty = document.getElementById('background-empty');
var removeBackgroundButton = document.getElementById('remove-background-image');
var removeBackgroundFlag = document.getElementById('remove-background-image-flag');
var addRegionButton = document.getElementById('add-region-button');
var regionAddModal = document.getElementById('region-add-modal');
var regionCardTemplate = document.getElementById('region-card-template');
var regionsJsonInput = document.getElementById('regions-json');
var templateForm = document.getElementById('template-form');
var draft = null;
var selectedIndex = -1;
var overlayRenderFrame = 0;
function escapeHtml(value) {
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
}
function valueOrDefault(value, fallback) {
return utils.valueOrDefault ? utils.valueOrDefault(value, fallback) : (value === undefined || value === null || value === '' ? fallback : value);
}
function getCanvasSize() {
return {
width: Math.max(1, Number(canvasWidthInput.value || 1920)),
height: Math.max(1, Number(canvasHeightInput.value || 1080))
};
}
function clamp(value, min, max) {
return utils.clamp ? utils.clamp(value, min, max) : Math.max(min, Math.min(max, value));
}
function getCards() {
return Array.prototype.slice.call(regionList.querySelectorAll('.region-item'));
}
function cardAt(index) {
return getCards()[index] || null;
}
function getRegionName(card) {
return utils.getRegionName ? utils.getRegionName(card) : String(card.querySelector('[name="region_name[]"]').value || '').trim();
}
function syncRegionIdentity(card, value) {
if (utils.syncRegionIdentity) {
utils.syncRegionIdentity(card, value);
return;
}
var next = String(value || '').trim();
card.querySelector('[name="region_name[]"]').value = next;
card.querySelector('[name="region_key[]"]').value = next;
card.querySelector('[name="region_label[]"]').value = next;
}
function validateRegionNames() {
var cards = getCards();
var names = {};
var hasDuplicate = false;
cards.forEach(function (card) {
var input = card.querySelector('[name="region_name[]"]');
if (!input) {
return;
}
var normalized = String(input.value || '').trim().toLowerCase();
if (!normalized) {
input.setCustomValidity('Region name is required.');
return;
}
if (!names[normalized]) {
names[normalized] = [];
}
names[normalized].push(input);
});
Object.keys(names).forEach(function (key) {
var inputs = names[key];
if (inputs.length > 1) {
hasDuplicate = true;
inputs.forEach(function (input) {
input.setCustomValidity('Region names must be unique on this template.');
});
} else {
inputs[0].setCustomValidity('');
}
});
return !hasDuplicate;
}
function readCard(card) {
return utils.readCard ? utils.readCard(card) : {
region_key: getRegionName(card),
label: getRegionName(card),
region_type: card.querySelector('[name="region_type[]"]').value,
font_family: card.querySelector('[name="font_family[]"]').value,
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
height: Number(card.querySelector('[name="region_height[]"]').value || 0),
z_index: Number(card.querySelector('[name="region_z[]"]').value || 0)
};
}
function writeCard(card, values) {
if (utils.writeCard) {
utils.writeCard(card, values);
return;
}
if (values.region_name !== undefined) {
syncRegionIdentity(card, values.region_name);
} else if (values.region_key !== undefined) {
syncRegionIdentity(card, values.region_key);
} else if (values.label !== undefined) {
syncRegionIdentity(card, values.label);
}
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
if (values.height !== undefined) { card.querySelector('[name="region_height[]"]').value = Math.round(values.height); }
if (values.z_index !== undefined) { card.querySelector('[name="region_z[]"]').value = Math.round(values.z_index); }
}
function getOverlayRect() {
return utils.getOverlayRect ? utils.getOverlayRect(overlay) : overlay.getBoundingClientRect();
}
function toCanvasPoint(event) {
return utils.toCanvasPoint ? utils.toCanvasPoint(event, overlay, getCanvasSize()) : {
x: 0,
y: 0
};
}
function canvasRectToPixels(region) {
return utils.canvasRectToPixels ? utils.canvasRectToPixels(region, overlay, getCanvasSize()) : {
left: 0,
top: 0,
width: 0,
height: 0
};
}
function updateAspectRatio() {
var size = getCanvasSize();
stage.style.aspectRatio = size.width + ' / ' + size.height;
}
function updateCanvasSizeSummary() {
var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex];
canvasSizeSummary.textContent = option ? option.textContent : '';
}
function updateCanvasSizeLock() {
var lockOnExistingTemplate = canvasSizeSelect.dataset.lockOnExistingTemplate === 'true';
var locked = lockOnExistingTemplate && getCards().length > 0;
canvasSizeSelect.disabled = locked;
canvasSizeSummary.classList.toggle('is-locked', locked);
}
function syncCanvasSizeSelection() {
var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex];
if (!option) {
return;
}
if (canvasSizeIdInput) {
canvasSizeIdInput.value = option.value;
}
canvasWidthInput.value = Math.max(1, Number(option.dataset.width || canvasWidthInput.value || 1920));
canvasHeightInput.value = Math.max(1, Number(option.dataset.height || canvasHeightInput.value || 1080));
updateAspectRatio();
updateCanvasSizeSummary();
}
function updateBackgroundPreview(file) {
if (!file) {
return;
}
if (removeBackgroundFlag) {
removeBackgroundFlag.checked = false;
}
var reader = new FileReader();
reader.onload = function () {
backgroundPreview.src = reader.result;
backgroundPreview.style.display = 'block';
backgroundEmpty.style.display = 'none';
};
reader.readAsDataURL(file);
}
function updateStageBackgroundColor() {
if (!stage) {
return;
}
stage.style.backgroundColor = backgroundColorInput && backgroundColorInput.value ? backgroundColorInput.value : '#111111';
}
function getRegionChipLabel(regionType) {
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : 'Text';
}
function populateRegionCard(card, region) {
var chip = card.querySelector('[data-region-chip]');
var title = card.querySelector('[data-region-title]');
var nameInput = card.querySelector('[name="region_name[]"]');
var fontFamilyInput = card.querySelector('[name="font_family[]"]');
var regionTypeInput = card.querySelector('[name="region_type[]"]');
var regionKeyInput = card.querySelector('[name="region_key[]"]');
var regionLabelInput = card.querySelector('[name="region_label[]"]');
if (title) {
title.textContent = region.label || region.region_key || 'Region';
}
if (chip) {
chip.textContent = getRegionChipLabel(region.region_type);
}
if (nameInput) {
nameInput.value = region.region_key || region.label || '';
}
if (fontFamilyInput) {
fontFamilyInput.value = region.region_type === 'image' ? '' : (region.font_family || 'Arial');
}
if (regionTypeInput) {
regionTypeInput.value = region.region_type || 'text';
}
if (regionKeyInput) {
regionKeyInput.value = region.region_key || region.label || '';
}
if (regionLabelInput) {
regionLabelInput.value = region.label || region.region_key || '';
}
card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80);
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
card.querySelector('[name="region_width[]"]').value = valueOrDefault(region.width, 300);
card.querySelector('[name="region_height[]"]').value = valueOrDefault(region.height, 120);
}
function updateRegionLabel(card) {
var label = getRegionName(card) || 'Region';
var cards = getCards();
var index = cards.indexOf(card);
var title = card.querySelector('.template-field-head strong');
if (title) {
title.textContent = label;
}
if (index >= 0 && regionSelect.options[index]) {
regionSelect.options[index].textContent = label;
}
}
function makeRegionCard(region) {
var card;
if (regionCardTemplate && regionCardTemplate.content) {
card = regionCardTemplate.content.firstElementChild.cloneNode(true);
} else {
card = document.createElement('div');
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
}
populateRegionCard(card, region);
var nameInput = card.querySelector('[name="region_name[]"]');
nameInput.addEventListener('input', function () {
syncRegionIdentity(card, nameInput.value);
updateRegionLabel(card);
validateRegionNames();
renderRegionSidebar();
renderOverlay();
});
card.addEventListener('click', function (event) {
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) {
return;
}
setSelected(getCards().indexOf(card));
});
card.querySelector('.remove-region').addEventListener('click', function (event) {
event.preventDefault();
card.remove();
if (!getCards().length) {
selectedIndex = -1;
} else if (selectedIndex >= getCards().length) {
selectedIndex = getCards().length - 1;
}
renderRegionSidebar();
renderOverlay();
});
return card;
}
function renderRegionList(initialRegions) {
regionList.innerHTML = '';
initialRegions.forEach(function (region) {
regionList.appendChild(makeRegionCard(region));
});
}
function renderRegionSidebar() {
var cards = getCards();
regionSelect.innerHTML = '';
if (!cards.length) {
regionSelect.disabled = true;
regionList.innerHTML = '';
updateCanvasSizeLock();
return;
}
regionSelect.disabled = false;
if (selectedIndex < 0 || selectedIndex >= cards.length) {
selectedIndex = 0;
}
cards.forEach(function (card, index) {
var option = document.createElement('option');
option.value = String(index);
option.textContent = getRegionName(card) || ('Region ' + (index + 1));
if (index === selectedIndex) {
option.selected = true;
}
regionSelect.appendChild(option);
card.hidden = index !== selectedIndex;
});
regionSelect.value = String(selectedIndex);
updateCanvasSizeLock();
validateRegionNames();
}
function renderOverlay() {
var cards = getCards();
var selectedCard = selectedIndex >= 0 ? cards[selectedIndex] : null;
var selectedNow = selectedCard ? cards.indexOf(selectedCard) : -1;
var regions = cards.map(readCard);
regionsJsonInput.value = JSON.stringify(regions);
overlay.innerHTML = regions.map(function (region, index) {
var box = canvasRectToPixels(region);
var selected = index === selectedNow ? ' selected' : '';
return '<div class="designer-rect' + selected + '" data-index="' + index + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;"><div class="designer-rect-label">' + escapeHtml(region.label || region.region_key || 'Region') + '</div><span class="resize-handle nw" data-dir="nw"></span><span class="resize-handle ne" data-dir="ne"></span><span class="resize-handle sw" data-dir="sw"></span><span class="resize-handle se" data-dir="se"></span></div>';
}).join('');
if (draft) {
var rect = getOverlayRect();
var size = getCanvasSize();
var draftBox = { x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: Math.abs(draft.end.x - draft.start.x), height: Math.abs(draft.end.y - draft.start.y) };
overlay.innerHTML += '<div class="designer-rect designer-draft" style="left:' + ((draftBox.x / size.width) * rect.width) + 'px;top:' + ((draftBox.y / size.height) * rect.height) + 'px;width:' + ((draftBox.width / size.width) * rect.width) + 'px;height:' + ((draftBox.height / size.height) * rect.height) + 'px;"></div>';
}
}
function requestOverlayRender() {
if (overlayRenderFrame) {
return;
}
overlayRenderFrame = window.requestAnimationFrame(function () {
overlayRenderFrame = 0;
renderOverlay();
});
}
function render() {
updateAspectRatio();
renderRegionSidebar();
renderOverlay();
}
function setSelected(index) {
var cards = getCards();
if (!cards.length) {
selectedIndex = -1;
} else if (index < 0) {
selectedIndex = 0;
} else {
selectedIndex = clamp(index, 0, cards.length - 1);
}
renderRegionSidebar();
renderOverlay();
}
function addRegion(region) {
var hint = regionList.querySelector('.muted');
if (hint) {
hint.remove();
}
regionList.appendChild(makeRegionCard(region));
setSelected(getCards().length - 1);
}
function openAddRegionModal() {
if (!regionAddModal || !window.bootstrap || !window.bootstrap.Modal) {
return;
}
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).show();
}
function createDefaultRegion(type) {
var count = getCards().length + 1;
var name = 'region_' + count;
return {
region_key: name,
label: name,
region_type: type,
font_family: type === 'text' || type === 'html' ? 'Arial' : '',
x: 80,
y: 80,
width: type === 'image' || type === 'webpage' || type === 'html' ? 420 : 300,
height: type === 'image' || type === 'webpage' || type === 'html' ? 240 : 120,
z_index: 1
};
}
function clampRegion(region) {
var size = getCanvasSize();
var minSize = 12;
var x = clamp(region.x, 0, size.width - minSize);
var y = clamp(region.y, 0, size.height - minSize);
var width = Math.max(minSize, region.width);
var height = Math.max(minSize, region.height);
if (x + width > size.width) {
width = size.width - x;
}
if (y + height > size.height) {
height = size.height - y;
}
return { x: Math.round(x), y: Math.round(y), width: Math.round(Math.max(minSize, width)), height: Math.round(Math.max(minSize, height)) };
}
function startDraw(event) {
var start = toCanvasPoint(event);
draft = { start: start, end: start };
renderOverlay();
function moveHandler(moveEvent) {
draft.end = toCanvasPoint(moveEvent);
requestOverlayRender();
}
function upHandler(upEvent) {
draft.end = toCanvasPoint(upEvent);
var width = Math.abs(draft.end.x - draft.start.x);
var height = Math.abs(draft.end.y - draft.start.y);
if (width >= 8 && height >= 8) {
var region = clampRegion({ x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: width, height: height });
addRegion({ region_key: 'region_' + (getCards().length + 1), label: 'Region ' + (getCards().length + 1), region_type: 'text', x: region.x, y: region.y, width: region.width, height: region.height, z_index: 1 });
}
draft = null;
requestOverlayRender();
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
function startMove(index, event) {
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
function moveHandler(moveEvent) {
var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y;
var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height });
writeCard(cardAt(index), { x: next.x, y: next.y });
requestOverlayRender();
}
function upHandler() {
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
function resizeFromHandle(index, dir, event) {
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
function moveHandler(moveEvent) {
var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y;
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
if (dir.indexOf('w') !== -1) { next.x = startRegion.x + dx; next.width = startRegion.width - dx; }
if (dir.indexOf('e') !== -1) { next.width = startRegion.width + dx; }
if (dir.indexOf('n') !== -1) { next.y = startRegion.y + dy; next.height = startRegion.height - dy; }
if (dir.indexOf('s') !== -1) { next.height = startRegion.height + dy; }
if (next.width < 12) { if (dir.indexOf('w') !== -1) { next.x -= 12 - next.width; } next.width = 12; }
if (next.height < 12) { if (dir.indexOf('n') !== -1) { next.y -= 12 - next.height; } next.height = 12; }
next = clampRegion(next);
writeCard(cardAt(index), { x: next.x, y: next.y, width: next.width, height: next.height });
requestOverlayRender();
}
function upHandler() {
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
if (addRegionButton && regionAddModal) {
var addRegionTypeButtons = regionAddModal.querySelectorAll('[data-add-region-type]');
addRegionButton.addEventListener('click', function () {
openAddRegionModal();
});
Array.prototype.forEach.call(addRegionTypeButtons, function (button) {
button.addEventListener('click', function () {
var regionType = button.getAttribute('data-add-region-type');
addRegion(createDefaultRegion(regionType));
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).hide();
}
});
});
}
backgroundInput.addEventListener('change', function () {
var file = backgroundInput.files && backgroundInput.files[0];
if (file) {
updateBackgroundPreview(file);
}
});
if (removeBackgroundButton && removeBackgroundFlag) {
removeBackgroundButton.addEventListener('click', function () {
removeBackgroundFlag.checked = true;
backgroundInput.value = '';
backgroundPreview.removeAttribute('src');
backgroundPreview.style.display = 'none';
backgroundEmpty.style.display = 'block';
});
}
if (backgroundColorInput) {
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
}
canvasSizeSelect.addEventListener('change', function () { syncCanvasSizeSelection(); render(); });
canvasWidthInput.addEventListener('input', render);
canvasHeightInput.addEventListener('input', render);
regionSelect.addEventListener('change', function () { setSelected(Number(regionSelect.value || 0)); });
overlay.addEventListener('mousedown', function (event) {
var rect = event.target.closest('.designer-rect');
if (rect) {
var index = Number(rect.getAttribute('data-index'));
var handle = event.target.closest('.resize-handle');
event.preventDefault();
setSelected(index);
if (handle) {
resizeFromHandle(index, handle.getAttribute('data-dir'), event);
} else {
startMove(index, event);
}
return;
}
if (event.target !== overlay && !event.target.classList.contains('designer-overlay')) {
return;
}
event.preventDefault();
setSelected(-1);
startDraw(event);
});
if (templateForm) {
templateForm.addEventListener('formdata', function (event) {
if (!validateRegionNames()) {
event.preventDefault();
return;
}
syncCanvasSizeSelection();
event.formData.set('regions_json', JSON.stringify(getCards().map(readCard)));
});
templateForm.addEventListener('submit', function () {
if (!validateRegionNames()) {
return;
}
syncCanvasSizeSelection();
regionsJsonInput.value = JSON.stringify(getCards().map(readCard));
});
}
renderRegionList(existingRegions);
syncCanvasSizeSelection();
updateStageBackgroundColor();
render();
})();
+16
View File
@@ -0,0 +1,16 @@
(function () {
var storageKey = 'lte-theme';
var theme = 'auto';
try {
var storedTheme = window.localStorage.getItem(storageKey);
if (storedTheme === 'dark' || storedTheme === 'light' || storedTheme === 'auto') {
theme = storedTheme;
}
} catch (error) {
theme = 'auto';
}
document.documentElement.dataset.bsTheme = theme;
document.documentElement.style.colorScheme = theme;
}());
@@ -1,5 +1,5 @@
(function () {
var THEME_STORAGE_KEY = 'webui-theme';
var THEME_STORAGE_KEY = 'web-theme';
function getPreferredTheme() {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
@@ -33,7 +33,7 @@
var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark';
var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode';
document.documentElement.dataset.theme = normalizedTheme;
document.documentElement.dataset.bsTheme = normalizedTheme;
document.documentElement.style.colorScheme = normalizedTheme;
Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) {
@@ -62,7 +62,7 @@
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
var nextTheme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
var nextTheme = document.documentElement.dataset.bsTheme === 'dark' ? 'light' : 'dark';
setStoredTheme(nextTheme);
applyTheme(nextTheme);
});
+131
View File
@@ -0,0 +1,131 @@
(function () {
function getBootstrapToast(toast) {
if (!toast || !window.bootstrap || !window.bootstrap.Toast) {
return null;
}
return window.bootstrap.Toast.getOrCreateInstance(toast, {
autohide: true,
delay: 4000
});
}
function removeToast(toast) {
if (toast && toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}
function dismissToast(toast) {
var instance = getBootstrapToast(toast);
if (instance) {
instance.hide();
return;
}
removeToast(toast);
}
function setToastVariant(toast, variant) {
if (!toast || !toast.classList) {
return;
}
var nextVariant = String(variant || 'success').trim().toLowerCase();
var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'];
variants.forEach(function (value) {
toast.classList.remove('text-bg-' + value);
});
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'success' : nextVariant));
}
function getMessageVariant(message, fallbackVariant) {
var text = String(message || '').trim();
if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
return 'danger';
}
return String(fallbackVariant || 'success').trim().toLowerCase() || 'success';
}
function showToast(message, variant) {
var text = String(message || '').trim();
if (!text) {
return;
}
var container = document.getElementById('app-toast-container');
if (!container) {
return;
}
var existingToast = document.getElementById('app-toast');
if (existingToast) {
var existingBody = existingToast.querySelector('.toast-body');
if (existingBody) {
existingBody.textContent = text;
}
var nextVariant = getMessageVariant(text, variant);
existingToast.setAttribute('data-toast-variant', nextVariant);
setToastVariant(existingToast, nextVariant);
var existingInstance = getBootstrapToast(existingToast);
if (existingInstance) {
existingInstance.show();
}
return;
}
var toast = document.createElement('div');
toast.className = 'toast align-items-center border-0';
toast.id = 'app-toast';
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.setAttribute('aria-atomic', 'true');
toast.setAttribute('data-bs-autohide', 'true');
toast.setAttribute('data-bs-delay', '4000');
toast.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>';
var toastVariant = getMessageVariant(text, variant);
toast.setAttribute('data-toast-variant', toastVariant);
setToastVariant(toast, toastVariant);
toast.querySelector('.toast-body').textContent = text;
toast.addEventListener('hidden.bs.toast', function () {
removeToast(toast);
});
container.appendChild(toast);
var instance = getBootstrapToast(toast);
if (instance) {
instance.show();
}
}
function initToast() {
var toast = document.getElementById('app-toast');
if (!toast) {
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
}
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'success');
setToastVariant(toast, existingVariant);
var instance = getBootstrapToast(toast);
if (instance) {
instance.show();
}
}
window.dismissToast = dismissToast;
window.showToast = showToast;
window.initToast = initToast;
initToast();
}());
+55
View File
@@ -0,0 +1,55 @@
Software License Agreement
==========================
**CKEditor&nbsp;5** (https://github.com/ckeditor/ckeditor5)<br>
Copyright (c) 20032026, [CKSource Holding sp. z o.o.](https://cksource.com) All rights reserved.
Licensed under a dual-license model, this software is available under:
* the [GNU General Public License Version 2 or later](https://www.gnu.org/licenses/gpl.html) (see COPYING.GPL),
* or commercial license terms from CKSource Holding sp. z o.o.
For more information, see: [https://ckeditor.com/legal/ckeditor-licensing-options](https://ckeditor.com/legal/ckeditor-licensing-options).
If you are using CKEditor under commercial terms, you are free to remove the COPYING.GPL file with the full copy of a GPL license.
Sources of Intellectual Property Included in CKEditor&nbsp;5
------------------------------------------------------------
Where not otherwise indicated, all CKEditor&nbsp;5 content is authored by CKSource engineers and consists of CKSource-owned intellectual property. In some specific instances, CKEditor&nbsp;5 will incorporate work done by developers outside of CKSource with their express permission.
The following libraries are included in CKEditor&nbsp;5 under the [ISC license](https://opensource.org/licenses/ISC):
* hast-util-from-dom - Copyright (c) Keith McKnight <keith@mcknig.ht>.
* rehype-dom-parse - Copyright (c) 2018 Keith McKnight <keith@mcknig.ht>.
* rehype-dom-stringify - Copyright (c) 2018 Keith McKnight <keith@mcknig.ht>.
The following libraries are included in CKEditor&nbsp;5 under the [MIT license](https://opensource.org/licenses/MIT):
* @types/color-convert - Copyright (c) Microsoft Corporation.
* @types/hast - Copyright (c) Microsoft Corporation.
* blurhash - Copyright (c) 2018 Wolt Enterprises.
* color-convert - Copyright (c) 2011-2016 Heather Arthur <fayearthur@gmail.com> and Copyright (c) 2016-2021 Josh Junon <josh@junon.me>.
* color-parse - Copyright (c) 2015 Dmitry Ivanov.
* emojibase-data - Copyright (c) 2017-2019 Miles Johnson.
* es-toolkit - Copyright (c) 2024 Viva Republica, Inc and Copyright OpenJS Foundation and other contributors.
* fuzzysort - Copyright (c) 2018 Stephen Kamenar.
* hast-util-to-html - Copyright (c) Titus Wormer <tituswormer@gmail.com>.
* hast-util-to-mdast - Copyright (c) Titus Wormer <tituswormer@gmail.com> and Copyright (c) Seth Vincent <sethvincent@gmail.com>.
* hastscript - Copyright (c) Titus Wormer <tituswormer@gmail.com>.
* is-emoji-supported - Copyright (c) 2016-2020 Koala Interactive, Inc.
* Regular expression for URL validation - Copyright (c) 2010-2018 Diego Perini.
* rehype-remark - Copyright (c) Titus Wormer <tituswormer@gmail.com>.
* remark-breaks - Copyright (c) 2017 Titus Wormer <tituswormer@gmail.com>.
* remark-gfm - Copyright (c) Titus Wormer <tituswormer@gmail.com>.
* remark-parse - Copyright (c) 2014 Titus Wormer <tituswormer@gmail.com>.
* remark-rehype - Copyright (c) Titus Wormer <tituswormer@gmail.com>.
* remark-stringify - Copyright (c) 2014 Titus Wormer <tituswormer@gmail.com>.
* unified - Copyright (c) 2015 Titus Wormer <tituswormer@gmail.com>.
* unist-util-visit - Copyright (c) 2015 Titus Wormer <tituswormer@gmail.com>.
* vanilla-colorful - Copyright (c) 2020 Serhii Kulykov <iamkulykov@gmail.com>.
Trademarks
----------
**CKEditor** is a trademark of [CKSource Holding sp. z o.o.](https://cksource.com) All other brand and product names are trademarks, registered trademarks or service marks of their respective holders.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
import type { Translations } from '@ckeditor/ckeditor5-utils';
declare const translations: Translations;
export default translations;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More