This PR breaks the large web and player bootstrap files into smaller modules with clearer ownership.
Publish Docker Image / build-and-push (push) Successful in 58s
Publish Docker Image / build-and-push (push) Successful in 58s
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.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
uploads/
|
||||
docker-compose.dev.yml
|
||||
.vscode/
|
||||
.env
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
@@ -39,6 +39,7 @@ 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}
|
||||
|
||||
Generated
+1927
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "1.1.5",
|
||||
"version": "1.4.0",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media uploads",
|
||||
"repository": {
|
||||
@@ -16,11 +16,13 @@
|
||||
"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": {
|
||||
|
||||
+3
-1
@@ -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
@@ -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
|
||||
|
||||
+36
-2
@@ -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
|
||||
@@ -107,6 +137,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
|
||||
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}`
|
||||
: removeBackgroundImage
|
||||
@@ -153,12 +184,15 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
|
||||
}];
|
||||
}
|
||||
|
||||
ensureUniqueTemplateRegionNames(regions);
|
||||
|
||||
return {
|
||||
name,
|
||||
canvasSizeId: resolvedCanvasSizeId,
|
||||
canvasSizeWidth: canvasWidth,
|
||||
canvasSizeHeight: canvasHeight,
|
||||
backgroundImagePath,
|
||||
backgroundColor,
|
||||
regions
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,6 +67,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,6 +75,7 @@ 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');
|
||||
@@ -196,6 +198,20 @@ async function ensureSchema(pool) {
|
||||
await addColumnIfMissing(pool, 'screens', 'created_by', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'screens', 'modified_by', 'INT NULL');
|
||||
|
||||
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 pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
|
||||
+26
-579
@@ -1,151 +1,12 @@
|
||||
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');
|
||||
|
||||
// Playlist assembly and revision helpers.
|
||||
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 } = require('./player/onboarding');
|
||||
const { registerPlayerRoutes } = require('./player/routes');
|
||||
|
||||
|
||||
// Player runtime, upload API, and websocket wiring.
|
||||
@@ -155,327 +16,30 @@ async function start() {
|
||||
const PORT = Number(process.env.PLAYER_PORT || 3001);
|
||||
const ASSET_DIR = path.join(__dirname, 'player', 'public');
|
||||
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
|
||||
const connectionsBySlug = new Map();
|
||||
const dashboardListenersBySlug = new Map();
|
||||
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());
|
||||
|
||||
// Static assets and mirrored uploads are served from the player container.
|
||||
app.use('/assets', express.static(ASSET_DIR));
|
||||
app.use('/uploads', express.static(UPLOAD_DIR));
|
||||
|
||||
app.get('/api/uploads/config', function (_req, res) {
|
||||
res.json({
|
||||
uploadDir: UPLOAD_DIR
|
||||
});
|
||||
registerPlayerOnboardingRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerRuntime: playerRuntime,
|
||||
QRCode: require('qrcode')
|
||||
});
|
||||
|
||||
app.put('/api/uploads/:filename', express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) {
|
||||
try {
|
||||
const filename = path.basename(String(req.params.filename || '').trim());
|
||||
if (!filename) {
|
||||
return res.status(400).json({ error: 'Filename is required' });
|
||||
}
|
||||
const filePath = path.join(UPLOAD_DIR, filename);
|
||||
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
|
||||
await fs.promises.mkdir(UPLOAD_DIR, { 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 = path.basename(String(req.params.filename || '').trim());
|
||||
if (!filename) {
|
||||
return res.status(400).json({ error: 'Filename is required' });
|
||||
}
|
||||
const filePath = path.join(UPLOAD_DIR, 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);
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
// Screen playback endpoints render the active playlist for a slug.
|
||||
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', 'redirect', 'pause', 'blackout', 'previous', 'next', 'left', 'right'].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
|
||||
? sendCommandToConnection(req.params.slug, connectionId, commandPayload)
|
||||
: 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);
|
||||
}
|
||||
registerPlayerRoutes(app, {
|
||||
pool: pool,
|
||||
common: common,
|
||||
uploadDir: UPLOAD_DIR,
|
||||
assetDir: ASSET_DIR,
|
||||
playerRuntime: playerRuntime,
|
||||
playerPlaylistService: playerPlaylistService
|
||||
});
|
||||
|
||||
app.use(function (error, _req, res, _next) {
|
||||
@@ -485,124 +49,6 @@ async function start() {
|
||||
|
||||
await common.ensureSchema(pool);
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
// Websocket upgrades split dashboard snapshots from player client sessions.
|
||||
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}`);
|
||||
@@ -617,3 +63,4 @@ if (require.main === module) {
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
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 bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen) {
|
||||
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.');
|
||||
}
|
||||
|
||||
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 = typeof isNameAvailableOnScreen === 'function'
|
||||
? await isNameAvailableOnScreen(pool, normalizedClientName, normalizedDeviceId)
|
||||
: true;
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
res.json({
|
||||
deviceId: deviceId,
|
||||
clientName: status ? status.client_name : clientName,
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeDeviceId: normalizeDeviceId,
|
||||
getPublicBaseUrl: getPublicBaseUrl,
|
||||
getOnboardingStatus: getOnboardingStatus,
|
||||
bindDeviceToScreen: bindDeviceToScreen,
|
||||
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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,11 @@
|
||||
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));
|
||||
@@ -542,6 +552,12 @@
|
||||
commandSocket = socket;
|
||||
|
||||
socket.onopen = function () {
|
||||
if (typeof syncOnboardingClientNameFromServer === 'function') {
|
||||
syncOnboardingClientNameFromServer(socket).then(function () {
|
||||
sendCommandHello(socket);
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendCommandHello(socket);
|
||||
};
|
||||
|
||||
@@ -1038,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
|
||||
};
|
||||
|
||||
@@ -1101,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.
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -9,6 +9,19 @@ body {
|
||||
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%;
|
||||
@@ -19,6 +32,166 @@ body {
|
||||
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;
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function sanitizeFontFamily(value) {
|
||||
return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim();
|
||||
}
|
||||
|
||||
function sanitizeFontSize(value) {
|
||||
return Math.max(8, Number(value || 0) || 24);
|
||||
}
|
||||
|
||||
function sanitizeTextColor(value, fallback) {
|
||||
const raw = String(value || '').trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return fallback || '#000000';
|
||||
}
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['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
-294
@@ -1,320 +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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function sanitizeFontFamily(value) {
|
||||
return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim();
|
||||
}
|
||||
|
||||
function sanitizeFontSize(value) {
|
||||
return Math.max(8, Number(value || 0) || 24);
|
||||
}
|
||||
|
||||
function sanitizeTextColor(value, fallback) {
|
||||
const raw = String(value || '').trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return fallback || '#000000';
|
||||
}
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['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 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="ck-content-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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
? playerRuntime.sendCommandToConnection(req.params.slug, connectionId, commandPayload)
|
||||
: 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
|
||||
};
|
||||
@@ -0,0 +1,416 @@
|
||||
const crypto = require('crypto');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
|
||||
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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function isClientNameAvailableOnScreen(poolArg, clientName, excludeDeviceId) {
|
||||
const normalizedName = String(clientName || '').trim();
|
||||
if (!normalizedName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedDeviceId = normalizeDeviceId(excludeDeviceId);
|
||||
const lowerName = normalizedName.toLowerCase();
|
||||
const liveDeviceIds = new Set();
|
||||
const liveClientIds = new Set();
|
||||
|
||||
for (const bucket of connectionsBySlug.values()) {
|
||||
if (!bucket || typeof bucket.values !== 'function') {
|
||||
continue;
|
||||
}
|
||||
for (const connection of bucket.values()) {
|
||||
const existingDeviceId = normalizeDeviceId(connection && connection.deviceId ? connection.deviceId : '');
|
||||
const existingClientId = normalizeDeviceId(connection && connection.clientId ? connection.clientId : '');
|
||||
if (existingDeviceId) {
|
||||
liveDeviceIds.add(existingDeviceId);
|
||||
}
|
||||
if (existingClientId) {
|
||||
liveClientIds.add(existingClientId);
|
||||
}
|
||||
const existingName = String(connection && connection.clientName ? connection.clientName : '').trim();
|
||||
if (!existingName) {
|
||||
continue;
|
||||
}
|
||||
if (existingName.toLowerCase() !== lowerName) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedDeviceId && existingDeviceId && existingDeviceId === normalizedDeviceId) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const activePool = poolArg || pool;
|
||||
if (!activePool || (!liveDeviceIds.size && !liveClientIds.size)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const [deviceRows] = await activePool.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(?))`,
|
||||
[normalizedName]
|
||||
);
|
||||
|
||||
for (let i = 0; i < deviceRows.length; i += 1) {
|
||||
const deviceId = normalizeDeviceId(deviceRows[i] && deviceRows[i].device_id ? deviceRows[i].device_id : '');
|
||||
if (!deviceId) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedDeviceId && deviceId === normalizedDeviceId) {
|
||||
continue;
|
||||
}
|
||||
if (liveDeviceIds.has(deviceId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
isClientNameAvailableOnScreen: isClientNameAvailableOnScreen,
|
||||
sendCommandToConnection: sendCommandToConnection,
|
||||
broadcastCommand: broadcastCommand
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPlayerRuntime: createPlayerRuntime
|
||||
};
|
||||
+147
-2212
File diff suppressed because it is too large
Load Diff
Vendored
+208
@@ -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 };
|
||||
@@ -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] || screen.client_name || 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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
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 getScreenDeleteBlockMessage(pool, screen) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM player_onboarding_devices WHERE screen_id = ?', [screen.id]);
|
||||
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This screen is still linked to onboarding devices.' : '';
|
||||
}
|
||||
|
||||
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,
|
||||
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
|
||||
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
|
||||
getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage,
|
||||
getCanvasSizeDeleteBlockMessage: getCanvasSizeDeleteBlockMessage,
|
||||
getPlaylistDeleteBlockMessage: getPlaylistDeleteBlockMessage
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createPlayerActionService };
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+7
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
File diff suppressed because it is too large
Load Diff
+144
-280
@@ -31,299 +31,55 @@
|
||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
||||
}
|
||||
|
||||
function renderClientActionCell(client) {
|
||||
var paused = Boolean(client.paused);
|
||||
var pauseButtonClass = 'button-link list-action' + (paused ? ' is-paused' : '');
|
||||
var pauseButtonLabel = paused ? 'Resume' : 'Pause';
|
||||
var blackout = Boolean(client.blackout);
|
||||
var blackoutButtonClass = 'button-link list-action' + (blackout ? ' is-blackout' : '');
|
||||
var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout';
|
||||
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
|
||||
var blackoutCommandValue = blackout ? 'false' : 'true';
|
||||
|
||||
return '<div class="actions"><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="inline-form" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="button-link list-action is-danger" data-action="reload">Reload</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="inline-form" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="button-link list-action list-action--nav" data-action="previous" aria-label="Previous slide">◀</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="inline-form" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="button-link list-action list-action--nav" data-action="next" aria-label="Next slide">▶</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="inline-form" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause">' + pauseButtonLabel + '</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="inline-form" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout">' + blackoutButtonLabel + '</button></form></div>';
|
||||
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 updateClientActionCell(cell, client) {
|
||||
if (!cell) {
|
||||
function setButtonVariant(button, classesToRemove, classToAdd) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
var pauseButton = cell.querySelector('button[data-action="pause"]');
|
||||
if (!pauseButton) {
|
||||
cell.innerHTML = renderClientActionCell(client);
|
||||
if (button.classList) {
|
||||
classesToRemove.forEach(function (className) {
|
||||
button.classList.remove(className);
|
||||
});
|
||||
if (classToAdd) {
|
||||
button.classList.add(classToAdd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var paused = Boolean(client.paused);
|
||||
pauseButton.className = 'button-link list-action' + (paused ? ' is-paused' : '');
|
||||
pauseButton.textContent = 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/screens/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
|
||||
var reloadButton = cell.querySelector('button[data-action="reload"]');
|
||||
if (reloadButton) {
|
||||
reloadButton.textContent = 'Reload';
|
||||
reloadButton.className = 'button-link list-action is-danger';
|
||||
var reloadForm = reloadButton.form;
|
||||
if (reloadForm) {
|
||||
var reloadInput = reloadForm.querySelector('input[name="connectionId"]');
|
||||
if (reloadInput) {
|
||||
reloadInput.value = client.id || '';
|
||||
}
|
||||
reloadForm.action = '/admin/screens/' + 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);
|
||||
blackoutButton.className = 'button-link list-action' + (blackout ? ' is-blackout' : '');
|
||||
blackoutButton.textContent = 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/screens/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
|
||||
var previousButton = cell.querySelector('button[data-action="previous"]');
|
||||
if (!previousButton) {
|
||||
cell.innerHTML = renderClientActionCell(client);
|
||||
return;
|
||||
}
|
||||
previousButton.className = 'button-link list-action list-action--nav';
|
||||
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/screens/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
|
||||
var nextButton = cell.querySelector('button[data-action="next"]');
|
||||
if (!nextButton) {
|
||||
cell.innerHTML = renderClientActionCell(client);
|
||||
return;
|
||||
}
|
||||
nextButton.className = 'button-link list-action list-action--nav';
|
||||
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/screens/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
}
|
||||
|
||||
function renderClientRow(client) {
|
||||
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>';
|
||||
var clientIp = client.clientIp ? escapeHtml(client.clientIp) : (client.remoteAddress ? escapeHtml(client.remoteAddress) : '<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 clientId = client.clientId ? escapeHtml(client.clientId) : '<span class="empty">Unknown</span>';
|
||||
var connectionId = client.id ? escapeHtml(client.id) : '<span class="empty">Unknown</span>';
|
||||
var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : 'No slide currently showing';
|
||||
|
||||
return [
|
||||
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '">',
|
||||
'<td data-label="Client/Connection ID"><div class="connection-count">' + clientId + '</div><br><div class="connection-id">' + connectionId + '</div></td>',
|
||||
'<td data-label="IP">' + clientIp + '</td>',
|
||||
'<td data-label="Viewport">' + viewport + '</td>',
|
||||
'<td data-label="Connected">' + connectedAt + '</td>',
|
||||
'<td data-label="Screen"><div>' + escapeHtml(client.screen_name) + '</div><div class="subtle">Showing: ' + currentSlide + '</div></td>',
|
||||
'<td data-label="Actions">' + renderClientActionCell(client) + '</td>',
|
||||
'</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;
|
||||
}
|
||||
if (!state.clients.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="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;
|
||||
var className = String(button.className || '');
|
||||
classesToRemove.forEach(function (removeClass) {
|
||||
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
|
||||
});
|
||||
|
||||
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);
|
||||
row = tempBody.firstElementChild;
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
row.setAttribute('data-client-key', rowKey);
|
||||
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">' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '</div>' : '') : '<span class="empty">Unknown</span>';
|
||||
var clientIp = client.clientIp ? escapeHtml(client.clientIp) : (client.remoteAddress ? escapeHtml(client.remoteAddress) : '<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 clientId = client.clientId ? escapeHtml(client.clientId) : '<span class="empty">Unknown</span>';
|
||||
var connectionId = client.id ? escapeHtml(client.id) : '<span class="empty">Unknown</span>';
|
||||
var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : 'No slide currently showing';
|
||||
|
||||
row.cells[0].innerHTML = '<div class="connection-count">' + clientId + '</div><br><div class="connection-id">' + connectionId + '</div>';
|
||||
row.cells[1].innerHTML = clientIp;
|
||||
row.cells[2].innerHTML = viewport;
|
||||
row.cells[3].innerHTML = connectedAt;
|
||||
row.cells[4].innerHTML = '<div>' + escapeHtml(client.screen_name) + '</div><div class="subtle">Showing: ' + currentSlide + '</div>';
|
||||
updateClientActionCell(row.cells[5], client);
|
||||
}
|
||||
|
||||
var referenceNode = tbody.children[index] || null;
|
||||
if (referenceNode !== row) {
|
||||
tbody.insertBefore(row, referenceNode);
|
||||
}
|
||||
});
|
||||
|
||||
while (tbody.children.length > state.clients.length) {
|
||||
tbody.removeChild(tbody.lastElementChild);
|
||||
if (classToAdd) {
|
||||
className += ' ' + classToAdd;
|
||||
}
|
||||
|
||||
window.applyTableSort(document.getElementById('dashboard-clients-table'));
|
||||
button.className = className.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function updateScreenTable(state) {
|
||||
var table = document.getElementById('dashboard-screens-table');
|
||||
if (!table || !Array.isArray(state.screens)) {
|
||||
return;
|
||||
function normalizeDisplayIp(value) {
|
||||
var ip = String(value || '').trim();
|
||||
if (!ip) {
|
||||
return '';
|
||||
}
|
||||
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
|
||||
if (!tbody) {
|
||||
return;
|
||||
|
||||
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
|
||||
return ip.slice(7).trim();
|
||||
}
|
||||
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);
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
blackoutButton.textContent = label;
|
||||
blackoutButton.className = 'button-link list-action' + (allBlackout ? ' is-blackout' : '');
|
||||
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);
|
||||
}
|
||||
|
||||
window.webHandleDashboardState = handleDashboardState;
|
||||
|
||||
function initConfirmForms() {
|
||||
document.addEventListener('submit', function (event) {
|
||||
var form = event.target;
|
||||
@@ -340,6 +96,62 @@
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -384,6 +196,42 @@
|
||||
}
|
||||
|
||||
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')) {
|
||||
@@ -404,7 +252,13 @@
|
||||
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') {
|
||||
@@ -437,10 +291,14 @@
|
||||
throw new Error(text || 'Unable to save changes.');
|
||||
});
|
||||
}
|
||||
if (form.hasAttribute && form.hasAttribute('data-async-save-reload')) {
|
||||
window.location.replace(response.url || window.location.href);
|
||||
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 {
|
||||
@@ -452,12 +310,16 @@
|
||||
} catch (_error) {
|
||||
savedMessage = '';
|
||||
}
|
||||
showToast(savedMessage || 'Saved.');
|
||||
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);
|
||||
}
|
||||
@@ -479,6 +341,8 @@
|
||||
}
|
||||
|
||||
initConfirmForms();
|
||||
initDirtyTracking();
|
||||
initCancelConfirm();
|
||||
initAsyncCommandForms();
|
||||
initAsyncSaveForms();
|
||||
initSubmitOnChange();
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
(function () {
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 pauseButtonLabel = paused ? 'Resume' : 'Pause';
|
||||
var blackout = Boolean(client.blackout);
|
||||
var blackoutButtonClass = 'btn btn-sm ' + (blackout ? 'btn-success' : 'btn-secondary');
|
||||
var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout';
|
||||
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
|
||||
var blackoutCommandValue = blackout ? 'false' : 'true';
|
||||
|
||||
return '<div class="d-flex flex-wrap gap-1"><form method="post" action="/admin/screens/' + 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/screens/' + 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/screens/' + 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/screens/' + 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-pause-fill me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/admin/screens/' + 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-eye-slash 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-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/screens/' + 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/screens/' + 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);
|
||||
setButtonVariant(blackoutButton, ['btn-success', 'btn-secondary'], blackout ? 'btn-success' : 'btn-secondary');
|
||||
blackoutButton.innerHTML = '<i class="bi bi-eye-slash 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/screens/' + 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/screens/' + 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/screens/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
}
|
||||
|
||||
function renderClientRow(client) {
|
||||
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle">' + 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>';
|
||||
|
||||
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>',
|
||||
'<td data-label="Actions">' + renderClientActionCell(client) + '</td>',
|
||||
'</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;
|
||||
}
|
||||
if (!state.clients.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" 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);
|
||||
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 >= 7) {
|
||||
var connectedAt = client.connectedAt ? '<div>' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '</div>' + (client.lastSeenAt ? '<div class="subtle">' + 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;
|
||||
updateClientActionCell(row.cells[6], client);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
blackoutButton.innerHTML = (allBlackout ? '<i class="bi bi-eye me-1" aria-hidden="true"></i>' : '<i class="bi bi-eye-slash 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/screens/' + 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) {
|
||||
throw new Error(text || 'Unable to rename client.');
|
||||
});
|
||||
}
|
||||
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();
|
||||
}());
|
||||
+174
-82
@@ -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) {
|
||||
@@ -264,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,15 +436,15 @@
|
||||
addSlideSelect.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 orderNumber = row.querySelector('.playlist-order-number');
|
||||
@@ -369,6 +454,9 @@
|
||||
});
|
||||
syncAddSlideOptions();
|
||||
updateEmptyState();
|
||||
if (markDirty) {
|
||||
markPlaylistDirty();
|
||||
}
|
||||
}
|
||||
|
||||
function lockDraggedRowWidths(row) {
|
||||
@@ -435,11 +523,13 @@
|
||||
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));
|
||||
@@ -447,24 +537,24 @@
|
||||
row.innerHTML = '' +
|
||||
'<td class="playlist-order-cell" data-label="Order">' +
|
||||
'<div class="playlist-order-cell-inner">' +
|
||||
'<button type="button" class="playlist-drag-handle" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"></button>' +
|
||||
'<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 + '" /></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 /></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="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;
|
||||
}
|
||||
@@ -481,7 +571,7 @@
|
||||
if (removeButton) {
|
||||
event.preventDefault();
|
||||
row.remove();
|
||||
updateRowOrder();
|
||||
updateRowOrder(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -514,7 +604,7 @@
|
||||
},
|
||||
onEnd: function () {
|
||||
unlockDraggedRowWidths(tbody.querySelector('.sortable-drag'));
|
||||
updateRowOrder();
|
||||
updateRowOrder(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -544,7 +634,7 @@
|
||||
summary: 'Always visible'
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
updateRowOrder();
|
||||
updateRowOrder(true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -559,6 +649,8 @@
|
||||
updateRowOrder();
|
||||
}
|
||||
|
||||
window.initPlaylistScheduleForm = initPlaylistScheduleForm;
|
||||
|
||||
initPlaylistScheduleModal();
|
||||
initPlaylistScheduleForm();
|
||||
initPlaylistEditStaging();
|
||||
+358
-48
@@ -21,7 +21,8 @@ import {
|
||||
Subscript,
|
||||
Superscript,
|
||||
Underline
|
||||
} from '/assets/js/ckeditor5/ckeditor5.js';
|
||||
} from '/assets/js/vendor/ckeditor5/ckeditor5.js';
|
||||
import { createTemplateSelectorLockController } from '/assets/js/slides/slide-form-template-lock.js';
|
||||
|
||||
(function () {
|
||||
var dataElement = document.getElementById('slide-editor-data');
|
||||
@@ -42,13 +43,25 @@ import {
|
||||
var templateSelect = document.getElementById('template-select');
|
||||
var templateFields = document.getElementById('template-fields');
|
||||
var slideForm = document.getElementById('slide-form');
|
||||
var slideEditorShell = document.querySelector('.slide-editor-shell');
|
||||
var slideEditorSidebar = document.querySelector('.slide-editor-sidebar');
|
||||
var slidePreviewMeta = document.getElementById('slide-preview-meta');
|
||||
var slidePreviewStage = document.getElementById('slide-preview-stage');
|
||||
var slidePreviewCanvas = document.getElementById('slide-preview-canvas');
|
||||
var slidePreviewBackground = document.getElementById('slide-preview-background');
|
||||
var slidePreviewEmpty = document.getElementById('slide-preview-empty');
|
||||
var slidePreviewOverlay = document.getElementById('slide-preview-overlay');
|
||||
var openPreviewPopupButton = document.getElementById('open-preview-popup');
|
||||
var editorInstances = new Map();
|
||||
var submitting = false;
|
||||
var previewRenderFrame = 0;
|
||||
var previewPopupWindow = null;
|
||||
var previewPopupRenderFrame = 0;
|
||||
var currentPreviewCanvasWidth = 0;
|
||||
var currentPreviewCanvasHeight = 0;
|
||||
var sidebarSyncFrame = 0;
|
||||
var sidebarTopOffset = 16;
|
||||
var templateSelectorLock = createTemplateSelectorLockController(templateSelect);
|
||||
|
||||
class FontSizeInputUI extends Plugin {
|
||||
static get pluginName() {
|
||||
@@ -211,6 +224,21 @@ import {
|
||||
return String(value || '');
|
||||
}
|
||||
|
||||
function requestPreviewRender() {
|
||||
if (previewRenderFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewRenderFrame = window.requestAnimationFrame(function () {
|
||||
previewRenderFrame = 0;
|
||||
renderPreview();
|
||||
});
|
||||
}
|
||||
|
||||
function getTemplateFieldControls() {
|
||||
return templateFields ? templateFields.querySelectorAll('input, textarea, select') : [];
|
||||
}
|
||||
|
||||
function sanitizePreviewHtml(html) {
|
||||
var output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
@@ -445,30 +473,221 @@ import {
|
||||
};
|
||||
}
|
||||
|
||||
function getPreviewCanvasDimensions(template) {
|
||||
return {
|
||||
width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)),
|
||||
height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080))
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreviewPopupHtml() {
|
||||
return '<!doctype html>' +
|
||||
'<html>' +
|
||||
'<head>' +
|
||||
'<meta charset="utf-8" />' +
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1" />' +
|
||||
'<title>Slide preview</title>' +
|
||||
'<style>' +
|
||||
'html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#111;color:#fff;font-family:Arial,sans-serif;}' +
|
||||
'#popup-preview-stage{position:relative;width:100%;height:100%;overflow:hidden;background:#111;}' +
|
||||
'#popup-preview-canvas{position:absolute;top:0;left:0;transform-origin:top left;will-change:transform;}' +
|
||||
'#popup-preview-canvas,#popup-preview-stage{box-sizing:border-box;}' +
|
||||
'.slide-preview-background,.slide-preview-overlay,.slide-preview-empty{position:absolute;inset:0;}' +
|
||||
'.slide-preview-background{width:100%;height:100%;object-fit:contain;display:block;z-index:1;}' +
|
||||
'.slide-preview-overlay{z-index:2;}' +
|
||||
'.slide-preview-empty{display:flex;align-items:center;justify-content:center;padding:1rem;text-align:center;color:rgba(255,255,255,0.75);z-index:0;}' +
|
||||
'.slide-preview-region{position:absolute;overflow:hidden;box-sizing:border-box;}' +
|
||||
'.slide-preview-image{width:100%;height:100%;object-fit:contain;display:block;}' +
|
||||
'.slide-preview-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,0.65);background:rgba(255,255,255,0.06);font-size:0.9rem;}' +
|
||||
'</style>' +
|
||||
'</head>' +
|
||||
'<body>' +
|
||||
'<div id="popup-preview-stage">' +
|
||||
'<div id="popup-preview-canvas"></div>' +
|
||||
'</div>' +
|
||||
'</body>' +
|
||||
'</html>';
|
||||
}
|
||||
|
||||
function syncPopupPreview() {
|
||||
if (!previewPopupWindow || previewPopupWindow.closed) {
|
||||
previewPopupWindow = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var popupDocument = previewPopupWindow.document;
|
||||
if (!popupDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
var popupStage = popupDocument.getElementById('popup-preview-stage');
|
||||
var popupCanvas = popupDocument.getElementById('popup-preview-canvas');
|
||||
var template = getSelectedTemplate();
|
||||
|
||||
if (!popupStage || !popupCanvas) {
|
||||
if (previewPopupRenderFrame) {
|
||||
cancelAnimationFrame(previewPopupRenderFrame);
|
||||
}
|
||||
previewPopupRenderFrame = requestAnimationFrame(syncPopupPreview);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!template) {
|
||||
popupStage.style.backgroundColor = '#111111';
|
||||
popupCanvas.style.width = '';
|
||||
popupCanvas.style.height = '';
|
||||
popupCanvas.style.transform = '';
|
||||
popupCanvas.innerHTML = slidePreviewCanvas.innerHTML;
|
||||
var popupEmpty = popupCanvas.querySelector('#slide-preview-empty');
|
||||
var popupOverlay = popupCanvas.querySelector('#slide-preview-overlay');
|
||||
var popupBackground = popupCanvas.querySelector('#slide-preview-background');
|
||||
if (popupBackground) {
|
||||
popupBackground.style.display = 'none';
|
||||
}
|
||||
if (popupEmpty) {
|
||||
popupEmpty.style.display = 'flex';
|
||||
popupEmpty.textContent = 'Select a template to preview the slide.';
|
||||
}
|
||||
if (popupOverlay) {
|
||||
popupOverlay.innerHTML = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var canvasWidth = currentPreviewCanvasWidth || Math.max(1, Number(template.canvas_size_width || 1920));
|
||||
var canvasHeight = currentPreviewCanvasHeight || Math.max(1, Number(template.canvas_size_height || 1080));
|
||||
popupStage.style.backgroundColor = template.background_color || '#111111';
|
||||
popupCanvas.style.width = canvasWidth + 'px';
|
||||
popupCanvas.style.height = canvasHeight + 'px';
|
||||
popupCanvas.innerHTML = slidePreviewCanvas.innerHTML;
|
||||
var popupBackground = popupCanvas.querySelector('#slide-preview-background');
|
||||
var popupEmpty = popupCanvas.querySelector('#slide-preview-empty');
|
||||
var popupOverlay = popupCanvas.querySelector('#slide-preview-overlay');
|
||||
|
||||
if (popupBackground) {
|
||||
popupBackground.style.display = template.background_image_path ? 'block' : 'none';
|
||||
popupBackground.src = template.background_image_path || '';
|
||||
}
|
||||
if (popupEmpty) {
|
||||
popupEmpty.style.display = 'none';
|
||||
}
|
||||
|
||||
var stageRect = popupStage.getBoundingClientRect();
|
||||
if (!stageRect.width || !stageRect.height) {
|
||||
if (previewPopupRenderFrame) {
|
||||
cancelAnimationFrame(previewPopupRenderFrame);
|
||||
}
|
||||
previewPopupRenderFrame = requestAnimationFrame(syncPopupPreview);
|
||||
return;
|
||||
}
|
||||
|
||||
var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1;
|
||||
popupCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')';
|
||||
}
|
||||
|
||||
function openPreviewPopup() {
|
||||
if (previewPopupWindow && !previewPopupWindow.closed) {
|
||||
previewPopupWindow.focus();
|
||||
syncPopupPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
var popupWidth = 1280;
|
||||
var popupHeight = 900;
|
||||
var left = Math.max(0, Math.round((window.screen.width - popupWidth) / 2));
|
||||
var top = Math.max(0, Math.round((window.screen.height - popupHeight) / 2));
|
||||
var popupFeatures = [
|
||||
'popup=yes',
|
||||
'toolbar=no',
|
||||
'location=no',
|
||||
'menubar=no',
|
||||
'directories=no',
|
||||
'status=no',
|
||||
'scrollbars=yes',
|
||||
'resizable=yes',
|
||||
'width=' + popupWidth,
|
||||
'height=' + popupHeight,
|
||||
'left=' + left,
|
||||
'top=' + top
|
||||
].join(',');
|
||||
|
||||
previewPopupWindow = window.open('', 'slide-preview-popup', popupFeatures);
|
||||
if (!previewPopupWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewPopupWindow.document.open();
|
||||
previewPopupWindow.document.write(buildPreviewPopupHtml());
|
||||
previewPopupWindow.document.close();
|
||||
previewPopupWindow.addEventListener('resize', syncPopupPreview);
|
||||
previewPopupWindow.focus();
|
||||
syncPopupPreview();
|
||||
}
|
||||
|
||||
function syncSlideEditorSidebar() {
|
||||
sidebarSyncFrame = 0;
|
||||
|
||||
if (!slideEditorShell || !slideEditorSidebar) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.matchMedia && window.matchMedia('(max-width: 1199.98px)').matches) {
|
||||
slideEditorSidebar.style.transform = '';
|
||||
return;
|
||||
}
|
||||
|
||||
var shellRect = slideEditorShell.getBoundingClientRect();
|
||||
var sidebarHeight = slideEditorSidebar.offsetHeight;
|
||||
var shellTop = window.scrollY + shellRect.top;
|
||||
var shellBottom = shellTop + slideEditorShell.offsetHeight;
|
||||
var maxTranslate = Math.max(0, shellBottom - shellTop - sidebarHeight - sidebarTopOffset);
|
||||
var desiredTranslate = Math.max(0, Math.min(window.scrollY + sidebarTopOffset - shellTop, maxTranslate));
|
||||
|
||||
slideEditorSidebar.style.transform = desiredTranslate ? 'translate3d(0, ' + desiredTranslate + 'px, 0)' : '';
|
||||
}
|
||||
|
||||
function requestSidebarSync() {
|
||||
if (sidebarSyncFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
sidebarSyncFrame = window.requestAnimationFrame(syncSlideEditorSidebar);
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
var template = getSelectedTemplate();
|
||||
if (!template) {
|
||||
slidePreviewMeta.textContent = 'Select a template to preview the slide.';
|
||||
slidePreviewMeta.textContent = '--';
|
||||
slidePreviewBackground.style.display = 'none';
|
||||
slidePreviewEmpty.style.display = 'flex';
|
||||
slidePreviewEmpty.textContent = 'Select a template to preview the slide.';
|
||||
slidePreviewCanvas.style.width = '';
|
||||
slidePreviewCanvas.style.height = '';
|
||||
slidePreviewOverlay.innerHTML = '';
|
||||
slidePreviewOverlay.style.width = '';
|
||||
slidePreviewOverlay.style.height = '';
|
||||
slidePreviewOverlay.style.transform = '';
|
||||
slidePreviewStage.style.aspectRatio = '16 / 9';
|
||||
slidePreviewStage.style.backgroundColor = '#111111';
|
||||
return;
|
||||
}
|
||||
|
||||
var canvasWidth = Math.max(1, Number(template.canvas_size_width || 1920));
|
||||
var canvasHeight = Math.max(1, Number(template.canvas_size_height || 1080));
|
||||
var canvasSize = getPreviewCanvasDimensions(template);
|
||||
var canvasWidth = canvasSize.width;
|
||||
var canvasHeight = canvasSize.height;
|
||||
currentPreviewCanvasWidth = canvasWidth;
|
||||
currentPreviewCanvasHeight = canvasHeight;
|
||||
slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight;
|
||||
slidePreviewMeta.textContent = template.name + ' • ' + canvasWidth + 'x' + canvasHeight;
|
||||
slidePreviewStage.style.backgroundColor = template.background_color || '#111111';
|
||||
slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight;
|
||||
if (!(template.regions || []).length) {
|
||||
slidePreviewEmpty.style.display = 'flex';
|
||||
slidePreviewEmpty.textContent = 'This template has no regions.';
|
||||
slidePreviewBackground.style.display = template.background_image_path ? 'block' : 'none';
|
||||
slidePreviewBackground.src = template.background_image_path || '';
|
||||
slidePreviewCanvas.style.width = canvasWidth + 'px';
|
||||
slidePreviewCanvas.style.height = canvasHeight + 'px';
|
||||
slidePreviewCanvas.style.transform = '';
|
||||
slidePreviewOverlay.innerHTML = '';
|
||||
slidePreviewOverlay.style.width = '';
|
||||
slidePreviewOverlay.style.height = '';
|
||||
@@ -481,11 +700,21 @@ import {
|
||||
slidePreviewBackground.src = template.background_image_path || '';
|
||||
|
||||
var stageRect = slidePreviewStage.getBoundingClientRect();
|
||||
if (!stageRect.width || !stageRect.height) {
|
||||
if (previewRenderFrame) {
|
||||
cancelAnimationFrame(previewRenderFrame);
|
||||
}
|
||||
previewRenderFrame = requestAnimationFrame(function () {
|
||||
previewRenderFrame = 0;
|
||||
renderPreview();
|
||||
});
|
||||
return;
|
||||
}
|
||||
var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1;
|
||||
slidePreviewOverlay.style.width = canvasWidth + 'px';
|
||||
slidePreviewOverlay.style.height = canvasHeight + 'px';
|
||||
slidePreviewOverlay.style.transform = 'scale(' + scale + ')';
|
||||
slidePreviewOverlay.style.transformOrigin = 'top left';
|
||||
slidePreviewCanvas.style.width = canvasWidth + 'px';
|
||||
slidePreviewCanvas.style.height = canvasHeight + 'px';
|
||||
slidePreviewCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')';
|
||||
slidePreviewCanvas.style.transformOrigin = 'top left';
|
||||
var regions = template.regions || [];
|
||||
slidePreviewOverlay.innerHTML = regions.map(function (region) {
|
||||
var box = getRegionBox(region, canvasWidth, canvasHeight);
|
||||
@@ -525,79 +754,93 @@ import {
|
||||
: ' slide-preview-text-region';
|
||||
return '<div class="slide-preview-region' + selected + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;">' + content + '</div>';
|
||||
}).join('');
|
||||
|
||||
syncPopupPreview();
|
||||
}
|
||||
|
||||
function renderTextRegion(region) {
|
||||
var current = getCurrentRegionValue(region);
|
||||
return '' +
|
||||
'<div class="card template-field-card" data-region-id="' + region.id + '">' +
|
||||
'<div class="template-field-head">' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">Text</span>' +
|
||||
'<div class="template-field-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-secondary template-editor-toggle" data-toggle-editor-height="' + region.id + '">Expand</button>' +
|
||||
'<span class="chip">Text</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
||||
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current) + '</textarea>' +
|
||||
'<div class="card-body p-3 d-grid gap-3">' +
|
||||
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
||||
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current) + '</textarea>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(getCurrentTextStyle(region).font_size) + '" />' +
|
||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(getCurrentTextStyle(region).font_size) + '" />' +
|
||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderWebpageRegion(region) {
|
||||
var current = getCurrentRegionValue(region);
|
||||
return '' +
|
||||
'<div class="card template-field-card" data-region-id="' + region.id + '">' +
|
||||
'<div class="template-field-head">' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">Webpage</span>' +
|
||||
'</div>' +
|
||||
'<label style="display:block; margin-top:12px;">Webpage URL' +
|
||||
'<input type="url" name="region_webpage_' + region.id + '" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' +
|
||||
'</label>' +
|
||||
'<div class="muted slide-image-file">The webpage will be loaded in an iframe.</div>' +
|
||||
'<div class="card-body p-3 d-grid gap-3">' +
|
||||
'<label style="display:block;">Webpage URL' +
|
||||
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' +
|
||||
'</label>' +
|
||||
'<div class="muted slide-image-file">The webpage will be loaded in an iframe.</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderHtmlRegion(region) {
|
||||
var current = getCurrentRegionValue(region);
|
||||
return '' +
|
||||
'<div class="card template-field-card" data-region-id="' + region.id + '">' +
|
||||
'<div class="template-field-head">' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">HTML</span>' +
|
||||
'</div>' +
|
||||
'<label style="display:block; margin-top:12px;">HTML content' +
|
||||
'<textarea name="region_html_' + region.id + '" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
|
||||
'</label>' +
|
||||
'<div class="muted slide-image-file">HTML is rendered in a sandboxed iframe preview.</div>' +
|
||||
'<div class="card-body p-3 d-grid gap-3">' +
|
||||
'<label style="display:block;">HTML content' +
|
||||
'<textarea name="region_html_' + region.id + '" class="form-control" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
|
||||
'</label>' +
|
||||
'<div class="muted slide-image-file">HTML is rendered in a sandboxed iframe preview.</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderImageRegion(region) {
|
||||
var current = getCurrentRegionValue(region);
|
||||
return '' +
|
||||
'<div class="card template-field-card" data-region-id="' + region.id + '">' +
|
||||
'<div class="template-field-head">' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">Image</span>' +
|
||||
'</div>' +
|
||||
'<label style="display:block; margin-top:12px;">Image file' +
|
||||
'<input type="file" name="region_image_' + region.id + '" accept="image/*" />' +
|
||||
'</label>' +
|
||||
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
||||
(current
|
||||
? '<div class="muted slide-image-file">Current: <a href="' + escapeHtml(current) + '" target="_blank" rel="noreferrer">' + escapeHtml(current) + '</a></div>'
|
||||
: '<div class="muted slide-image-file">No image selected.</div>') +
|
||||
'<div class="card-body p-3 d-grid gap-3">' +
|
||||
'<label style="display:block;">Image file' +
|
||||
'<input type="file" name="region_image_' + region.id + '" class="form-control" accept="image/*" />' +
|
||||
'</label>' +
|
||||
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
||||
(current
|
||||
? '<div class="muted slide-image-file">Current: <a href="' + escapeHtml(current) + '" target="_blank" rel="noreferrer">' + escapeHtml(current) + '</a></div>'
|
||||
: '<div class="muted slide-image-file">No image selected.</div>') +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderTemplate() {
|
||||
templateSelectorLock.reset();
|
||||
clearImagePreviewUrls();
|
||||
destroyEditors();
|
||||
var template = getTemplateById(templateSelect.value);
|
||||
if (!template) {
|
||||
templateFields.innerHTML = '';
|
||||
renderPreview();
|
||||
requestPreviewRender();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -712,14 +955,28 @@ import {
|
||||
if (hidden) {
|
||||
hidden.value = editor.getData();
|
||||
}
|
||||
renderPreview();
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
});
|
||||
renderPreview();
|
||||
requestPreviewRender();
|
||||
}).catch(function (error) {
|
||||
console.error('Failed to initialize CKEditor.', error);
|
||||
});
|
||||
});
|
||||
|
||||
templateFields.querySelectorAll('[data-toggle-editor-height]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
var regionId = button.getAttribute('data-toggle-editor-height');
|
||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
var expanded = card.classList.toggle('is-expanded');
|
||||
button.textContent = expanded ? 'Collapse' : 'Expand';
|
||||
});
|
||||
});
|
||||
|
||||
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
|
||||
input.addEventListener('change', function () {
|
||||
if (input.dataset.previewUrl) {
|
||||
@@ -729,10 +986,14 @@ import {
|
||||
if (input.files && input.files.length) {
|
||||
input.dataset.previewUrl = URL.createObjectURL(input.files[0]);
|
||||
}
|
||||
renderPreview();
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
});
|
||||
});
|
||||
renderPreview();
|
||||
window.requestAnimationFrame(function () {
|
||||
templateSelectorLock.arm();
|
||||
});
|
||||
requestPreviewRender();
|
||||
}
|
||||
|
||||
slideForm.addEventListener('submit', async function (event) {
|
||||
@@ -740,6 +1001,9 @@ import {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
var submitterValue = event.submitter && event.submitter.name === 'save_action'
|
||||
? String(event.submitter.value || '').trim().toLowerCase()
|
||||
: '';
|
||||
var saves = Array.prototype.map.call(templateFields.querySelectorAll('.ckeditor-holder'), function (holder) {
|
||||
var regionId = holder.getAttribute('data-region-id');
|
||||
var editor = editorInstances.get(regionId);
|
||||
@@ -753,13 +1017,17 @@ import {
|
||||
submitting = true;
|
||||
try {
|
||||
await Promise.all(saves);
|
||||
var formData = new FormData(slideForm);
|
||||
if (event.submitter && event.submitter.name) {
|
||||
formData.set(event.submitter.name, event.submitter.value || '');
|
||||
}
|
||||
var response = await fetch(slideForm.action, {
|
||||
method: (slideForm.method || 'POST').toUpperCase(),
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html, application/json, text/plain, */*'
|
||||
},
|
||||
body: new FormData(slideForm),
|
||||
body: formData,
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
@@ -767,6 +1035,14 @@ import {
|
||||
throw new Error(await response.text() || 'Unable to save slide.');
|
||||
}
|
||||
|
||||
if (submitterValue === 'close' || submitterValue === 'new') {
|
||||
var redirectUrl = submitterValue === 'close'
|
||||
? String(slideForm.dataset.asyncSaveCloseUrl || response.url || slideForm.action)
|
||||
: String(slideForm.dataset.asyncSaveNewUrl || response.url || slideForm.action);
|
||||
window.location.replace(redirectUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
var responseText = await response.text();
|
||||
var savedMessage = '';
|
||||
try {
|
||||
@@ -779,19 +1055,53 @@ import {
|
||||
savedMessage = '';
|
||||
}
|
||||
|
||||
showToast(savedMessage || 'Saved slide.');
|
||||
window.location.href = response.url || slideForm.action;
|
||||
showToast(savedMessage || 'Saved slide.', 'success');
|
||||
slideForm.dataset.dirty = 'false';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
});
|
||||
|
||||
slideForm.addEventListener('input', function () {
|
||||
slideForm.dataset.dirty = 'true';
|
||||
}, true);
|
||||
|
||||
slideForm.addEventListener('change', function () {
|
||||
slideForm.dataset.dirty = 'true';
|
||||
}, true);
|
||||
|
||||
if (openPreviewPopupButton) {
|
||||
openPreviewPopupButton.addEventListener('click', function () {
|
||||
openPreviewPopup();
|
||||
});
|
||||
}
|
||||
|
||||
templateSelect.addEventListener('change', renderTemplate);
|
||||
templateFields.addEventListener('change', renderPreview);
|
||||
templateFields.addEventListener('input', renderPreview);
|
||||
templateFields.addEventListener('change', function () {
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
});
|
||||
templateFields.addEventListener('input', function () {
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
});
|
||||
if (existingTemplateId) {
|
||||
templateSelect.value = String(existingTemplateId);
|
||||
}
|
||||
renderTemplate();
|
||||
window.addEventListener('resize', renderPreview);
|
||||
if (window.ResizeObserver && slidePreviewStage) {
|
||||
var previewResizeObserver = new ResizeObserver(function () {
|
||||
requestPreviewRender();
|
||||
});
|
||||
previewResizeObserver.observe(slidePreviewStage);
|
||||
}
|
||||
requestSidebarSync();
|
||||
window.addEventListener('scroll', requestSidebarSync, { passive: true });
|
||||
window.addEventListener('resize', requestPreviewRender);
|
||||
window.addEventListener('resize', requestSidebarSync);
|
||||
window.addEventListener('beforeunload', function () {
|
||||
if (previewPopupWindow && !previewPopupWindow.closed) {
|
||||
previewPopupWindow.close();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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.webHandleDashboardState === 'function') {
|
||||
window.webHandleDashboardState(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,27 +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 removeBackgroundButton = document.getElementById('remove-background-image');
|
||||
var removeBackgroundFlag = document.getElementById('remove-background-image-flag');
|
||||
var addTextRegionButton = document.getElementById('add-text-region');
|
||||
var addImageRegionButton = document.getElementById('add-image-region');
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
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() {
|
||||
@@ -55,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() {
|
||||
@@ -67,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),
|
||||
@@ -93,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) {
|
||||
@@ -101,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); }
|
||||
@@ -110,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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -182,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;
|
||||
@@ -233,7 +317,6 @@
|
||||
renderRegionSidebar();
|
||||
renderOverlay();
|
||||
});
|
||||
updateRegionFieldVisibility(card);
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -269,6 +352,7 @@
|
||||
});
|
||||
regionSelect.value = String(selectedIndex);
|
||||
updateCanvasSizeLock();
|
||||
validateRegionNames();
|
||||
}
|
||||
|
||||
function renderOverlay() {
|
||||
@@ -290,6 +374,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
function requestOverlayRender() {
|
||||
if (overlayRenderFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
overlayRenderFrame = window.requestAnimationFrame(function () {
|
||||
overlayRenderFrame = 0;
|
||||
renderOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
updateAspectRatio();
|
||||
renderRegionSidebar();
|
||||
@@ -318,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;
|
||||
@@ -356,7 +458,7 @@
|
||||
renderOverlay();
|
||||
function moveHandler(moveEvent) {
|
||||
draft.end = toCanvasPoint(moveEvent);
|
||||
renderOverlay();
|
||||
requestOverlayRender();
|
||||
}
|
||||
function upHandler(upEvent) {
|
||||
draft.end = toCanvasPoint(upEvent);
|
||||
@@ -367,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);
|
||||
}
|
||||
@@ -384,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);
|
||||
@@ -410,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);
|
||||
@@ -420,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];
|
||||
@@ -445,6 +552,9 @@
|
||||
backgroundEmpty.style.display = 'block';
|
||||
});
|
||||
}
|
||||
if (backgroundColorInput) {
|
||||
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
|
||||
}
|
||||
canvasSizeSelect.addEventListener('change', function () { syncCanvasSizeSelection(); render(); });
|
||||
canvasWidthInput.addEventListener('input', render);
|
||||
canvasHeightInput.addEventListener('input', render);
|
||||
@@ -470,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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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();
|
||||
})();
|
||||
@@ -1,20 +1,16 @@
|
||||
(function () {
|
||||
var storageKey = 'web-theme';
|
||||
var theme = 'light';
|
||||
var storageKey = 'lte-theme';
|
||||
var theme = 'auto';
|
||||
|
||||
try {
|
||||
var storedTheme = window.localStorage.getItem(storageKey);
|
||||
if (storedTheme === 'dark' || storedTheme === 'light') {
|
||||
if (storedTheme === 'dark' || storedTheme === 'light' || storedTheme === 'auto') {
|
||||
theme = storedTheme;
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
theme = 'dark';
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
theme = 'dark';
|
||||
}
|
||||
theme = 'auto';
|
||||
}
|
||||
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.dataset.bsTheme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}());
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+77
-33
@@ -1,57 +1,102 @@
|
||||
(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) {
|
||||
if (!toast) {
|
||||
var instance = getBootstrapToast(toast);
|
||||
if (instance) {
|
||||
instance.hide();
|
||||
return;
|
||||
}
|
||||
removeToast(toast);
|
||||
}
|
||||
|
||||
function setToastVariant(toast, variant) {
|
||||
if (!toast || !toast.classList) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.classList.add('toast-hide');
|
||||
window.setTimeout(function () {
|
||||
if (toast && toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 220);
|
||||
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 showToast(message) {
|
||||
function getMessageVariant(message, fallbackVariant) {
|
||||
var text = String(message || '').trim();
|
||||
if (/^(unable to delete|cannot delete|can't delete)/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');
|
||||
var existingBody = existingToast ? existingToast.querySelector('.toast-body') : null;
|
||||
if (existingToast && existingBody) {
|
||||
existingBody.textContent = text;
|
||||
existingToast.classList.remove('toast-hide');
|
||||
window.clearTimeout(existingToast._dismissTimer);
|
||||
existingToast._dismissTimer = window.setTimeout(function () {
|
||||
dismissToast(existingToast);
|
||||
}, 4000);
|
||||
if (existingToast) {
|
||||
var existingBody = existingToast.querySelector('.toast-body');
|
||||
if (existingBody) {
|
||||
existingBody.textContent = text;
|
||||
}
|
||||
setToastVariant(existingToast, getMessageVariant(text, variant));
|
||||
var existingInstance = getBootstrapToast(existingToast);
|
||||
if (existingInstance) {
|
||||
existingInstance.show();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.className = 'toast align-items-center border-0';
|
||||
toast.id = 'app-toast';
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
toast.innerHTML = '<div class="toast-body"></div><button type="button" class="toast-close" aria-label="Dismiss notification">×</button>';
|
||||
toast.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>';
|
||||
setToastVariant(toast, getMessageVariant(text, variant));
|
||||
toast.querySelector('.toast-body').textContent = text;
|
||||
|
||||
var closeButton = toast.querySelector('.toast-close');
|
||||
closeButton.addEventListener('click', function () {
|
||||
dismissToast(toast);
|
||||
toast.addEventListener('hidden.bs.toast', function () {
|
||||
removeToast(toast);
|
||||
});
|
||||
|
||||
document.body.appendChild(toast);
|
||||
toast._dismissTimer = window.setTimeout(function () {
|
||||
dismissToast(toast);
|
||||
}, 4000);
|
||||
container.appendChild(toast);
|
||||
var instance = getBootstrapToast(toast);
|
||||
if (instance) {
|
||||
instance.show();
|
||||
}
|
||||
}
|
||||
|
||||
function initToast() {
|
||||
var toast = document.getElementById('app-toast');
|
||||
var closeButton = document.getElementById('app-toast-close');
|
||||
if (!toast || !closeButton) {
|
||||
if (!toast) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,13 +110,12 @@
|
||||
// ignore URL cleanup failures
|
||||
}
|
||||
|
||||
closeButton.addEventListener('click', function () {
|
||||
dismissToast(toast);
|
||||
});
|
||||
setToastVariant(toast, getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'success'));
|
||||
|
||||
window.setTimeout(function () {
|
||||
dismissToast(toast);
|
||||
}, 4000);
|
||||
var instance = getBootstrapToast(toast);
|
||||
if (instance) {
|
||||
instance.show();
|
||||
}
|
||||
}
|
||||
|
||||
window.dismissToast = dismissToast;
|
||||
|
||||
Vendored
Vendored
Vendored
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user