Initial Product release
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
uploads
|
||||
.git
|
||||
.gitignore
|
||||
*.tmp
|
||||
@@ -0,0 +1,21 @@
|
||||
NODE_ENV=production
|
||||
WEB_PORT=3000
|
||||
PLAYER_PORT=3001
|
||||
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_NAME=signage
|
||||
DB_USER=signage_user
|
||||
DB_PASSWORD=signage_password
|
||||
MYSQL_ROOT_PASSWORD=root_password
|
||||
|
||||
PLAYER_INTERNAL_BASE_URL=http://player:3001
|
||||
PLAYER_PUBLIC_BASE_URL=http://localhost:3001
|
||||
UPLOAD_DIR=/app/uploads
|
||||
|
||||
SESSION_MAX_AGE_DAYS=14
|
||||
DASHBOARD_REFRESH_INTERVAL_MS=2000
|
||||
DEFAULT_ADMIN_USERNAME=admin
|
||||
DEFAULT_ADMIN_NAME=Admin
|
||||
DEFAULT_ADMIN_PASSWORD=admin
|
||||
PASSWORD_HASH_ITERATIONS=310000
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
uploads/
|
||||
.env
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.DS_Store
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY src ./src
|
||||
|
||||
RUN mkdir -p /app/uploads
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "run", "start:webui"]
|
||||
+43
-36
@@ -1,32 +1,56 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
app:
|
||||
webui:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: signage-app
|
||||
container_name: signage-webui
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
|
||||
DB_HOST: mysql
|
||||
DB_PORT: 3306
|
||||
DB_NAME: signage
|
||||
DB_USER: signage_user
|
||||
DB_PASSWORD: signage_password
|
||||
|
||||
REDIS_URL: redis://redis:6379
|
||||
UPLOAD_DIR: /app/uploads
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
WEB_PORT: ${WEB_PORT:-3000}
|
||||
DB_HOST: ${DB_HOST:-mysql}
|
||||
DB_PORT: ${DB_PORT:-3306}
|
||||
DB_NAME: ${DB_NAME:-signage}
|
||||
DB_USER: ${DB_USER:-signage_user}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
PLAYER_INTERNAL_BASE_URL: ${PLAYER_INTERNAL_BASE_URL:-http://player:3001}
|
||||
PLAYER_PUBLIC_BASE_URL: ${PLAYER_PUBLIC_BASE_URL:-http://localhost:3001}
|
||||
UPLOAD_DIR: ${UPLOAD_DIR:-/app/uploads}
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
command: ["npm", "run", "start:webui"]
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
networks:
|
||||
- signage-net
|
||||
|
||||
player:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: signage-player
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
NODE_ENV: ${NODE_ENV:-production}
|
||||
PLAYER_PORT: ${PLAYER_PORT:-3001}
|
||||
DB_HOST: ${DB_HOST:-mysql}
|
||||
DB_PORT: ${DB_PORT:-3306}
|
||||
DB_NAME: ${DB_NAME:-signage}
|
||||
DB_USER: ${DB_USER:-signage_user}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
UPLOAD_DIR: ${UPLOAD_DIR:-/app/uploads}
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
command: ["npm", "run", "start:player"]
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- signage-net
|
||||
@@ -38,10 +62,10 @@ services:
|
||||
ports:
|
||||
- "3306:3306"
|
||||
environment:
|
||||
MYSQL_DATABASE: signage
|
||||
MYSQL_USER: signage_user
|
||||
MYSQL_PASSWORD: signage_password
|
||||
MYSQL_ROOT_PASSWORD: root_password
|
||||
MYSQL_DATABASE: ${DB_NAME:-signage}
|
||||
MYSQL_USER: ${DB_USER:-signage_user}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD:-signage_password}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root_password}
|
||||
command:
|
||||
- --default-authentication-plugin=mysql_native_password
|
||||
- --character-set-server=utf8mb4
|
||||
@@ -49,23 +73,7 @@ services:
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot_password"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- signage-net
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: signage-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p$$MYSQL_ROOT_PASSWORD"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -74,7 +82,6 @@ services:
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
redis_data:
|
||||
uploads:
|
||||
|
||||
networks:
|
||||
|
||||
Generated
+1604
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Pulse Signage application with MySQL and media uploads",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node -r dotenv/config src/webui.js",
|
||||
"start:webui": "node -r dotenv/config src/webui.js",
|
||||
"start:player": "node -r dotenv/config src/player.js",
|
||||
"dev:webui": "nodemon -r dotenv/config src/webui.js",
|
||||
"dev:player": "nodemon -r dotenv/config src/player.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.21.2",
|
||||
"handlebars": "^4.7.8",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.14.3",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.10"
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
const PASSWORD_ITERATIONS = Number(process.env.PASSWORD_HASH_ITERATIONS || 310000);
|
||||
const PASSWORD_KEY_LENGTH = 32;
|
||||
const PASSWORD_DIGEST = 'sha256';
|
||||
const SESSION_BYTES = 32;
|
||||
|
||||
function hashPassword(password, salt) {
|
||||
const safePassword = String(password || '');
|
||||
const safeSalt = salt || crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.pbkdf2Sync(safePassword, safeSalt, PASSWORD_ITERATIONS, PASSWORD_KEY_LENGTH, PASSWORD_DIGEST).toString('hex');
|
||||
return {
|
||||
salt: safeSalt,
|
||||
hash: hash,
|
||||
iterations: PASSWORD_ITERATIONS
|
||||
};
|
||||
}
|
||||
|
||||
function verifyPassword(password, record) {
|
||||
if (!record || !record.password_salt || !record.password_hash) {
|
||||
return false;
|
||||
}
|
||||
const iterations = Number(record.password_iterations || PASSWORD_ITERATIONS);
|
||||
const safePassword = String(password || '');
|
||||
const expected = crypto.pbkdf2Sync(safePassword, String(record.password_salt), iterations, PASSWORD_KEY_LENGTH, PASSWORD_DIGEST);
|
||||
const actual = Buffer.from(String(record.password_hash), 'hex');
|
||||
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
|
||||
}
|
||||
|
||||
function createSessionToken() {
|
||||
return crypto.randomBytes(SESSION_BYTES).toString('hex');
|
||||
}
|
||||
|
||||
function hashSessionToken(token) {
|
||||
return crypto.createHash('sha256').update(String(token || '')).digest('hex');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
createSessionToken,
|
||||
hashSessionToken
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
const db = require('./db');
|
||||
const data = require('./data');
|
||||
const player = require('./player-render');
|
||||
|
||||
module.exports = {
|
||||
createPool: db.createPool,
|
||||
ensureSchema: db.ensureSchema,
|
||||
slugify: data.slugify,
|
||||
uniqueScreenSlug: data.uniqueScreenSlug,
|
||||
parseJsonSafe: data.parseJsonSafe,
|
||||
fetchAdminData: data.fetchAdminData,
|
||||
fetchPlaylistById: data.fetchPlaylistById,
|
||||
fetchScreenById: data.fetchScreenById,
|
||||
fetchScreenEditData: data.fetchScreenEditData,
|
||||
fetchTemplateById: data.fetchTemplateById,
|
||||
fetchSlideById: data.fetchSlideById,
|
||||
fetchTemplatesData: data.fetchTemplatesData,
|
||||
fetchCanvasSizesData: data.fetchCanvasSizesData,
|
||||
fetchCanvasSizeById: data.fetchCanvasSizeById,
|
||||
buildCanvasSizePayload: data.buildCanvasSizePayload,
|
||||
buildSlidePayload: data.buildSlidePayload,
|
||||
extractTemplateRegions: data.extractTemplateRegions,
|
||||
buildTemplatePayload: data.buildTemplatePayload,
|
||||
mediaKind: player.mediaKind,
|
||||
renderPlayerPage: player.renderPlayerPage
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
async function fetchAdminData(pool) {
|
||||
const [playlists] = await pool.query('SELECT * FROM playlists ORDER BY id DESC');
|
||||
const [canvasSizes] = await pool.query('SELECT * FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
const [templates] = await pool.query(`
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
|
||||
FROM slide_templates st
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY st.id DESC
|
||||
`);
|
||||
const [templateRegions] = await pool.query('SELECT * FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [slides] = await pool.query(`
|
||||
SELECT s.*, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slides s
|
||||
LEFT JOIN slide_templates st ON st.id = s.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY s.id DESC
|
||||
`);
|
||||
const [screens] = await pool.query(`
|
||||
SELECT s.*, p.name AS playlist_name
|
||||
FROM screens s
|
||||
LEFT JOIN playlists p ON p.id = s.playlist_id
|
||||
ORDER BY s.id DESC
|
||||
`);
|
||||
const [playlistSlides] = await pool.query(`
|
||||
SELECT ps.id, ps.playlist_id, ps.position, ps.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, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, 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
|
||||
ORDER BY ps.playlist_id ASC, ps.position ASC, ps.id ASC
|
||||
`);
|
||||
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchAdminData
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
async function fetchCanvasSizesData(pool) {
|
||||
const [canvasSizes] = await pool.query('SELECT * FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
return { canvasSizes };
|
||||
}
|
||||
|
||||
async function fetchCanvasSizeById(pool, id) {
|
||||
const [rows] = await pool.query('SELECT * FROM canvas_sizes WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
function buildCanvasSizePayload(req, existingCanvasSize) {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const width = Math.max(1, Number(req.body.width || (existingCanvasSize && existingCanvasSize.width) || 0));
|
||||
const height = Math.max(1, Number(req.body.height || (existingCanvasSize && existingCanvasSize.height) || 0));
|
||||
|
||||
if (!name) {
|
||||
const error = new Error('Canvas size name is required.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
width,
|
||||
height
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchCanvasSizesData,
|
||||
fetchCanvasSizeById,
|
||||
buildCanvasSizePayload
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
const { fetchAdminData } = require('./admin');
|
||||
const { fetchPlaylistById } = require('./playlists');
|
||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData } = require('./screens');
|
||||
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes');
|
||||
const { fetchSlideById, buildSlidePayload } = require('./slides');
|
||||
const { parseJsonSafe } = require('./utils');
|
||||
|
||||
module.exports = {
|
||||
slugify,
|
||||
uniqueScreenSlug,
|
||||
parseJsonSafe,
|
||||
fetchAdminData,
|
||||
fetchPlaylistById,
|
||||
fetchScreenById,
|
||||
fetchScreenEditData,
|
||||
fetchTemplateById,
|
||||
fetchSlideById,
|
||||
fetchTemplatesData,
|
||||
fetchCanvasSizesData,
|
||||
fetchCanvasSizeById,
|
||||
buildCanvasSizePayload,
|
||||
buildSlidePayload,
|
||||
extractTemplateRegions,
|
||||
buildTemplatePayload
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
async function fetchPlaylistById(pool, id) {
|
||||
const [rows] = await pool.query('SELECT * FROM playlists WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchPlaylistById
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
function slugify(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-{2,}/g, '-');
|
||||
}
|
||||
|
||||
async function uniqueScreenSlug(pool, baseSlug) {
|
||||
const start = baseSlug || `screen-${Date.now()}`;
|
||||
let candidate = start;
|
||||
let counter = 2;
|
||||
while (true) {
|
||||
const [rows] = await pool.query('SELECT id FROM screens WHERE slug = ?', [candidate]);
|
||||
if (!rows.length) {
|
||||
return candidate;
|
||||
}
|
||||
candidate = `${start}-${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchScreenById(pool, id) {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT s.*, p.name AS playlist_name
|
||||
FROM screens s
|
||||
LEFT JOIN playlists p ON p.id = s.playlist_id
|
||||
WHERE s.id = ?
|
||||
`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function fetchScreenEditData(pool) {
|
||||
const [playlists] = await pool.query('SELECT * FROM playlists ORDER BY id DESC');
|
||||
return { playlists };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
slugify,
|
||||
uniqueScreenSlug,
|
||||
fetchScreenById,
|
||||
fetchScreenEditData
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
const { fetchTemplateById } = require('./templates');
|
||||
const { parseJsonSafe } = require('./utils');
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
|
||||
|
||||
function sanitizeRichText(html) {
|
||||
let output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, (tag) => {
|
||||
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
const closing = Boolean(match[1]);
|
||||
const name = String(match[2] || '').toLowerCase();
|
||||
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
|
||||
return '';
|
||||
}
|
||||
if (name === 'br') {
|
||||
return '<br>';
|
||||
}
|
||||
return closing ? `</${name}>` : `<${name}>`;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchSlideById(pool, id) {
|
||||
const [slides] = await pool.query(`
|
||||
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.created_at, s.modified_at,
|
||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slides s
|
||||
LEFT JOIN slide_templates st ON st.id = s.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE s.id = ?
|
||||
`, [id]);
|
||||
if (!slides.length) {
|
||||
return null;
|
||||
}
|
||||
const slide = slides[0];
|
||||
slide.content = parseJsonSafe(slide.content_json) || {};
|
||||
if (slide.template_id) {
|
||||
slide.template = await fetchTemplateById(pool, slide.template_id);
|
||||
}
|
||||
return slide;
|
||||
}
|
||||
|
||||
function getFilesByField(files) {
|
||||
const map = {};
|
||||
(files || []).forEach((file) => {
|
||||
map[file.fieldname] = file;
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
function getTextRegionStyle(body, region, existingContent) {
|
||||
const existing = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
const fontFamily = String(body[`region_font_family_${region.id}`] || existing.font_family || region.font_family || 'Arial').trim() || 'Arial';
|
||||
const fontSize = Math.max(8, Number(body[`region_font_size_${region.id}`] || existing.font_size || 24));
|
||||
const fontColor = sanitizeTextColor(body[`region_font_color_${region.id}`] || existing.font_color || region.font_color || '#000000');
|
||||
return {
|
||||
font_family: fontFamily,
|
||||
font_size: fontSize,
|
||||
font_color: fontColor
|
||||
};
|
||||
}
|
||||
|
||||
function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
const content = {};
|
||||
template.regions.forEach((region) => {
|
||||
if (region.region_type === 'image') {
|
||||
const uploaded = filesByField[`region_image_${region.id}`];
|
||||
const existing = body[`existing_region_image_${region.id}`];
|
||||
content[region.region_key] = {
|
||||
type: 'image',
|
||||
value: uploaded ? `/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
|
||||
};
|
||||
} else if (region.region_type === 'webpage') {
|
||||
const submitted = body[`region_webpage_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
||||
content[region.region_key] = {
|
||||
type: 'webpage',
|
||||
value: submitted === undefined ? current : String(submitted || '').trim()
|
||||
};
|
||||
} else if (region.region_type === 'html') {
|
||||
const submitted = body[`region_html_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
||||
content[region.region_key] = {
|
||||
type: 'html',
|
||||
value: submitted === undefined ? current : String(submitted || '')
|
||||
};
|
||||
} else {
|
||||
const submitted = body[`region_text_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
||||
const style = getTextRegionStyle(body, region, existingContent);
|
||||
content[region.region_key] = {
|
||||
type: 'text',
|
||||
value: submitted === undefined ? current : String(submitted),
|
||||
font_family: style.font_family,
|
||||
font_size: style.font_size,
|
||||
font_color: style.font_color
|
||||
};
|
||||
}
|
||||
});
|
||||
return content;
|
||||
}
|
||||
|
||||
async function buildSlidePayload(pool, req, existingSlide) {
|
||||
const title = String(req.body.title || '').trim();
|
||||
const templateId = req.body.template_id ? Number(req.body.template_id) : null;
|
||||
const filesByField = getFilesByField(req.files || []);
|
||||
const template = templateId ? await fetchTemplateById(pool, templateId) : null;
|
||||
const existingContent = existingSlide ? parseJsonSafe(existingSlide.content_json) || {} : {};
|
||||
|
||||
if (!title) {
|
||||
const error = new Error('Slide title is required.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (templateId && !template) {
|
||||
const error = new Error('Template not found.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (template) {
|
||||
return {
|
||||
title,
|
||||
body: existingSlide ? existingSlide.body : null,
|
||||
templateId: template.id,
|
||||
contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent)),
|
||||
mediaPath: null,
|
||||
mediaType: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
body: existingSlide ? existingSlide.body : null,
|
||||
templateId: null,
|
||||
contentJson: existingSlide ? existingSlide.content_json : null,
|
||||
mediaPath: existingSlide ? existingSlide.media_path : null,
|
||||
mediaType: existingSlide ? existingSlide.media_type : null
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchSlideById,
|
||||
buildSlidePayload
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
||||
|
||||
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html'];
|
||||
|
||||
function normalizeTemplateRegionType(value) {
|
||||
const rawType = String(value || 'text').trim();
|
||||
return ALLOWED_TEMPLATE_REGION_TYPES.includes(rawType) ? rawType : 'text';
|
||||
}
|
||||
|
||||
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,
|
||||
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
|
||||
WHERE st.id = ?
|
||||
`, [id]);
|
||||
if (!templates.length) {
|
||||
return null;
|
||||
}
|
||||
const template = templates[0];
|
||||
const [regions] = await pool.query('SELECT * FROM slide_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
|
||||
template.regions = regions;
|
||||
return template;
|
||||
}
|
||||
|
||||
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,
|
||||
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
|
||||
ORDER BY st.id DESC
|
||||
`);
|
||||
const [templateRegions] = await pool.query('SELECT * FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
return { templates, templateRegions };
|
||||
}
|
||||
|
||||
function extractTemplateRegions(body) {
|
||||
const regionsJson = body.regions_json;
|
||||
if (regionsJson) {
|
||||
const parsed = parseJsonSafe(regionsJson);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((region) => ({
|
||||
region_key: String(region.region_name || region.region_key || region.label || '').trim(),
|
||||
region_type: normalizeTemplateRegionType(region.region_type),
|
||||
label: String(region.region_name || region.label || region.region_key || '').trim(),
|
||||
font_family: ['text', 'html'].includes(normalizeTemplateRegionType(region.region_type)) ? String(region.font_family || '').trim() || null : null,
|
||||
x: Number(region.x || 0),
|
||||
y: Number(region.y || 0),
|
||||
width: Number(region.width || 100),
|
||||
height: Number(region.height || 100),
|
||||
z_index: Number(region.z_index || 0)
|
||||
})).filter((region) => region.region_key && region.label);
|
||||
}
|
||||
}
|
||||
|
||||
const keys = readFormArray(body, 'region_key[]');
|
||||
const names = readFormArray(body, 'region_name[]');
|
||||
const labels = readFormArray(body, 'region_label[]');
|
||||
const types = readFormArray(body, 'region_type[]');
|
||||
const xs = readFormArray(body, 'region_x[]');
|
||||
const ys = readFormArray(body, 'region_y[]');
|
||||
const widths = readFormArray(body, 'region_width[]');
|
||||
const heights = readFormArray(body, 'region_height[]');
|
||||
const zs = readFormArray(body, 'region_z[]');
|
||||
const fonts = readFormArray(body, 'font_family[]');
|
||||
const regions = [];
|
||||
|
||||
for (let i = 0; i < keys.length; i += 1) {
|
||||
const name = String(names[i] || keys[i] || labels[i] || '').trim();
|
||||
const rawType = String(types[i] || 'text').trim();
|
||||
const regionType = normalizeTemplateRegionType(rawType);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
regions.push({
|
||||
region_key: name,
|
||||
region_type: regionType,
|
||||
label: name,
|
||||
font_family: ['text', 'html'].includes(regionType) ? String(fonts[i] || 'Arial').trim() || 'Arial' : null,
|
||||
x: Number(xs[i] || 0),
|
||||
y: Number(ys[i] || 0),
|
||||
width: Number(widths[i] || 100),
|
||||
height: Number(heights[i] || 100),
|
||||
z_index: Number(zs[i] || 0)
|
||||
});
|
||||
}
|
||||
|
||||
return regions;
|
||||
}
|
||||
|
||||
function getFilesByField(files) {
|
||||
const map = {};
|
||||
(files || []).forEach((file) => {
|
||||
map[file.fieldname] = file;
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
async function buildTemplatePayload(pool, req, existingTemplate) {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const canvasSizeId = req.body.canvas_size_id ? Number(req.body.canvas_size_id) : null;
|
||||
let canvasWidth = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_width) || 1920));
|
||||
let canvasHeight = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_height) || 1080));
|
||||
let regions = extractTemplateRegions(req.body);
|
||||
const filesByField = getFilesByField(req.files || []);
|
||||
const backgroundImage = filesByField.background_image;
|
||||
const backgroundImagePath = backgroundImage
|
||||
? `/uploads/${backgroundImage.filename}`
|
||||
: String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null;
|
||||
|
||||
if (!name) {
|
||||
const error = new Error('Template name is required.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let resolvedCanvasSizeId = canvasSizeId;
|
||||
if (resolvedCanvasSizeId) {
|
||||
const [canvasSizes] = await pool.query('SELECT * FROM canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]);
|
||||
const canvasSize = canvasSizes[0];
|
||||
if (!canvasSize) {
|
||||
const error = new Error('Canvas size not found.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
canvasWidth = Number(canvasSize.width);
|
||||
canvasHeight = Number(canvasSize.height);
|
||||
} else {
|
||||
resolvedCanvasSizeId = null;
|
||||
}
|
||||
|
||||
if (!regions.length) {
|
||||
if (existingTemplate) {
|
||||
const error = new Error('At least one region is required.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
regions = [{
|
||||
region_key: 'region_1',
|
||||
region_type: 'text',
|
||||
label: 'Region 1',
|
||||
font_family: 'Arial',
|
||||
x: 120,
|
||||
y: 120,
|
||||
width: Math.max(200, Math.round(canvasWidth * 0.22)),
|
||||
height: Math.max(120, Math.round(canvasHeight * 0.15)),
|
||||
z_index: 1
|
||||
}];
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
canvasSizeId: resolvedCanvasSizeId,
|
||||
canvasSizeWidth: canvasWidth,
|
||||
canvasSizeHeight: canvasHeight,
|
||||
backgroundImagePath,
|
||||
regions
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchTemplateById,
|
||||
fetchTemplatesData,
|
||||
extractTemplateRegions,
|
||||
buildTemplatePayload,
|
||||
parseJsonSafe
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
function parseJsonSafe(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readFormArray(body, key) {
|
||||
if (Array.isArray(body[key])) {
|
||||
return body[key];
|
||||
}
|
||||
if (body[key] === undefined) {
|
||||
return [];
|
||||
}
|
||||
return [body[key]];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseJsonSafe,
|
||||
readFormArray
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const { hashPassword } = require('./auth');
|
||||
|
||||
function createPool() {
|
||||
return mysql.createPool({
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT || 3306),
|
||||
user: process.env.DB_USER || 'signage_user',
|
||||
password: process.env.DB_PASSWORD || 'signage_password',
|
||||
database: process.env.DB_NAME || 'signage',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
namedPlaceholders: true
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureSchema(pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS canvas_sizes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
width INT NOT NULL,
|
||||
height INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_canvas_sizes_dimensions (width, height)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await pool.query('ALTER TABLE canvas_sizes ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
||||
await pool.query('ALTER TABLE canvas_sizes ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at');
|
||||
await pool.query('ALTER TABLE canvas_sizes ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER modified_at');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await pool.query('ALTER TABLE playlists ADD COLUMN IF NOT EXISTS fade_between_slides TINYINT(1) NOT NULL DEFAULT 0 AFTER name');
|
||||
await pool.query('ALTER TABLE playlists ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
||||
await pool.query('ALTER TABLE playlists ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at');
|
||||
await pool.query('ALTER TABLE playlists ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER modified_at');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS slide_templates (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
canvas_size_id INT NULL,
|
||||
background_image_path VARCHAR(512) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query('ALTER TABLE slide_templates ADD COLUMN IF NOT EXISTS canvas_size_id INT NULL AFTER name');
|
||||
await pool.query('ALTER TABLE slide_templates ADD COLUMN IF NOT EXISTS background_image_path VARCHAR(512) NULL');
|
||||
await pool.query('ALTER TABLE slide_templates ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
||||
await pool.query('ALTER TABLE slide_templates ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at');
|
||||
await pool.query('ALTER TABLE slide_templates ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER modified_at');
|
||||
|
||||
await pool.query(`
|
||||
INSERT IGNORE INTO canvas_sizes (name, width, height) VALUES
|
||||
('Full HD', 1920, 1080),
|
||||
('HD', 1280, 720),
|
||||
('4K UHD', 3840, 2160),
|
||||
('Portrait Full HD', 1080, 1920),
|
||||
('Portrait HD', 720, 1280)
|
||||
`);
|
||||
|
||||
const [legacyTemplateColumns] = await pool.query(`
|
||||
SELECT COUNT(*) AS column_count
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'slide_templates'
|
||||
AND column_name IN ('canvas_width', 'canvas_height')
|
||||
`);
|
||||
if (legacyTemplateColumns[0] && Number(legacyTemplateColumns[0].column_count) === 2) {
|
||||
await pool.query(`
|
||||
UPDATE slide_templates st
|
||||
JOIN canvas_sizes cs ON cs.width = st.canvas_width AND cs.height = st.canvas_height
|
||||
SET st.canvas_size_id = cs.id
|
||||
WHERE st.canvas_size_id IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS slide_template_regions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
template_id INT NOT NULL,
|
||||
region_key VARCHAR(100) NOT NULL,
|
||||
region_type VARCHAR(20) NOT NULL,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
x INT NOT NULL DEFAULT 0,
|
||||
y INT NOT NULL DEFAULT 0,
|
||||
width INT NOT NULL DEFAULT 100,
|
||||
height INT NOT NULL DEFAULT 100,
|
||||
z_index INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query('ALTER TABLE slide_template_regions ADD COLUMN IF NOT EXISTS font_family VARCHAR(100) NULL AFTER label');
|
||||
await pool.query('ALTER TABLE slide_template_regions ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
||||
await pool.query('ALTER TABLE slide_template_regions ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at');
|
||||
await pool.query('ALTER TABLE slide_template_regions ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER modified_at');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS slides (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
body TEXT NULL,
|
||||
template_id INT NULL,
|
||||
content_json JSON NULL,
|
||||
media_path VARCHAR(512) NULL,
|
||||
media_type VARCHAR(100) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query('ALTER TABLE slides ADD COLUMN IF NOT EXISTS body TEXT NULL AFTER title');
|
||||
await pool.query('ALTER TABLE slides ADD COLUMN IF NOT EXISTS template_id INT NULL AFTER body');
|
||||
await pool.query('ALTER TABLE slides ADD COLUMN IF NOT EXISTS content_json JSON NULL AFTER template_id');
|
||||
await pool.query('ALTER TABLE slides ADD COLUMN IF NOT EXISTS media_path VARCHAR(512) NULL AFTER content_json');
|
||||
await pool.query('ALTER TABLE slides ADD COLUMN IF NOT EXISTS media_type VARCHAR(100) NULL AFTER media_path');
|
||||
await pool.query('ALTER TABLE slides ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
||||
await pool.query('ALTER TABLE slides ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at');
|
||||
await pool.query('ALTER TABLE slides ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER modified_at');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS playlist_slides (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
playlist_id INT NOT NULL,
|
||||
slide_id INT NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
duration_seconds INT NOT NULL DEFAULT 10,
|
||||
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
|
||||
schedule_start_datetime DATETIME NULL,
|
||||
schedule_end_datetime DATETIME NULL,
|
||||
schedule_start_time TIME NULL,
|
||||
schedule_end_time TIME NULL,
|
||||
schedule_days_json JSON NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_playlist_slides_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_playlist_slides_slide FOREIGN KEY (slide_id) REFERENCES slides(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS duration_seconds INT NOT NULL DEFAULT 10 AFTER position');
|
||||
await pool.query("ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always' AFTER duration_seconds");
|
||||
await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS schedule_start_datetime DATETIME NULL AFTER schedule_mode');
|
||||
await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS schedule_end_datetime DATETIME NULL AFTER schedule_start_datetime');
|
||||
await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS schedule_start_time TIME NULL AFTER schedule_end_datetime');
|
||||
await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS schedule_end_time TIME NULL AFTER schedule_start_time');
|
||||
await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS schedule_days_json JSON NULL AFTER schedule_end_time');
|
||||
await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
||||
await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at');
|
||||
await pool.query('ALTER TABLE playlist_slides ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER modified_at');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS screens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(255) NOT NULL UNIQUE,
|
||||
playlist_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_screens_playlist FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query('ALTER TABLE screens ADD COLUMN IF NOT EXISTS playlist_id INT NULL AFTER slug');
|
||||
await pool.query('ALTER TABLE screens ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
||||
await pool.query('ALTER TABLE screens ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at');
|
||||
await pool.query('ALTER TABLE screens ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER modified_at');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NULL,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash CHAR(64) NOT NULL,
|
||||
password_salt VARCHAR(64) NOT NULL,
|
||||
password_iterations INT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await pool.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS name VARCHAR(255) NULL AFTER id');
|
||||
await pool.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash CHAR(64) NOT NULL AFTER username');
|
||||
await pool.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password_salt VARCHAR(64) NOT NULL AFTER password_hash');
|
||||
await pool.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password_iterations INT NOT NULL AFTER password_salt');
|
||||
await pool.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
||||
await pool.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at');
|
||||
await pool.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER modified_at');
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
session_hash CHAR(64) PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_auth_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
await pool.query('ALTER TABLE auth_sessions ADD COLUMN IF NOT EXISTS created_by INT NULL AFTER created_at');
|
||||
await pool.query('ALTER TABLE auth_sessions ADD COLUMN IF NOT EXISTS modified_by INT NULL AFTER last_used_at');
|
||||
|
||||
const [userCountRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
|
||||
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
|
||||
const username = String(process.env.DEFAULT_ADMIN_USERNAME || 'admin').trim() || 'admin';
|
||||
const name = String(process.env.DEFAULT_ADMIN_NAME || 'Admin').trim() || 'Admin';
|
||||
const password = String(process.env.DEFAULT_ADMIN_PASSWORD || 'admin').trim() || 'admin';
|
||||
const passwordRecord = hashPassword(password);
|
||||
await pool.query(
|
||||
'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, null, null]
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query('UPDATE users SET name = username WHERE name IS NULL OR name = ""');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPool,
|
||||
ensureSchema
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
+487
@@ -0,0 +1,487 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const common = require('./common');
|
||||
|
||||
async function buildScreenPlaylist(pool, slug) {
|
||||
const [screenRows] = await pool.query('SELECT * 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: [] };
|
||||
}
|
||||
|
||||
const [playlistRows] = await pool.query('SELECT * 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 = {};
|
||||
if (templateIds.length) {
|
||||
const [templateRows] = await pool.query(`
|
||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.created_at, st.modified_at,
|
||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM slide_templates st
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE st.id IN (?)
|
||||
`, [templateIds]);
|
||||
const [regionRows] = await pool.query('SELECT * FROM slide_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
|
||||
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_type, slide.media_path),
|
||||
template_id: slide.template_id,
|
||||
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
||||
content: common.parseJsonSafe(slide.content_json) || {}
|
||||
};
|
||||
});
|
||||
|
||||
return { screen, playlist, slides };
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const app = express();
|
||||
const pool = common.createPool();
|
||||
const PORT = Number(process.env.PLAYER_PORT || 3001);
|
||||
const ASSET_DIR = path.join(__dirname, 'player', 'public');
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
|
||||
const connectionsBySlug = new Map();
|
||||
const dashboardListenersBySlug = new Map();
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.use('/assets', express.static(ASSET_DIR));
|
||||
app.use('/uploads', express.static(UPLOAD_DIR));
|
||||
|
||||
function getConnectionBucket(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
if (!connectionsBySlug.has(key)) {
|
||||
connectionsBySlug.set(key, new Map());
|
||||
}
|
||||
return connectionsBySlug.get(key);
|
||||
}
|
||||
|
||||
function removeConnection(slug, connectionId) {
|
||||
const bucket = connectionsBySlug.get(slug);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
bucket.delete(connectionId);
|
||||
if (!bucket.size) {
|
||||
connectionsBySlug.delete(slug);
|
||||
}
|
||||
}
|
||||
|
||||
function getDashboardListenerBucket(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
if (!dashboardListenersBySlug.has(key)) {
|
||||
dashboardListenersBySlug.set(key, new Set());
|
||||
}
|
||||
return dashboardListenersBySlug.get(key);
|
||||
}
|
||||
|
||||
function removeDashboardListener(slug, socket) {
|
||||
const key = String(slug || '').trim();
|
||||
const bucket = dashboardListenersBySlug.get(key);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
bucket.delete(socket);
|
||||
if (!bucket.size) {
|
||||
dashboardListenersBySlug.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function buildClientLabel(connection) {
|
||||
const clientId = String(connection.clientId || '').trim();
|
||||
const userAgent = String(connection.userAgent || '').trim();
|
||||
const clientIp = String(connection.clientIp || '').trim();
|
||||
const viewport = connection.viewport && typeof connection.viewport === 'object'
|
||||
? connection.viewport
|
||||
: null;
|
||||
const labelParts = [];
|
||||
|
||||
if (userAgent) {
|
||||
labelParts.push(userAgent.length > 72 ? `${userAgent.slice(0, 72)}...` : userAgent);
|
||||
}
|
||||
|
||||
if (clientId) {
|
||||
labelParts.push(`id ${clientId.slice(-6)}`);
|
||||
}
|
||||
|
||||
if (clientIp) {
|
||||
labelParts.push(clientIp);
|
||||
}
|
||||
|
||||
if (viewport && Number.isFinite(Number(viewport.width)) && Number.isFinite(Number(viewport.height))) {
|
||||
labelParts.push(`${Number(viewport.width)}x${Number(viewport.height)}`);
|
||||
}
|
||||
|
||||
if (!labelParts.length) {
|
||||
return connection.remoteAddress || 'connected client';
|
||||
}
|
||||
|
||||
return labelParts.join(' • ');
|
||||
}
|
||||
|
||||
function snapshotConnections(slug) {
|
||||
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||
if (!bucket) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(bucket.values()).map(function (connection) {
|
||||
return {
|
||||
id: connection.id,
|
||||
clientId: connection.clientId || null,
|
||||
label: connection.label,
|
||||
userAgent: connection.userAgent || null,
|
||||
viewport: connection.viewport || null,
|
||||
page: connection.page || null,
|
||||
currentSlide: connection.currentSlide || null,
|
||||
paused: Boolean(connection.paused),
|
||||
blackout: Boolean(connection.blackout),
|
||||
currentSlideId: connection.currentSlide && connection.currentSlide.id ? connection.currentSlide.id : null,
|
||||
currentSlideTitle: connection.currentSlide && connection.currentSlide.title ? connection.currentSlide.title : null,
|
||||
clientIp: connection.clientIp || null,
|
||||
remoteAddress: connection.remoteAddress || null,
|
||||
connectedAt: connection.connectedAt ? connection.connectedAt.toISOString() : null,
|
||||
lastSeenAt: connection.lastSeenAt ? connection.lastSeenAt.toISOString() : null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastConnectionSnapshot(slug) {
|
||||
const key = String(slug || '').trim();
|
||||
const bucket = dashboardListenersBySlug.get(key);
|
||||
if (!bucket || !bucket.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
type: 'snapshot',
|
||||
slug: key,
|
||||
connections: snapshotConnections(slug),
|
||||
sentAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
bucket.forEach(function (socket) {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sendCommandToConnection(slug, connectionId, commandOrPayload) {
|
||||
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||
if (!bucket || !bucket.size) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const target = bucket.get(String(connectionId || '').trim());
|
||||
if (!target || target.socket.readyState !== WebSocket.OPEN) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||
? Object.assign({}, commandOrPayload)
|
||||
: { command: commandOrPayload };
|
||||
payload.type = 'command';
|
||||
payload.targetConnectionId = target.id;
|
||||
payload.sentAt = new Date().toISOString();
|
||||
|
||||
target.socket.send(JSON.stringify(payload));
|
||||
return 1;
|
||||
}
|
||||
|
||||
function broadcastCommand(slug, commandOrPayload) {
|
||||
const bucket = connectionsBySlug.get(String(slug || '').trim());
|
||||
if (!bucket || !bucket.size) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
bucket.forEach(function (connection) {
|
||||
if (connection.socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||
? Object.assign({}, commandOrPayload)
|
||||
: { command: commandOrPayload };
|
||||
payload.type = 'command';
|
||||
payload.sentAt = new Date().toISOString();
|
||||
|
||||
connection.socket.send(JSON.stringify(payload));
|
||||
sent += 1;
|
||||
});
|
||||
return sent;
|
||||
}
|
||||
|
||||
app.get('/', function (_req, res) {
|
||||
res.send('Pulse Signage player service');
|
||||
});
|
||||
|
||||
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' });
|
||||
}
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get(['/api/screens/:slug/connections', '/api/screens/:slug/clients'], async function (req, res, next) {
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
|
||||
if (!screenRows.length) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
const connections = snapshotConnections(req.params.slug);
|
||||
res.json({
|
||||
screen: screenRows[0],
|
||||
screenSlug: req.params.slug,
|
||||
count: connections.length,
|
||||
connections: connections
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/screens/:slug/commands', async function (req, res, next) {
|
||||
try {
|
||||
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
||||
const connectionId = String((req.body && (req.body.connectionId || req.body.clientId)) || req.query.connectionId || req.query.clientId || '').trim();
|
||||
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
||||
? req.body.blackout
|
||||
: req.query.blackout;
|
||||
if (!command) {
|
||||
return res.status(400).json({ error: 'Command is required' });
|
||||
}
|
||||
if (['refresh', 'reload', 'pause', 'blackout', 'previous', 'next', 'left', 'right'].indexOf(command) === -1) {
|
||||
return res.status(400).json({ error: 'Unsupported command' });
|
||||
}
|
||||
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [req.params.slug]);
|
||||
if (!screenRows.length) {
|
||||
return res.status(404).json({ error: 'Screen not found' });
|
||||
}
|
||||
|
||||
const commandPayload = command === 'blackout' && blackoutValue !== undefined
|
||||
? {
|
||||
command: command,
|
||||
blackout: blackoutValue
|
||||
}
|
||||
: command;
|
||||
|
||||
const sent = connectionId
|
||||
? sendCommandToConnection(req.params.slug, connectionId, commandPayload)
|
||||
: broadcastCommand(req.params.slug, commandPayload);
|
||||
res.json({
|
||||
screen: screenRows[0],
|
||||
screenSlug: req.params.slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null,
|
||||
sent: sent
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.use(function (error, _req, res, _next) {
|
||||
console.error(error);
|
||||
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
|
||||
});
|
||||
|
||||
await common.ensureSchema(pool);
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
server.on('upgrade', function (request, socket, head) {
|
||||
let pathname = '';
|
||||
try {
|
||||
pathname = new URL(request.url, 'http://localhost').pathname;
|
||||
} catch (_error) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const dashboardMatch = pathname.match(/^\/ws\/screens\/([^/]+)\/events$/);
|
||||
const playerMatch = pathname.match(/^\/ws\/screens\/([^/]+)$/);
|
||||
if (!dashboardMatch && !playerMatch) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const slug = decodeURIComponent((dashboardMatch || playerMatch)[1]);
|
||||
wss.handleUpgrade(request, socket, head, function (ws) {
|
||||
wss.emit('connection', ws, request, slug, dashboardMatch ? 'dashboard' : 'player');
|
||||
});
|
||||
});
|
||||
|
||||
wss.on('connection', function (socket, request, slug, role) {
|
||||
if (role === 'dashboard') {
|
||||
const listenerBucket = getDashboardListenerBucket(slug);
|
||||
if (!listenerBucket) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
listenerBucket.add(socket);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'snapshot',
|
||||
slug: String(slug || '').trim(),
|
||||
connections: snapshotConnections(slug),
|
||||
sentAt: new Date().toISOString()
|
||||
}));
|
||||
|
||||
socket.on('close', function () {
|
||||
removeDashboardListener(slug, socket);
|
||||
});
|
||||
|
||||
socket.on('error', function () {
|
||||
removeDashboardListener(slug, socket);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteAddress = request.socket && request.socket.remoteAddress ? request.socket.remoteAddress : null;
|
||||
const forwardedFor = String(request.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
||||
const connectionId = crypto.randomUUID();
|
||||
const connection = {
|
||||
id: connectionId,
|
||||
slug: slug,
|
||||
socket: socket,
|
||||
clientId: null,
|
||||
userAgent: null,
|
||||
viewport: null,
|
||||
page: null,
|
||||
paused: false,
|
||||
blackout: false,
|
||||
clientIp: forwardedFor || remoteAddress,
|
||||
remoteAddress: remoteAddress,
|
||||
label: forwardedFor || remoteAddress || 'connected client',
|
||||
connectedAt: new Date(),
|
||||
lastSeenAt: new Date()
|
||||
};
|
||||
const bucket = getConnectionBucket(slug);
|
||||
|
||||
if (!bucket) {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.set(connectionId, connection);
|
||||
|
||||
socket.on('message', function (rawMessage) {
|
||||
connection.lastSeenAt = new Date();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(String(rawMessage || ''));
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload || (payload.type !== 'hello' && payload.type !== 'state')) {
|
||||
return;
|
||||
}
|
||||
|
||||
connection.clientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId;
|
||||
connection.userAgent = payload.userAgent ? String(payload.userAgent).trim() : connection.userAgent;
|
||||
connection.viewport = payload.viewport && typeof payload.viewport === 'object' ? payload.viewport : connection.viewport;
|
||||
connection.page = payload.page ? String(payload.page).trim() : connection.page;
|
||||
connection.paused = Boolean(payload.paused);
|
||||
connection.blackout = Boolean(payload.blackout);
|
||||
connection.clientIp = payload.clientIp ? String(payload.clientIp).trim() : connection.clientIp;
|
||||
connection.currentSlide = payload.currentSlide && typeof payload.currentSlide === 'object' ? {
|
||||
id: payload.currentSlide.id || null,
|
||||
title: payload.currentSlide.title || '',
|
||||
kind: payload.currentSlide.kind || '',
|
||||
playlistSignature: payload.currentSlide.playlistSignature || ''
|
||||
} : connection.currentSlide;
|
||||
connection.label = buildClientLabel(connection);
|
||||
connection.lastSeenAt = new Date();
|
||||
broadcastConnectionSnapshot(slug);
|
||||
});
|
||||
|
||||
socket.on('close', function () {
|
||||
removeConnection(slug, connectionId);
|
||||
broadcastConnectionSnapshot(slug);
|
||||
});
|
||||
|
||||
socket.on('error', function () {
|
||||
removeConnection(slug, connectionId);
|
||||
broadcastConnectionSnapshot(slug);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, function () {
|
||||
console.log(`Pulse Signage app listening on port ${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { start };
|
||||
|
||||
if (require.main === module) {
|
||||
start().catch(function (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slide-shell {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 560ms ease;
|
||||
}
|
||||
|
||||
.slide-shell.is-visible {
|
||||
opacity: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.slide-shell.is-exiting {
|
||||
opacity: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.slide-shell > .slide {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body.screen-blackout #app {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slide-canvas {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.slide-media {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slide-media img,
|
||||
.slide-media video,
|
||||
.slide-media iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slide img,
|
||||
.slide video,
|
||||
.slide iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
padding: 14px 18px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.body {
|
||||
position: absolute;
|
||||
left: 5%;
|
||||
right: 5%;
|
||||
bottom: 8%;
|
||||
padding: 16px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 16px;
|
||||
font-size: 2.5vw;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 2rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.debug-info {
|
||||
margin: 16px auto 0;
|
||||
padding: 12px;
|
||||
max-width: 90%;
|
||||
white-space: pre-wrap;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e5e7eb;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.template-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.template-stage .template-background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: fill;
|
||||
display: block;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.template-region {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.template-region.text {
|
||||
color: #fff;
|
||||
display: block;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.template-region.text > * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.template-region.text > * + * {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
.template-region.text ul,
|
||||
.template-region.text ol {
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
.template-region.image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.template-region.webpage iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-region.html iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-region-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;
|
||||
}
|
||||
|
||||
.template-region.text table,
|
||||
.template-region.text th,
|
||||
.template-region.text td {
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.template-region.text table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.template-region.text th,
|
||||
.template-region.text td {
|
||||
padding: 0.35em 0.5em;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.template-region.text th {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.webpage-preloads {
|
||||
position: fixed;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.webpage-preload-frame {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('./common');
|
||||
+1704
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,356 @@
|
||||
.slide-editor-top {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.slide-image-file {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.slide-preview-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.slide-preview-header {
|
||||
margin-bottom: 0;
|
||||
padding: 16px 16px 8px;
|
||||
}
|
||||
|
||||
.slide-preview-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.slide-preview-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(135deg, #111827, #1f2937);
|
||||
}
|
||||
|
||||
.slide-preview-scroll {
|
||||
min-height: 0;
|
||||
padding: 0 16px 16px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.slide-preview-scroll button {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.slide-preview-background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.slide-preview-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #cbd5e1;
|
||||
background: rgba(17, 24, 39, 0.35);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.slide-preview-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.slide-preview-region {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: rgba(15, 23, 42, 0.2);
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.slide-preview-region h1,
|
||||
.slide-preview-region h2,
|
||||
.slide-preview-region h3,
|
||||
.slide-preview-region h4,
|
||||
.slide-preview-region h5,
|
||||
.slide-preview-region h6,
|
||||
.slide-preview-region p,
|
||||
.slide-preview-region blockquote,
|
||||
.slide-preview-region ul,
|
||||
.slide-preview-region ol,
|
||||
.slide-preview-region pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.slide-preview-region ul,
|
||||
.slide-preview-region ol {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.slide-preview-region code {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.slide-preview-text-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slide-preview-image-region {
|
||||
background: rgba(15, 23, 42, 0.25);
|
||||
}
|
||||
|
||||
.slide-preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slide-preview-webpage-region {
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-preview-webpage-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-preview-html-region {
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-preview-html-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-schedule-dialog {
|
||||
width: min(900px, calc(100vw - 32px));
|
||||
max-width: 900px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
.slide-schedule-dialog::backdrop {
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
.slide-schedule-dialog iframe {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 120px;
|
||||
border: 0;
|
||||
margin-top: 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.slide-schedule-dialog-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.slide-schedule-dialog button {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.slide-schedule-dialog button.secondary {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.slide-schedule-dialog button.secondary:hover {
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
|
||||
.slide-schedule-dialog form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.slide-schedule-dialog .card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.schedule-type-field {
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.schedule-day-option {
|
||||
margin-top: 0;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.schedule-day-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.schedule-day-label {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.schedule-day-option .switch-ui {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.schedule-day-option input:focus-visible + .switch-ui {
|
||||
outline: 3px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.schedule-day-option input:checked + .switch-ui {
|
||||
background: linear-gradient(180deg, var(--primary), var(--primary-dark));
|
||||
border-color: rgba(37, 99, 235, 0.55);
|
||||
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08), 0 0 0 4px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.schedule-save-actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.schedule-save-actions button {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.slide-preview-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.slide-preview-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.slide-preview-table tbody tr {
|
||||
background: none;
|
||||
border: default;
|
||||
}
|
||||
|
||||
.slide-preview-table th,
|
||||
.slide-preview-table td {
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.slide-preview-table th {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rich-editor {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rich-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rich-toolbar button {
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.rich-surface {
|
||||
min-height: 240px;
|
||||
padding: 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.rich-surface:focus {
|
||||
outline: 2px solid rgba(37, 99, 235, 0.3);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.editorjs-holder {
|
||||
min-height: 240px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
padding: 16px 12px 12px;
|
||||
background: var(--editor-paper);
|
||||
color: var(--editor-text);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.editorjs-holder .codex-editor,
|
||||
.editorjs-holder .codex-editor__redactor,
|
||||
.editorjs-holder .ce-block,
|
||||
.editorjs-holder .ce-paragraph,
|
||||
.editorjs-holder .ce-header,
|
||||
.editorjs-holder .ce-list,
|
||||
.editorjs-holder .ce-list__item,
|
||||
.editorjs-holder [contenteditable],
|
||||
.editorjs-holder [contenteditable] * {
|
||||
color: var(--editor-text);
|
||||
}
|
||||
|
||||
.editorjs-holder [contenteditable] {
|
||||
caret-color: var(--editor-text);
|
||||
}
|
||||
|
||||
.editorjs-holder .codex-editor__redactor [contenteditable]:empty:after {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.slide-editor-top {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,245 @@
|
||||
.template-designer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.template-designer-main {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.template-meta-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.template-meta-row > label {
|
||||
flex: 1 1 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.template-designer-sidebar {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.template-designer-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
#template-form .template-designer-actions button {
|
||||
flex: 1 1 0;
|
||||
width: auto;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.region-select {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#canvas-size-select:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#canvas-size-summary.is-locked {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.designer-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #0f172a;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #1f2937;
|
||||
cursor: crosshair;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.designer-stage img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: fill;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.designer-stage .empty-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.designer-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.designer-rect {
|
||||
position: absolute;
|
||||
border: 2px solid #22c55e;
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.designer-rect.selected {
|
||||
border-color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
}
|
||||
|
||||
.designer-rect-label {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
padding: 2px 6px;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.designer-rect .resize-handle {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.designer-rect .resize-handle.nw {
|
||||
left: -7px;
|
||||
top: -7px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.designer-rect .resize-handle.ne {
|
||||
right: -7px;
|
||||
top: -7px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.designer-rect .resize-handle.sw {
|
||||
left: -7px;
|
||||
bottom: -7px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.designer-rect .resize-handle.se {
|
||||
right: -7px;
|
||||
bottom: -7px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.designer-draft {
|
||||
border-style: dashed;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.region-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#template-form {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
#template-form label {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
#template-form input,
|
||||
#template-form textarea,
|
||||
#template-form select,
|
||||
#template-form button {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
#template-form .card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.template-field-card + .template-field-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.template-designer-sidebar .row.template-designer-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.template-save-button {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.template-field-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.template-text-style-row {
|
||||
margin-top: 0;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.template-text-style-row input[type="color"] {
|
||||
height: 36px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.template-designer-sidebar .row.template-designer-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.image-filename {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.region-item {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.region-item.selected {
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 1px #f59e0b inset;
|
||||
}
|
||||
|
||||
.region-item .row {
|
||||
align-items: end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.region-item[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -0,0 +1,942 @@
|
||||
(function () {
|
||||
var THEME_STORAGE_KEY = 'webui-theme';
|
||||
|
||||
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 getTableHeaderText(headerCell) {
|
||||
return String(headerCell && headerCell.textContent ? headerCell.textContent : '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function cellHasButtonContent(cell) {
|
||||
return Boolean(cell && cell.querySelector && cell.querySelector('button, form, input, select, textarea'));
|
||||
}
|
||||
|
||||
function getCellSortValue(cell) {
|
||||
if (!cell) {
|
||||
return '';
|
||||
}
|
||||
var sortValue = cell.getAttribute && cell.getAttribute('data-sort-value');
|
||||
if (sortValue !== null && sortValue !== undefined && sortValue !== '') {
|
||||
return String(sortValue).trim();
|
||||
}
|
||||
return String(cell.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function getComparableSortValue(rawValue) {
|
||||
var value = String(rawValue || '').trim();
|
||||
if (!value) {
|
||||
return { type: 'empty', value: '' };
|
||||
}
|
||||
|
||||
var numericValue = Number(value.replace(/,/g, ''));
|
||||
if (!Number.isNaN(numericValue) && value !== '') {
|
||||
return { type: 'number', value: numericValue };
|
||||
}
|
||||
|
||||
var dateValue = Date.parse(value);
|
||||
if (!Number.isNaN(dateValue)) {
|
||||
return { type: 'date', value: dateValue };
|
||||
}
|
||||
|
||||
return { type: 'string', value: value.toLowerCase() };
|
||||
}
|
||||
|
||||
function compareSortValues(leftValue, rightValue) {
|
||||
if (leftValue.type === 'empty' && rightValue.type === 'empty') {
|
||||
return 0;
|
||||
}
|
||||
if (leftValue.type === 'empty') {
|
||||
return 1;
|
||||
}
|
||||
if (rightValue.type === 'empty') {
|
||||
return -1;
|
||||
}
|
||||
if (leftValue.type === rightValue.type) {
|
||||
if (leftValue.value < rightValue.value) {
|
||||
return -1;
|
||||
}
|
||||
if (leftValue.value > rightValue.value) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return String(leftValue.value).localeCompare(String(rightValue.value), undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function getSortableTableState(table) {
|
||||
if (!table._sortableState) {
|
||||
table._sortableState = {
|
||||
columnIndex: null,
|
||||
direction: 'asc'
|
||||
};
|
||||
}
|
||||
return table._sortableState;
|
||||
}
|
||||
|
||||
function updateSortableHeaderState(table) {
|
||||
var state = getSortableTableState(table);
|
||||
var headerCells = table.tHead && table.tHead.rows && table.tHead.rows.length ? Array.prototype.slice.call(table.tHead.rows[0].cells || []) : [];
|
||||
headerCells.forEach(function (headerCell, index) {
|
||||
if (!headerCell) {
|
||||
return;
|
||||
}
|
||||
headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable');
|
||||
headerCell.removeAttribute('aria-sort');
|
||||
headerCell.removeAttribute('role');
|
||||
headerCell.removeAttribute('tabindex');
|
||||
|
||||
if (isSortableTableColumn(table, index)) {
|
||||
headerCell.classList.add('sortable');
|
||||
headerCell.setAttribute('role', 'button');
|
||||
headerCell.setAttribute('tabindex', '0');
|
||||
if (state.columnIndex === index) {
|
||||
headerCell.classList.add(state.direction === 'desc' ? 'sort-desc' : 'sort-asc');
|
||||
headerCell.setAttribute('aria-sort', state.direction === 'desc' ? 'descending' : 'ascending');
|
||||
} else {
|
||||
headerCell.setAttribute('aria-sort', 'none');
|
||||
}
|
||||
} else {
|
||||
headerCell.classList.add('unsortable');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isSortableTableColumn(table, columnIndex) {
|
||||
var headerCell = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0].cells[columnIndex] : null;
|
||||
if (!headerCell) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/actions?|buttons?/i.test(getTableHeaderText(headerCell))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var bodies = table.tBodies ? Array.prototype.slice.call(table.tBodies) : [];
|
||||
for (var i = 0; i < bodies.length; i += 1) {
|
||||
var rows = Array.prototype.slice.call(bodies[i].rows || []);
|
||||
for (var j = 0; j < rows.length; j += 1) {
|
||||
var cell = rows[j].cells ? rows[j].cells[columnIndex] : null;
|
||||
if (cell && cellHasButtonContent(cell)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function sortTable(table, columnIndex, direction) {
|
||||
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
|
||||
if (!tbody) {
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = Array.prototype.slice.call(tbody.rows || []);
|
||||
if (!rows.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var multiplier = direction === 'desc' ? -1 : 1;
|
||||
rows.sort(function (leftRow, rightRow) {
|
||||
var leftCell = leftRow.cells ? leftRow.cells[columnIndex] : null;
|
||||
var rightCell = rightRow.cells ? rightRow.cells[columnIndex] : null;
|
||||
var leftComparable = getComparableSortValue(getCellSortValue(leftCell));
|
||||
var rightComparable = getComparableSortValue(getCellSortValue(rightCell));
|
||||
return compareSortValues(leftComparable, rightComparable) * multiplier;
|
||||
});
|
||||
|
||||
rows.forEach(function (row) {
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function applyTableSort(table) {
|
||||
var state = getSortableTableState(table);
|
||||
if (state.columnIndex === null || state.columnIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
sortTable(table, state.columnIndex, state.direction);
|
||||
updateSortableHeaderState(table);
|
||||
}
|
||||
|
||||
function initSortableTables() {
|
||||
var tables = Array.prototype.slice.call(document.querySelectorAll('table'));
|
||||
tables.forEach(function (table) {
|
||||
var headerRow = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0] : null;
|
||||
if (!headerRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(headerRow.cells, function (headerCell, index) {
|
||||
if (!isSortableTableColumn(table, index)) {
|
||||
return;
|
||||
}
|
||||
|
||||
headerCell.addEventListener('click', function () {
|
||||
var state = getSortableTableState(table);
|
||||
var nextDirection = state.columnIndex === index && state.direction === 'asc' ? 'desc' : 'asc';
|
||||
state.columnIndex = index;
|
||||
state.direction = nextDirection;
|
||||
sortTable(table, index, nextDirection);
|
||||
updateSortableHeaderState(table);
|
||||
});
|
||||
|
||||
headerCell.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
headerCell.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
updateSortableHeaderState(table);
|
||||
});
|
||||
}
|
||||
|
||||
function renderClientActionCell(client) {
|
||||
var paused = Boolean(client.paused);
|
||||
var pauseButtonClass = 'button-link list-action' + (paused ? ' is-paused' : '');
|
||||
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 ? '0' : '1';
|
||||
|
||||
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 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);
|
||||
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 ? '0' : '1';
|
||||
}
|
||||
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><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;
|
||||
});
|
||||
|
||||
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><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);
|
||||
}
|
||||
|
||||
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('');
|
||||
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.textContent = label;
|
||||
blackoutButton.className = 'button-link list-action' + (allBlackout ? ' is-blackout' : '');
|
||||
if (blackoutInput) {
|
||||
blackoutInput.value = allBlackout ? '0' : '1';
|
||||
}
|
||||
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);
|
||||
updateSidebarStatus(Boolean(state.playerServiceConnected), 'Live player feed');
|
||||
}
|
||||
|
||||
function updateSidebarStatus(status, label) {
|
||||
var dot = document.getElementById('sidebar-status-dot');
|
||||
var text = document.getElementById('sidebar-status-text');
|
||||
var pill = document.getElementById('sidebar-status-pill');
|
||||
if (!dot && !text && !pill) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedStatus = status === 'online' || status === 'offline' || status === 'unknown'
|
||||
? status
|
||||
: (status ? 'online' : 'offline');
|
||||
var statusLabel = normalizedStatus === 'online'
|
||||
? (label || 'Connected to player feed')
|
||||
: normalizedStatus === 'offline'
|
||||
? 'Disconnected from player feed'
|
||||
: (label || 'Connecting to player feed');
|
||||
var pillLabel = normalizedStatus === 'online' ? 'Connected' : normalizedStatus === 'offline' ? 'Disconnected' : 'Connecting';
|
||||
|
||||
if (dot) {
|
||||
dot.classList.remove('status-dot--unknown', 'status-dot--online', 'status-dot--offline');
|
||||
dot.classList.add(normalizedStatus === 'online' ? 'status-dot--online' : normalizedStatus === 'offline' ? 'status-dot--offline' : 'status-dot--unknown');
|
||||
dot.setAttribute('aria-label', statusLabel);
|
||||
dot.setAttribute('title', statusLabel);
|
||||
}
|
||||
|
||||
if (text) {
|
||||
text.textContent = statusLabel;
|
||||
}
|
||||
|
||||
if (pill) {
|
||||
pill.classList.remove('status-pill--unknown', 'status-pill--online', 'status-pill--offline');
|
||||
pill.classList.add(normalizedStatus === 'online' ? 'status-pill--online' : normalizedStatus === 'offline' ? 'status-pill--offline' : 'status-pill--unknown');
|
||||
pill.textContent = pillLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function connectDashboardSocket() {
|
||||
if (!window.WebSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
var socket = null;
|
||||
var reconnectTimer = null;
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) {
|
||||
return;
|
||||
}
|
||||
reconnectTimer = window.setTimeout(function () {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/admin/dashboard');
|
||||
updateSidebarStatus('unknown', 'Connecting to player feed');
|
||||
|
||||
socket.onmessage = function (event) {
|
||||
try {
|
||||
var payload = JSON.parse(String(event.data || '{}'));
|
||||
if (payload && payload.type === 'dashboard-state') {
|
||||
handleDashboardState(payload.state);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore malformed dashboard payloads.
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = function () {
|
||||
updateSidebarStatus('offline');
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = function () {
|
||||
updateSidebarStatus('offline');
|
||||
try {
|
||||
socket.close();
|
||||
} catch (_error) {
|
||||
// ignore close errors
|
||||
}
|
||||
};
|
||||
|
||||
socket.onopen = function () {
|
||||
updateSidebarStatus('unknown', 'Connecting to player feed');
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
}
|
||||
|
||||
function dismissToast(toast) {
|
||||
toast.classList.add('toast-hide');
|
||||
window.setTimeout(function () {
|
||||
if (toast && toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 220);
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
var text = String(message || '').trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
var existingToast = document.getElementById('app-toast');
|
||||
var existingBody = existingToast ? existingToast.querySelector('.toast-body') : null;
|
||||
if (existingToast && existingBody) {
|
||||
existingBody.textContent = text;
|
||||
existingToast.classList.remove('toast-hide');
|
||||
window.clearTimeout(existingToast._dismissTimer);
|
||||
existingToast._dismissTimer = window.setTimeout(function () {
|
||||
dismissToast(existingToast);
|
||||
}, 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
toast.innerHTML = '<div class="toast-body"></div><button type="button" class="toast-close" aria-label="Dismiss notification">×</button>';
|
||||
toast.querySelector('.toast-body').textContent = text;
|
||||
|
||||
var closeButton = toast.querySelector('.toast-close');
|
||||
closeButton.addEventListener('click', function () {
|
||||
dismissToast(toast);
|
||||
});
|
||||
|
||||
document.body.appendChild(toast);
|
||||
toast._dismissTimer = window.setTimeout(function () {
|
||||
dismissToast(toast);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function initToast() {
|
||||
var toast = document.getElementById('app-toast');
|
||||
var closeButton = document.getElementById('app-toast-close');
|
||||
if (!toast || !closeButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var url = new URL(window.location.href);
|
||||
if (url.searchParams.has('message')) {
|
||||
url.searchParams.delete('message');
|
||||
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore URL cleanup failures
|
||||
}
|
||||
|
||||
closeButton.addEventListener('click', function () {
|
||||
dismissToast(toast);
|
||||
});
|
||||
|
||||
window.setTimeout(function () {
|
||||
dismissToast(toast);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function initConfirmForms() {
|
||||
document.addEventListener('submit', function (event) {
|
||||
var form = event.target;
|
||||
if (!form || !form.getAttribute) {
|
||||
return;
|
||||
}
|
||||
if (form.hasAttribute && form.hasAttribute('data-async-command')) {
|
||||
return;
|
||||
}
|
||||
var message = form.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initAsyncCommandForms() {
|
||||
document.addEventListener('submit', function (event) {
|
||||
var form = event.target;
|
||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-command')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.dataset && form.dataset.busy === 'true') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
var message = form.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
form.dataset.busy = 'true';
|
||||
|
||||
var formData = new FormData(form);
|
||||
var body = new URLSearchParams();
|
||||
formData.forEach(function (value, key) {
|
||||
body.append(key, value);
|
||||
});
|
||||
|
||||
fetch(form.action, {
|
||||
method: (form.method || 'POST').toUpperCase(),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
body: body.toString(),
|
||||
credentials: 'same-origin'
|
||||
}).finally(function () {
|
||||
delete form.dataset.busy;
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
|
||||
function initAsyncSaveForms() {
|
||||
document.addEventListener('submit', function (event) {
|
||||
var form = event.target;
|
||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.dataset && form.dataset.busy === 'true') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
var message = form.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
form.dataset.busy = 'true';
|
||||
|
||||
var formData = new FormData(form);
|
||||
var hasFileValue = false;
|
||||
formData.forEach(function (value) {
|
||||
if (value && typeof value === 'object' && typeof value.name === 'string') {
|
||||
hasFileValue = true;
|
||||
}
|
||||
});
|
||||
|
||||
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
|
||||
var body = isMultipart ? formData : new URLSearchParams();
|
||||
|
||||
if (!isMultipart) {
|
||||
formData.forEach(function (value, key) {
|
||||
body.append(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
fetch(form.action, {
|
||||
method: (form.method || 'POST').toUpperCase(),
|
||||
headers: Object.assign({
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html, application/json, text/plain, */*'
|
||||
}, isMultipart ? {} : {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||
}),
|
||||
body: isMultipart ? body : body.toString(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return response.text().then(function (text) {
|
||||
throw new Error(text || 'Unable to save changes.');
|
||||
});
|
||||
}
|
||||
return response.text().then(function (text) {
|
||||
var savedMessage = '';
|
||||
try {
|
||||
var doc = new DOMParser().parseFromString(text || '', 'text/html');
|
||||
var toastBody = doc.querySelector('.toast-body');
|
||||
if (toastBody && toastBody.textContent) {
|
||||
savedMessage = toastBody.textContent.trim();
|
||||
}
|
||||
} catch (_error) {
|
||||
savedMessage = '';
|
||||
}
|
||||
showToast(savedMessage || 'Saved.');
|
||||
});
|
||||
}).catch(function (error) {
|
||||
window.alert(error.message || 'Unable to save changes.');
|
||||
}).finally(function () {
|
||||
delete form.dataset.busy;
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
|
||||
function initSubmitOnChange() {
|
||||
var fields = document.querySelectorAll('[data-submit-on-change]');
|
||||
Array.prototype.forEach.call(fields, function (field) {
|
||||
field.addEventListener('change', function () {
|
||||
var form = field.form || field.closest('form');
|
||||
if (form) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initSidebarToggle() {
|
||||
var body = document.body;
|
||||
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]'));
|
||||
var backdrop = document.querySelector('[data-sidebar-backdrop]');
|
||||
|
||||
if (!toggleButtons.length || !backdrop) {
|
||||
return;
|
||||
}
|
||||
|
||||
function setSidebarOpen(isOpen) {
|
||||
body.classList.toggle('is-sidebar-open', isOpen);
|
||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
||||
toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
setSidebarOpen(!body.classList.contains('is-sidebar-open'));
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
||||
toggleButton.addEventListener('click', function () {
|
||||
toggleSidebar();
|
||||
});
|
||||
});
|
||||
|
||||
backdrop.addEventListener('click', function () {
|
||||
setSidebarOpen(false);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape') {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getPreferredTheme() {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
function getStoredTheme() {
|
||||
try {
|
||||
var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (storedTheme === 'dark' || storedTheme === 'light') {
|
||||
return storedTheme;
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setStoredTheme(theme) {
|
||||
try {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
var normalizedTheme = theme === 'dark' ? 'dark' : 'light';
|
||||
var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark';
|
||||
var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode';
|
||||
|
||||
document.documentElement.dataset.theme = normalizedTheme;
|
||||
document.documentElement.style.colorScheme = normalizedTheme;
|
||||
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) {
|
||||
var icon = toggleButton.querySelector('.theme-toggle__icon');
|
||||
toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false');
|
||||
toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase());
|
||||
if (icon) {
|
||||
icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun');
|
||||
icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon');
|
||||
}
|
||||
});
|
||||
|
||||
return normalizedTheme;
|
||||
}
|
||||
|
||||
function initThemeToggle() {
|
||||
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
|
||||
var storedTheme = getStoredTheme();
|
||||
var theme = storedTheme || getPreferredTheme();
|
||||
|
||||
applyTheme(theme);
|
||||
|
||||
if (!toggleButtons.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
||||
toggleButton.addEventListener('click', function () {
|
||||
var nextTheme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
setStoredTheme(nextTheme);
|
||||
applyTheme(nextTheme);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initToast();
|
||||
initConfirmForms();
|
||||
initAsyncCommandForms();
|
||||
initAsyncSaveForms();
|
||||
initSubmitOnChange();
|
||||
initThemeToggle();
|
||||
initSidebarToggle();
|
||||
initSortableTables();
|
||||
if (document.getElementById('sidebar-status-pill') || document.getElementById('sidebar-status-dot') || document.getElementById('sidebar-status-text')) {
|
||||
connectDashboardSocket();
|
||||
}
|
||||
}());
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/@editorjs/header@2.8.9/dist/header.umd.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".ce-header{padding:.6em 0 3px;margin:0;line-height:1.25em;outline:none}.ce-header p,.ce-header div{padding:0!important;margin:0!important}")),document.head.appendChild(e)}}catch(n){console.error("vite-plugin-css-injected-by-js",n)}})();
|
||||
(function(o,s){typeof exports=="object"&&typeof module<"u"?module.exports=s():typeof define=="function"&&define.amd?define(s):(o=typeof globalThis<"u"?globalThis:o||self,o.Header=s())})(this,function(){"use strict";const o="",s='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M19 17V10.2135C19 10.1287 18.9011 10.0824 18.836 10.1367L16 12.5"/></svg>',d='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M16 11C16 10 19 9.5 19 12C19 13.9771 16.0684 13.9997 16.0012 16.8981C15.9999 16.9533 16.0448 17 16.1 17L19.3 17"/></svg>',u='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M16 11C16 10.5 16.8323 10 17.6 10C18.3677 10 19.5 10.311 19.5 11.5C19.5 12.5315 18.7474 12.9022 18.548 12.9823C18.5378 12.9864 18.5395 13.0047 18.5503 13.0063C18.8115 13.0456 20 13.3065 20 14.8C20 16 19.5 17 17.8 17C17.8 17 16 17 16 16.3"/></svg>',c='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M18 10L15.2834 14.8511C15.246 14.9178 15.294 15 15.3704 15C16.8489 15 18.7561 15 20.2 15M19 17C19 15.7187 19 14.8813 19 13.6"/></svg>',g='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M16 15.9C16 15.9 16.3768 17 17.8 17C19.5 17 20 15.6199 20 14.7C20 12.7323 17.6745 12.0486 16.1635 12.9894C16.094 13.0327 16 12.9846 16 12.9027V10.1C16 10.0448 16.0448 10 16.1 10H19.8"/></svg>',v='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M6 7L6 12M6 17L6 12M6 12L12 12M12 7V12M12 17L12 12"/><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M19.5 10C16.5 10.5 16 13.3285 16 15M16 15V15C16 16.1046 16.8954 17 18 17H18.3246C19.3251 17 20.3191 16.3492 20.2522 15.3509C20.0612 12.4958 16 12.6611 16 15Z"/></svg>',w='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M9 7L9 12M9 17V12M9 12L15 12M15 7V12M15 17L15 12"/></svg>';/**
|
||||
* Header block for the Editor.js.
|
||||
*
|
||||
* @author CodeX (team@ifmo.su)
|
||||
* @copyright CodeX 2018
|
||||
* @license MIT
|
||||
* @version 2.0.0
|
||||
*/class p{constructor({data:e,config:t,api:i,readOnly:r}){this.api=i,this.readOnly=r,this._config=t??null,this._data=this.normalizeData(e),this._element=this.getTag()}get _CSS(){return{block:this.api.styles.block,wrapper:"ce-header"}}isHeaderData(e){return e.text!==void 0}normalizeData(e){const t={text:"",level:this.defaultLevel.number};return this.isHeaderData(e)&&(t.text=e.text||"",e.level!==void 0&&!isNaN(parseInt(e.level.toString()))&&(t.level=parseInt(e.level.toString()))),t}render(){return this._element}renderSettings(){return this.levels.map(e=>({icon:e.svg,label:this.api.i18n.t(`Heading ${e.number}`),onActivate:()=>this.setLevel(e.number),closeOnActivate:!0,isActive:this.currentLevel.number===e.number,render:()=>document.createElement("div")}))}setLevel(e){this.data={level:e,text:this.data.text}}merge(e){this._element.insertAdjacentHTML("beforeend",e.text)}validate(e){return e.text.trim()!==""}save(e){return{text:e.innerHTML,level:this.currentLevel.number}}static get conversionConfig(){return{export:"text",import:"text"}}static get sanitize(){return{level:!1,text:{}}}static get isReadOnlySupported(){return!0}get data(){return this._data.text=this._element.innerHTML,this._data.level=this.currentLevel.number,this._data}set data(e){if(this._data=this.normalizeData(e),e.level!==void 0&&this._element.parentNode){const t=this.getTag();t.innerHTML=this._element.innerHTML,this._element.parentNode.replaceChild(t,this._element),this._element=t}e.text!==void 0&&(this._element.innerHTML=this._data.text||"")}getTag(){var t;const e=document.createElement(this.currentLevel.tag);return e.innerHTML=this._data.text||"",e.classList.add(this._CSS.wrapper),e.contentEditable=this.readOnly?"false":"true",e.dataset.placeholder=this.api.i18n.t(((t=this._config)==null?void 0:t.placeholder)||""),e}get currentLevel(){let e=this.levels.find(t=>t.number===this._data.level);return e||(e=this.defaultLevel),e}get defaultLevel(){var e;if((e=this._config)!=null&&e.defaultLevel){const t=this.levels.find(i=>{var r;return i.number===((r=this._config)==null?void 0:r.defaultLevel)});if(t)return t;console.warn("(ง'̀-'́)ง Heading Tool: the default level specified was not found in available levels")}return this.levels[1]}get levels(){var t;const e=[{number:1,tag:"H1",svg:s},{number:2,tag:"H2",svg:d},{number:3,tag:"H3",svg:u},{number:4,tag:"H4",svg:c},{number:5,tag:"H5",svg:g},{number:6,tag:"H6",svg:v}];return(t=this._config)!=null&&t.levels?e.filter(i=>{var r;return(r=this._config)==null?void 0:r.levels.includes(i.number)}):e}onPaste(e){var i,r;const t=e.detail;if("data"in t){const l=t.data;let n=this.defaultLevel.number;switch(l.tagName){case"H1":n=1;break;case"H2":n=2;break;case"H3":n=3;break;case"H4":n=4;break;case"H5":n=5;break;case"H6":n=6;break}(i=this._config)!=null&&i.levels&&(n=(r=this._config)==null?void 0:r.levels.reduce((a,h)=>Math.abs(h-n)<Math.abs(a-n)?h:a)),this.data={level:n,text:l.innerHTML}}}static get pasteConfig(){return{tags:["H1","H2","H3","H4","H5","H6"]}}static get toolbox(){return{icon:w,title:"Heading"}}}return p});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/@editorjs/marker@1.4.0/dist/marker.umd.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
(function(){"use strict";try{if(typeof document<"u"){var e=document.createElement("style");e.appendChild(document.createTextNode(".cdx-marker{background:rgba(245,235,111,.29);padding:3px 0}")),document.head.appendChild(e)}}catch(d){console.error("vite-plugin-css-injected-by-js",d)}})();
|
||||
(function(i,s){typeof exports=="object"&&typeof module<"u"?module.exports=s():typeof define=="function"&&define.amd?define(s):(i=typeof globalThis<"u"?globalThis:i||self,i.Marker=s())})(this,function(){"use strict";const i="",s='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-width="2" d="M11.3536 9.31802L12.7678 7.90381C13.5488 7.12276 14.8151 7.12276 15.5962 7.90381C16.3772 8.68486 16.3772 9.95119 15.5962 10.7322L14.182 12.1464M11.3536 9.31802L7.96729 12.7043C7.40889 13.2627 7.02827 13.9739 6.8734 14.7482L6.69798 15.6253C6.55804 16.325 7.17496 16.942 7.87468 16.802L8.75176 16.6266C9.52612 16.4717 10.2373 16.0911 10.7957 15.5327L14.182 12.1464M11.3536 9.31802L14.182 12.1464"/><line x1="15" x2="19" y1="17" y2="17" stroke="currentColor" stroke-linecap="round" stroke-width="2"/></svg>';class n{static get CSS(){return"cdx-marker"}constructor({api:t}){this.api=t,this.button=null,this.tag="MARK",this.iconClasses={base:this.api.styles.inlineToolButton,active:this.api.styles.inlineToolButtonActive}}static get isInline(){return!0}render(){return this.button=document.createElement("button"),this.button.type="button",this.button.classList.add(this.iconClasses.base),this.button.innerHTML=this.toolboxIcon,this.button}surround(t){if(!t)return;let e=this.api.selection.findParentTag(this.tag,n.CSS);e?this.unwrap(e):this.wrap(t)}wrap(t){let e=document.createElement(this.tag);e.classList.add(n.CSS),e.appendChild(t.extractContents()),t.insertNode(e),this.api.selection.expandToTag(e)}unwrap(t){this.api.selection.expandToTag(t);let e=window.getSelection(),o=e.getRangeAt(0),a=o.extractContents();t.parentNode.removeChild(t),o.insertNode(a),e.removeAllRanges(),e.addRange(o)}checkState(){const t=this.api.selection.findParentTag(this.tag,n.CSS);this.button.classList.toggle(this.iconClasses.active,!!t)}get toolboxIcon(){return s}static get sanitize(){return{mark:{class:n.CSS}}}}return n});
|
||||
+8
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,527 @@
|
||||
(function () {
|
||||
var DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
function formatDays(daysValue) {
|
||||
var days = [];
|
||||
if (Array.isArray(daysValue)) {
|
||||
days = daysValue;
|
||||
} else if (daysValue) {
|
||||
try {
|
||||
var parsedDays = JSON.parse(daysValue);
|
||||
days = Array.isArray(parsedDays) ? parsedDays : [];
|
||||
} catch (_error) {
|
||||
days = [];
|
||||
}
|
||||
}
|
||||
return days
|
||||
.map(function (day) {
|
||||
return DAY_NAMES[Number(day)] || '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function initPlaylistScheduleModal() {
|
||||
var dialog = document.getElementById('slide-schedule-dialog');
|
||||
var frame = document.getElementById('slide-schedule-frame');
|
||||
var closeButton = document.getElementById('slide-schedule-close');
|
||||
var triggers = document.querySelectorAll('[data-schedule-config]');
|
||||
|
||||
if (!dialog || !frame || !closeButton || !triggers.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
function openScheduleModal(url) {
|
||||
frame.src = url;
|
||||
if (typeof dialog.showModal === 'function') {
|
||||
dialog.showModal();
|
||||
} else {
|
||||
dialog.setAttribute('open', 'open');
|
||||
}
|
||||
}
|
||||
|
||||
function resizeScheduleFrame() {
|
||||
try {
|
||||
if (!frame.contentWindow || !frame.contentWindow.document) {
|
||||
return;
|
||||
}
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
function closeScheduleModal() {
|
||||
frame.src = 'about:blank';
|
||||
if (typeof dialog.close === 'function') {
|
||||
dialog.close();
|
||||
} else {
|
||||
dialog.removeAttribute('open');
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
url += (url.indexOf('?') === -1 ? '?' : '&') + 'row_key=' + encodeURIComponent(rowKey);
|
||||
}
|
||||
openScheduleModal(url);
|
||||
});
|
||||
});
|
||||
|
||||
frame.addEventListener('load', resizeScheduleFrame);
|
||||
closeButton.addEventListener('click', closeScheduleModal);
|
||||
}
|
||||
|
||||
function scheduleSummary(values) {
|
||||
var mode = String(values.schedule_mode || 'always');
|
||||
if (mode === 'dates') {
|
||||
if (values.schedule_start_datetime && values.schedule_end_datetime) {
|
||||
return 'Dates: ' + values.schedule_start_datetime.replace('T', ' ') + ' to ' + values.schedule_end_datetime.replace('T', ' ');
|
||||
}
|
||||
return 'Dates: not set';
|
||||
}
|
||||
if (mode === 'times') {
|
||||
var days = formatDays(values.schedule_days_json);
|
||||
if (days && values.schedule_start_time && values.schedule_end_time) {
|
||||
return 'Times: ' + days + ' ' + values.schedule_start_time.slice(0, 5) + '-' + values.schedule_end_time.slice(0, 5);
|
||||
}
|
||||
return 'Times: not set';
|
||||
}
|
||||
return 'Always visible';
|
||||
}
|
||||
|
||||
function initPlaylistScheduleForm() {
|
||||
var form = document.querySelector('form[action*="/config"]');
|
||||
var select = document.getElementById('schedule-mode-select');
|
||||
if (!form || !select) {
|
||||
return;
|
||||
}
|
||||
|
||||
var datesPanel = document.getElementById('schedule-dates-panel');
|
||||
var timesPanel = document.getElementById('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"]');
|
||||
var endTimeInput = form.querySelector('[name="schedule_end_time"]');
|
||||
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 updateVisibility() {
|
||||
var mode = select.value;
|
||||
datesPanel.style.display = mode === 'dates' ? '' : 'none';
|
||||
timesPanel.style.display = mode === 'times' ? '' : 'none';
|
||||
window.requestAnimationFrame(function () {
|
||||
notifyParentResize();
|
||||
});
|
||||
}
|
||||
|
||||
select.addEventListener('change', updateVisibility);
|
||||
updateVisibility();
|
||||
|
||||
form.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
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) {
|
||||
if (checkbox.checked) {
|
||||
selectedDays.push(Number(checkbox.value));
|
||||
}
|
||||
});
|
||||
|
||||
var values = {
|
||||
row_key: String(rowKeyInput && rowKeyInput.value ? rowKeyInput.value : ''),
|
||||
schedule_mode: mode,
|
||||
schedule_start_datetime: startDateInput.value || '',
|
||||
schedule_end_datetime: endDateInput.value || '',
|
||||
schedule_start_time: startTimeInput.value || '',
|
||||
schedule_end_time: endTimeInput.value || '',
|
||||
schedule_days_json: JSON.stringify(selectedDays.sort()),
|
||||
summary: ''
|
||||
};
|
||||
values.summary = scheduleSummary(values);
|
||||
|
||||
if (window.parent && typeof window.parent.applyPlaylistScheduleConfig === 'function') {
|
||||
window.parent.applyPlaylistScheduleConfig(values);
|
||||
if (typeof window.parent.closeScheduleModal === 'function') {
|
||||
window.parent.closeScheduleModal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = new URLSearchParams(new FormData(form));
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||
},
|
||||
body: payload.toString(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return response.text().then(function (text) {
|
||||
throw new Error(text || 'Unable to save schedule.');
|
||||
});
|
||||
}
|
||||
if (window.parent && typeof window.parent.closeScheduleModal === 'function') {
|
||||
window.parent.closeScheduleModal();
|
||||
}
|
||||
}).catch(function (error) {
|
||||
alert(error.message || 'Unable to save schedule.');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initPlaylistEditStaging() {
|
||||
var tbody = document.getElementById('playlist-items-body');
|
||||
var form = document.getElementById('playlist-edit-form');
|
||||
var addSection = document.getElementById('playlist-add-section');
|
||||
var addSlideForm = document.getElementById('playlist-add-slide-form');
|
||||
var addSlideButton = document.getElementById('playlist-add-slide-button');
|
||||
var addSlideSelect = document.getElementById('playlist-add-slide-select');
|
||||
var addDurationInput = document.getElementById('playlist-add-duration');
|
||||
var addSlideEmpty = document.getElementById('playlist-add-slide-empty');
|
||||
|
||||
if (!tbody || !form) {
|
||||
return;
|
||||
}
|
||||
|
||||
function getRows() {
|
||||
return Array.prototype.slice.call(tbody.querySelectorAll('tr[data-playlist-slide-row]'));
|
||||
}
|
||||
|
||||
function findRowByKey(rowKey) {
|
||||
var rows = getRows();
|
||||
for (var i = 0; i < rows.length; i += 1) {
|
||||
if (rows[i].getAttribute('data-row-key') === rowKey) {
|
||||
return rows[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateEmptyState() {
|
||||
var rows = getRows();
|
||||
var placeholder = tbody.querySelector('.playlist-empty-row');
|
||||
if (rows.length) {
|
||||
if (placeholder) {
|
||||
placeholder.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!placeholder) {
|
||||
tbody.innerHTML = '<tr class="playlist-empty-row"><td colspan="5" class="empty">No slides assigned yet.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSummaryForRow(row) {
|
||||
var modeInput = row.querySelector('[name="schedule_mode[]"]');
|
||||
var startDatetime = row.querySelector('[name="schedule_start_datetime[]"]');
|
||||
var endDatetime = row.querySelector('[name="schedule_end_datetime[]"]');
|
||||
var startTime = row.querySelector('[name="schedule_start_time[]"]');
|
||||
var endTime = row.querySelector('[name="schedule_end_time[]"]');
|
||||
var daysJson = row.querySelector('[name="schedule_days_json[]"]');
|
||||
var mode = String(modeInput ? modeInput.value : 'always');
|
||||
var days = formatDays(daysJson && daysJson.value ? daysJson.value : '[]');
|
||||
|
||||
if (mode === 'dates') {
|
||||
if (startDatetime && endDatetime && startDatetime.value && endDatetime.value) {
|
||||
return 'Dates: ' + startDatetime.value.replace('T', ' ') + ' to ' + endDatetime.value.replace('T', ' ');
|
||||
}
|
||||
return 'Dates: not set';
|
||||
}
|
||||
if (mode === 'times') {
|
||||
if (days && startTime && endTime && startTime.value && endTime.value) {
|
||||
return 'Times: ' + days + ' ' + startTime.value.slice(0, 5) + '-' + endTime.value.slice(0, 5);
|
||||
}
|
||||
return 'Times: not set';
|
||||
}
|
||||
return 'Always visible';
|
||||
}
|
||||
|
||||
function syncAddSlideOptions() {
|
||||
if (!addSlideSelect) {
|
||||
return;
|
||||
}
|
||||
var activeSlideIds = {};
|
||||
var activeCanvasSignatures = {};
|
||||
getRows().forEach(function (row) {
|
||||
var slideId = String(row.getAttribute('data-slide-id') || '');
|
||||
var canvasSignature = String(row.getAttribute('data-canvas-signature') || '');
|
||||
if (slideId) {
|
||||
activeSlideIds[slideId] = true;
|
||||
}
|
||||
if (canvasSignature) {
|
||||
activeCanvasSignatures[canvasSignature] = true;
|
||||
}
|
||||
});
|
||||
|
||||
var allowedCanvasSignature = '';
|
||||
var canvasKeys = Object.keys(activeCanvasSignatures);
|
||||
if (canvasKeys.length === 1) {
|
||||
allowedCanvasSignature = canvasKeys[0];
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(addSlideSelect.options, function (option) {
|
||||
if (!option.value) {
|
||||
return;
|
||||
}
|
||||
var optionCanvasSignature = String(option.getAttribute('data-canvas-signature') || '');
|
||||
var isActive = Boolean(activeSlideIds[String(option.value)]);
|
||||
var isCanvasMismatch = Boolean(allowedCanvasSignature && optionCanvasSignature && optionCanvasSignature !== allowedCanvasSignature);
|
||||
option.disabled = isActive || isCanvasMismatch;
|
||||
option.hidden = isActive || isCanvasMismatch;
|
||||
});
|
||||
if (
|
||||
addSlideSelect.options[addSlideSelect.selectedIndex]
|
||||
&& (addSlideSelect.options[addSlideSelect.selectedIndex].disabled || addSlideSelect.options[addSlideSelect.selectedIndex].hidden)
|
||||
) {
|
||||
for (var i = 0; i < addSlideSelect.options.length; i += 1) {
|
||||
if (!addSlideSelect.options[i].disabled && !addSlideSelect.options[i].hidden) {
|
||||
addSlideSelect.selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var availableCount = 0;
|
||||
Array.prototype.forEach.call(addSlideSelect.options, function (option) {
|
||||
if (!option.value) {
|
||||
return;
|
||||
}
|
||||
if (!option.disabled && !option.hidden) {
|
||||
availableCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (addSlideButton) {
|
||||
addSlideButton.disabled = availableCount === 0;
|
||||
}
|
||||
if (addSlideSelect) {
|
||||
addSlideSelect.disabled = availableCount === 0;
|
||||
}
|
||||
if (addDurationInput) {
|
||||
addDurationInput.disabled = availableCount === 0;
|
||||
}
|
||||
if (addSlideEmpty) {
|
||||
addSlideEmpty.style.display = availableCount === 0 ? '' : 'none';
|
||||
}
|
||||
if (addSection && addSlideForm && addSlideSelect && addSlideButton) {
|
||||
var controlsVisible = availableCount > 0;
|
||||
addSlideForm.style.display = controlsVisible ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function updateRowOrder() {
|
||||
var rows = getRows();
|
||||
rows.forEach(function (row, index) {
|
||||
var orderCell = row.querySelector('.playlist-order-cell');
|
||||
var moveUp = row.querySelector('[data-playlist-move="up"]');
|
||||
var moveDown = row.querySelector('[data-playlist-move="down"]');
|
||||
if (orderCell) {
|
||||
orderCell.textContent = String(index + 1);
|
||||
}
|
||||
if (moveUp) {
|
||||
moveUp.disabled = index === 0;
|
||||
}
|
||||
if (moveDown) {
|
||||
moveDown.disabled = index === rows.length - 1;
|
||||
}
|
||||
});
|
||||
syncAddSlideOptions();
|
||||
updateEmptyState();
|
||||
}
|
||||
|
||||
function setRowSchedule(row, values) {
|
||||
var modeInput = row.querySelector('[name="schedule_mode[]"]');
|
||||
var startDatetime = row.querySelector('[name="schedule_start_datetime[]"]');
|
||||
var endDatetime = row.querySelector('[name="schedule_end_datetime[]"]');
|
||||
var startTime = row.querySelector('[name="schedule_start_time[]"]');
|
||||
var endTime = row.querySelector('[name="schedule_end_time[]"]');
|
||||
var daysJson = row.querySelector('[name="schedule_days_json[]"]');
|
||||
var summary = row.querySelector('.playlist-schedule-summary');
|
||||
|
||||
if (modeInput) {
|
||||
modeInput.value = values.schedule_mode || 'always';
|
||||
}
|
||||
if (startDatetime) {
|
||||
startDatetime.value = values.schedule_start_datetime || '';
|
||||
}
|
||||
if (endDatetime) {
|
||||
endDatetime.value = values.schedule_end_datetime || '';
|
||||
}
|
||||
if (startTime) {
|
||||
startTime.value = values.schedule_start_time || '';
|
||||
}
|
||||
if (endTime) {
|
||||
endTime.value = values.schedule_end_time || '';
|
||||
}
|
||||
if (daysJson) {
|
||||
daysJson.value = values.schedule_days_json || '[]';
|
||||
}
|
||||
if (summary) {
|
||||
summary.textContent = values.summary || scheduleSummaryForRow(row);
|
||||
}
|
||||
}
|
||||
|
||||
function createRow(values) {
|
||||
var row = document.createElement('tr');
|
||||
var rowKey = values.row_key || ('new-' + Date.now() + '-' + Math.random().toString(36).slice(2));
|
||||
row.setAttribute('data-playlist-slide-row', '');
|
||||
row.setAttribute('data-row-key', rowKey);
|
||||
row.setAttribute('data-slide-id', String(values.slide_id));
|
||||
row.setAttribute('data-canvas-signature', String(values.canvas_signature || ''));
|
||||
row.innerHTML = '' +
|
||||
'<td class="playlist-order-cell"></td>' +
|
||||
'<td>' + values.title + '<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" /></td>' +
|
||||
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required /></td>' +
|
||||
'<td>' +
|
||||
'<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 + '" />' +
|
||||
'</td>' +
|
||||
'<td><div class="actions playlist-item-actions">' +
|
||||
'<button type="button" class="secondary" data-playlist-move="up">Up</button>' +
|
||||
'<button type="button" class="secondary" data-playlist-move="down">Down</button>' +
|
||||
'<button type="button" class="schedule-config-button" disabled>Schedule</button>' +
|
||||
'<button type="button" class="danger" data-playlist-remove-row>Remove</button>' +
|
||||
'</div></td>';
|
||||
return row;
|
||||
}
|
||||
|
||||
tbody.addEventListener('click', function (event) {
|
||||
var moveButton = event.target.closest('[data-playlist-move]');
|
||||
var removeButton = event.target.closest('[data-playlist-remove-row]');
|
||||
var scheduleButton = event.target.closest('[data-schedule-config]');
|
||||
var row = event.target.closest('tr[data-playlist-slide-row]');
|
||||
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (moveButton) {
|
||||
event.preventDefault();
|
||||
var direction = moveButton.getAttribute('data-playlist-move');
|
||||
if (direction === 'up' && row.previousElementSibling && row.previousElementSibling.matches('[data-playlist-slide-row]')) {
|
||||
tbody.insertBefore(row, row.previousElementSibling);
|
||||
} else if (direction === 'down' && row.nextElementSibling && row.nextElementSibling.matches('[data-playlist-slide-row]')) {
|
||||
tbody.insertBefore(row.nextElementSibling, row);
|
||||
}
|
||||
updateRowOrder();
|
||||
return;
|
||||
}
|
||||
|
||||
if (removeButton) {
|
||||
event.preventDefault();
|
||||
row.remove();
|
||||
updateRowOrder();
|
||||
return;
|
||||
}
|
||||
|
||||
if (scheduleButton) {
|
||||
event.preventDefault();
|
||||
if (typeof window.openScheduleModal === 'function') {
|
||||
window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?row_key=' + encodeURIComponent(row.getAttribute('data-row-key') || ''));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (addSlideButton && addSlideSelect && addDurationInput) {
|
||||
addSlideButton.addEventListener('click', function () {
|
||||
var selectedOption = addSlideSelect.options[addSlideSelect.selectedIndex];
|
||||
if (!selectedOption || selectedOption.disabled) {
|
||||
return;
|
||||
}
|
||||
var placeholder = tbody.querySelector('.playlist-empty-row');
|
||||
if (placeholder) {
|
||||
placeholder.remove();
|
||||
}
|
||||
var row = createRow({
|
||||
row_key: 'new-' + Date.now(),
|
||||
slide_id: String(selectedOption.value),
|
||||
canvas_signature: String(selectedOption.getAttribute('data-canvas-signature') || ''),
|
||||
title: selectedOption.textContent || 'Slide',
|
||||
duration_seconds: Math.max(1, Number(addDurationInput.value || 10)),
|
||||
schedule_mode: 'always',
|
||||
schedule_start_datetime: '',
|
||||
schedule_end_datetime: '',
|
||||
schedule_start_time: '',
|
||||
schedule_end_time: '',
|
||||
schedule_days_json: '[]',
|
||||
summary: 'Always visible'
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
updateRowOrder();
|
||||
});
|
||||
}
|
||||
|
||||
window.applyPlaylistScheduleConfig = function (values) {
|
||||
var row = findRowByKey(String(values.row_key || ''));
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
setRowSchedule(row, values);
|
||||
};
|
||||
|
||||
updateRowOrder();
|
||||
}
|
||||
|
||||
initPlaylistScheduleModal();
|
||||
initPlaylistScheduleForm();
|
||||
initPlaylistEditStaging();
|
||||
}());
|
||||
@@ -0,0 +1,602 @@
|
||||
(function () {
|
||||
var dataElement = document.getElementById('slide-editor-data');
|
||||
if (!dataElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
var slideEditorData = {};
|
||||
try {
|
||||
slideEditorData = JSON.parse(dataElement.getAttribute('data-json') || dataElement.textContent || '{}') || {};
|
||||
} catch (_error) {
|
||||
slideEditorData = {};
|
||||
}
|
||||
|
||||
var templates = Array.isArray(slideEditorData.templates) ? slideEditorData.templates : [];
|
||||
var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null;
|
||||
var existingContent = slideEditorData.existingContent || {};
|
||||
var templateSelect = document.getElementById('template-select');
|
||||
var templateFields = document.getElementById('template-fields');
|
||||
var slideForm = document.getElementById('slide-form');
|
||||
var slidePreviewMeta = document.getElementById('slide-preview-meta');
|
||||
var slidePreviewStage = document.getElementById('slide-preview-stage');
|
||||
var slidePreviewBackground = document.getElementById('slide-preview-background');
|
||||
var slidePreviewEmpty = document.getElementById('slide-preview-empty');
|
||||
var slidePreviewOverlay = document.getElementById('slide-preview-overlay');
|
||||
var editorInstances = new Map();
|
||||
var submitting = false;
|
||||
|
||||
function dismissToast(toast) {
|
||||
if (!toast) {
|
||||
return;
|
||||
}
|
||||
toast.classList.add('toast-hide');
|
||||
window.setTimeout(function () {
|
||||
if (toast && toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 220);
|
||||
}
|
||||
|
||||
function showToast(message) {
|
||||
var text = String(message || '').trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
var existingToast = document.getElementById('app-toast');
|
||||
var existingBody = existingToast ? existingToast.querySelector('.toast-body') : null;
|
||||
if (existingToast && existingBody) {
|
||||
existingBody.textContent = text;
|
||||
existingToast.classList.remove('toast-hide');
|
||||
window.clearTimeout(existingToast._dismissTimer);
|
||||
existingToast._dismissTimer = window.setTimeout(function () {
|
||||
dismissToast(existingToast);
|
||||
}, 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
toast.innerHTML = '<div class="toast-body"></div><button type="button" class="toast-close" aria-label="Dismiss notification">×</button>';
|
||||
toast.querySelector('.toast-body').textContent = text;
|
||||
|
||||
var closeButton = toast.querySelector('.toast-close');
|
||||
closeButton.addEventListener('click', function () {
|
||||
dismissToast(toast);
|
||||
});
|
||||
|
||||
document.body.appendChild(toast);
|
||||
toast._dismissTimer = window.setTimeout(function () {
|
||||
dismissToast(toast);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function getTemplateById(id) {
|
||||
return templates.find(function (item) { return Number(item.id) === Number(id); }) || null;
|
||||
}
|
||||
|
||||
function getCurrentRegionValue(region) {
|
||||
var current = existingContent[region.region_key];
|
||||
return current && current.value !== undefined ? current.value : '';
|
||||
}
|
||||
|
||||
function normalizeEditorData(value) {
|
||||
var raw = String(value || '');
|
||||
if (!raw) {
|
||||
return { time: Date.now(), blocks: [] };
|
||||
}
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (parsed && Array.isArray(parsed.blocks)) {
|
||||
return parsed;
|
||||
}
|
||||
} catch (_error) {
|
||||
// fall through to legacy HTML/plain text
|
||||
}
|
||||
return {
|
||||
time: Date.now(),
|
||||
blocks: [{
|
||||
type: 'paragraph',
|
||||
data: { text: raw }
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizePreviewHtml(html) {
|
||||
var output = String(html || '');
|
||||
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
|
||||
return output.replace(/<[^>]+>/g, function (tag) {
|
||||
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
var closing = Boolean(match[1]);
|
||||
var name = String(match[2] || '').toLowerCase();
|
||||
var allowed = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code', 'table', 'thead', 'tbody', 'tr', 'th', 'td'];
|
||||
if (allowed.indexOf(name) === -1) {
|
||||
return '';
|
||||
}
|
||||
if (name === 'br') {
|
||||
return '<br>';
|
||||
}
|
||||
return closing ? '</' + name + '>' : '<' + name + '>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderPreviewListItem(item, tag) {
|
||||
if (item && typeof item === 'object') {
|
||||
var content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
|
||||
var children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
|
||||
var nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map(function (child) { return renderPreviewListItem(child, tag); }).join('') + '</' + tag + '>' : '';
|
||||
return '<li>' + sanitizePreviewHtml(content || '') + nested + '</li>';
|
||||
}
|
||||
return '<li>' + sanitizePreviewHtml(item || '') + '</li>';
|
||||
}
|
||||
|
||||
function renderPreviewTable(data) {
|
||||
var rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
|
||||
if (!rows.length) {
|
||||
return '';
|
||||
}
|
||||
var hasHeadings = Boolean(data.withHeadings);
|
||||
var body = rows.map(function (row, rowIndex) {
|
||||
var cells = Array.isArray(row) ? row : [];
|
||||
var cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
|
||||
var cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
|
||||
return '<tr>' + cells.map(function (cell) {
|
||||
return '<' + cellTag + cellAttrs + '>' + sanitizePreviewHtml(cell || '') + '</' + cellTag + '>';
|
||||
}).join('') + '</tr>';
|
||||
}).join('');
|
||||
return '<table class="slide-preview-table">' + body + '</table>';
|
||||
}
|
||||
|
||||
function renderPreviewBlock(block) {
|
||||
if (!block || !block.type || !block.data) {
|
||||
return '';
|
||||
}
|
||||
if (block.type === 'header') {
|
||||
var level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
|
||||
return '<h' + level + '>' + sanitizePreviewHtml(block.data.text || '') + '</h' + level + '>';
|
||||
}
|
||||
if (block.type === 'list') {
|
||||
var tag = block.data.style === 'ordered' ? 'ol' : 'ul';
|
||||
var 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(function (item) { return renderPreviewListItem(item, tag); }).join('') + '</' + tag + '>';
|
||||
}
|
||||
if (block.type === 'delimiter') {
|
||||
return '<hr />';
|
||||
}
|
||||
if (block.type === 'code') {
|
||||
return '<pre><code>' + sanitizePreviewHtml(block.data.code || '') + '</code></pre>';
|
||||
}
|
||||
if (block.type === 'table') {
|
||||
return renderPreviewTable(block.data);
|
||||
}
|
||||
if (block.type === 'paragraph') {
|
||||
return '<p>' + sanitizePreviewHtml(block.data.text || '') + '</p>';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderPreviewText(value) {
|
||||
var raw = String(value || '');
|
||||
if (!raw) {
|
||||
return '<div class="slide-preview-placeholder">Empty text</div>';
|
||||
}
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (parsed && Array.isArray(parsed.blocks)) {
|
||||
return parsed.blocks.map(renderPreviewBlock).join('');
|
||||
}
|
||||
} catch (_error) {
|
||||
// fall through to raw text
|
||||
}
|
||||
return '<p>' + sanitizePreviewHtml(raw) + '</p>';
|
||||
}
|
||||
|
||||
function renderPreviewTextRegion(region, value, style, scale) {
|
||||
var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
|
||||
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size) * scale)) + 'px;' : '';
|
||||
var color = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
|
||||
return '<div class="slide-preview-text-content" style="' + fontFamily + fontSize + color + '">' + renderPreviewText(value) + '</div>';
|
||||
}
|
||||
|
||||
function renderPreviewWebpageRegion(value) {
|
||||
var src = String(value || '').trim();
|
||||
if (!src) {
|
||||
return '<div class="slide-preview-placeholder">Webpage</div>';
|
||||
}
|
||||
return '<iframe class="slide-preview-webpage-frame" src="' + escapeHtml(src) + '" title="Webpage preview" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>';
|
||||
}
|
||||
|
||||
function renderPreviewHtmlRegion(value) {
|
||||
var html = String(value || '').trim();
|
||||
if (!html) {
|
||||
return '<div class="slide-preview-placeholder">HTML</div>';
|
||||
}
|
||||
return '<iframe class="slide-preview-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML preview" loading="eager"></iframe>';
|
||||
}
|
||||
|
||||
function destroyEditors() {
|
||||
editorInstances.forEach(function (editor) {
|
||||
if (editor && typeof editor.destroy === 'function') {
|
||||
editor.destroy();
|
||||
}
|
||||
});
|
||||
editorInstances.clear();
|
||||
}
|
||||
|
||||
function getEditorHiddenInput(regionId) {
|
||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
return card ? card.querySelector('input[type="hidden"][name="region_text_' + regionId + '"]') : null;
|
||||
}
|
||||
|
||||
function getCurrentTextStyle(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
return {
|
||||
font_family: String(current.font_family || region.font_family || 'Arial').trim() || 'Arial',
|
||||
font_size: Math.max(8, Number(current.font_size || 24)),
|
||||
font_color: String(current.font_color || region.font_color || '#000000').trim() || '#000000'
|
||||
};
|
||||
}
|
||||
|
||||
function getTextStyleFromCard(card, region) {
|
||||
var familyInput = card.querySelector('[name="region_font_family_' + region.id + '"]');
|
||||
var sizeInput = card.querySelector('[name="region_font_size_' + region.id + '"]');
|
||||
var colorInput = card.querySelector('[name="region_font_color_' + region.id + '"]');
|
||||
var current = getCurrentTextStyle(region);
|
||||
return {
|
||||
font_family: String(familyInput ? familyInput.value : current.font_family).trim() || current.font_family,
|
||||
font_size: Math.max(8, Number(sizeInput ? sizeInput.value : current.font_size)),
|
||||
font_color: String(colorInput ? colorInput.value : current.font_color).trim() || current.font_color
|
||||
};
|
||||
}
|
||||
|
||||
function getSelectedTemplate() {
|
||||
return templates.find(function (item) { return Number(item.id) === Number(templateSelect.value); }) || null;
|
||||
}
|
||||
|
||||
function clearImagePreviewUrls() {
|
||||
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
|
||||
if (input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getRegionBox(region, stageRect, canvasWidth, canvasHeight) {
|
||||
return {
|
||||
left: (Number(region.x || 0) / canvasWidth) * stageRect.width,
|
||||
top: (Number(region.y || 0) / canvasHeight) * stageRect.height,
|
||||
width: (Number(region.width || 0) / canvasWidth) * stageRect.width,
|
||||
height: (Number(region.height || 0) / canvasHeight) * stageRect.height
|
||||
};
|
||||
}
|
||||
|
||||
function renderPreview() {
|
||||
var template = getSelectedTemplate();
|
||||
if (!template) {
|
||||
slidePreviewMeta.textContent = 'Select a template to preview the slide.';
|
||||
slidePreviewBackground.style.display = 'none';
|
||||
slidePreviewEmpty.style.display = 'flex';
|
||||
slidePreviewEmpty.textContent = 'Select a template to preview the slide.';
|
||||
slidePreviewOverlay.innerHTML = '';
|
||||
slidePreviewStage.style.aspectRatio = '16 / 9';
|
||||
return;
|
||||
}
|
||||
|
||||
var canvasWidth = Math.max(1, Number(template.canvas_size_width || 1920));
|
||||
var canvasHeight = Math.max(1, Number(template.canvas_size_height || 1080));
|
||||
slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight;
|
||||
slidePreviewMeta.textContent = template.name + ' • ' + 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 || '';
|
||||
slidePreviewOverlay.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
slidePreviewEmpty.style.display = 'none';
|
||||
slidePreviewBackground.style.display = template.background_image_path ? 'block' : 'none';
|
||||
slidePreviewBackground.src = template.background_image_path || '';
|
||||
|
||||
var stageRect = slidePreviewStage.getBoundingClientRect();
|
||||
var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1;
|
||||
var regions = template.regions || [];
|
||||
slidePreviewOverlay.innerHTML = regions.map(function (region) {
|
||||
var box = getRegionBox(region, stageRect, canvasWidth, canvasHeight);
|
||||
var card = templateFields.querySelector('[data-region-id="' + region.id + '"]');
|
||||
var hiddenInput = card && card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]');
|
||||
var htmlInput = card && card.querySelector('textarea[name="region_html_' + region.id + '"]');
|
||||
var imageHidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + region.id + '"]');
|
||||
var imageFileInput = card && card.querySelector('input[type="file"][name="region_image_' + region.id + '"]');
|
||||
var webpageInput = card && card.querySelector('input[type="url"][name="region_webpage_' + region.id + '"]');
|
||||
var value = region.region_type === 'image'
|
||||
? ((imageFileInput && imageFileInput.dataset.previewUrl) ? imageFileInput.dataset.previewUrl : (imageHidden ? imageHidden.value : ''))
|
||||
: region.region_type === 'webpage'
|
||||
? (webpageInput ? webpageInput.value : '')
|
||||
: region.region_type === 'html'
|
||||
? (htmlInput ? htmlInput.value : '')
|
||||
: (hiddenInput ? hiddenInput.value : '');
|
||||
var style = region.region_type === 'image'
|
||||
? null
|
||||
: region.region_type === 'webpage'
|
||||
? null
|
||||
: region.region_type === 'html'
|
||||
? null
|
||||
: getTextStyleFromCard(card, region);
|
||||
var content = region.region_type === 'image'
|
||||
? (value ? '<img class="slide-preview-image" src="' + escapeHtml(value) + '" alt="" />' : '<div class="slide-preview-placeholder">Image</div>')
|
||||
: region.region_type === 'webpage'
|
||||
? renderPreviewWebpageRegion(value)
|
||||
: region.region_type === 'html'
|
||||
? renderPreviewHtmlRegion(value)
|
||||
: renderPreviewTextRegion(region, value, style, scale);
|
||||
var selected = region.region_type === 'image'
|
||||
? ' slide-preview-image-region'
|
||||
: region.region_type === 'webpage'
|
||||
? ' slide-preview-webpage-region'
|
||||
: region.region_type === 'html'
|
||||
? ' slide-preview-html-region'
|
||||
: ' 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('');
|
||||
}
|
||||
|
||||
function renderTextRegion(region) {
|
||||
var current = getCurrentRegionValue(region);
|
||||
var style = getCurrentTextStyle(region);
|
||||
var fontChoices = [
|
||||
'Arial',
|
||||
'Courier New',
|
||||
'Garamond',
|
||||
'Georgia',
|
||||
'Helvetica',
|
||||
'Impact',
|
||||
'Lucida Console',
|
||||
'Palatino',
|
||||
'Tahoma',
|
||||
'Times New Roman',
|
||||
'Trebuchet MS',
|
||||
'Verdana'
|
||||
];
|
||||
return '' +
|
||||
'<div class="card template-field-card" data-region-id="' + region.id + '">' +
|
||||
'<div class="template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">Text</span>' +
|
||||
'</div>' +
|
||||
'<div class="row template-text-style-row">' +
|
||||
'<label style="flex: 1;">Font style' +
|
||||
'<select name="region_font_family_' + region.id + '">' + fontChoices.map(function (font) {
|
||||
return '<option value="' + escapeHtml(font) + '"' + (style.font_family === font ? ' selected' : '') + '>' + escapeHtml(font) + '</option>';
|
||||
}).join('') + '</select>' +
|
||||
'</label>' +
|
||||
'<label style="width: 120px;">Size' +
|
||||
'<input type="number" name="region_font_size_' + region.id + '" min="8" max="180" step="1" value="' + escapeHtml(style.font_size) + '" />' +
|
||||
'</label>' +
|
||||
'<label style="width: 140px;">Color' +
|
||||
'<input type="color" name="region_font_color_' + region.id + '" value="' + escapeHtml(style.font_color) + '" />' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div class="editorjs-holder" id="editorjs-' + region.id + '" data-region-id="' + region.id + '"></div>' +
|
||||
'<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">' +
|
||||
'<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>';
|
||||
}
|
||||
|
||||
function renderHtmlRegion(region) {
|
||||
var current = getCurrentRegionValue(region);
|
||||
return '' +
|
||||
'<div class="card template-field-card" data-region-id="' + region.id + '">' +
|
||||
'<div class="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>';
|
||||
}
|
||||
|
||||
function renderImageRegion(region) {
|
||||
var current = getCurrentRegionValue(region);
|
||||
return '' +
|
||||
'<div class="card template-field-card" data-region-id="' + region.id + '">' +
|
||||
'<div class="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>';
|
||||
}
|
||||
|
||||
function renderTemplate() {
|
||||
clearImagePreviewUrls();
|
||||
destroyEditors();
|
||||
var template = getTemplateById(templateSelect.value);
|
||||
if (!template) {
|
||||
templateFields.innerHTML = '';
|
||||
renderPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
templateFields.innerHTML = template.regions.length ? template.regions.map(function (region) {
|
||||
if (region.region_type === 'image') {
|
||||
return renderImageRegion(region);
|
||||
}
|
||||
if (region.region_type === 'webpage') {
|
||||
return renderWebpageRegion(region);
|
||||
}
|
||||
if (region.region_type === 'html') {
|
||||
return renderHtmlRegion(region);
|
||||
}
|
||||
return renderTextRegion(region);
|
||||
}).join('') : '<div class="muted">This template has no regions.</div>';
|
||||
|
||||
templateFields.querySelectorAll('.editorjs-holder').forEach(function (holder) {
|
||||
var regionId = holder.getAttribute('data-region-id');
|
||||
var hidden = getEditorHiddenInput(regionId);
|
||||
var editor = new EditorJS({
|
||||
holder: holder.id,
|
||||
minHeight: 120,
|
||||
placeholder: 'Write content...',
|
||||
inlineToolbar: true,
|
||||
tools: {
|
||||
header: {
|
||||
class: Header,
|
||||
config: {
|
||||
placeholder: 'Enter a header',
|
||||
levels: [1, 2, 3, 4],
|
||||
defaultLevel: 3
|
||||
}
|
||||
},
|
||||
list: {
|
||||
class: EditorjsList,
|
||||
inlineToolbar: false,
|
||||
config: {
|
||||
defaultStyle: 'unordered'
|
||||
}
|
||||
},
|
||||
table: {
|
||||
class: Table,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
rows: 2,
|
||||
cols: 3,
|
||||
maxRows: 5,
|
||||
maxCols: 5
|
||||
}
|
||||
},
|
||||
marker: {
|
||||
class: Marker
|
||||
}
|
||||
},
|
||||
data: normalizeEditorData(hidden ? hidden.value : ''),
|
||||
onChange: function () {
|
||||
var instance = editorInstances.get(regionId);
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
instance.save().then(function (output) {
|
||||
if (hidden) {
|
||||
hidden.value = JSON.stringify(output);
|
||||
}
|
||||
renderPreview();
|
||||
});
|
||||
}
|
||||
});
|
||||
editorInstances.set(regionId, editor);
|
||||
});
|
||||
|
||||
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
|
||||
input.addEventListener('change', function () {
|
||||
if (input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
if (input.files && input.files.length) {
|
||||
input.dataset.previewUrl = URL.createObjectURL(input.files[0]);
|
||||
}
|
||||
renderPreview();
|
||||
});
|
||||
});
|
||||
renderPreview();
|
||||
}
|
||||
|
||||
slideForm.addEventListener('submit', async function (event) {
|
||||
if (submitting) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
var saves = Array.prototype.map.call(templateFields.querySelectorAll('.editorjs-holder'), function (holder) {
|
||||
var regionId = holder.getAttribute('data-region-id');
|
||||
var editor = editorInstances.get(regionId);
|
||||
var hidden = getEditorHiddenInput(regionId);
|
||||
if (!editor || !hidden) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return editor.save().then(function (output) {
|
||||
hidden.value = JSON.stringify(output);
|
||||
});
|
||||
});
|
||||
submitting = true;
|
||||
try {
|
||||
await Promise.all(saves);
|
||||
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),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text() || 'Unable to save slide.');
|
||||
}
|
||||
|
||||
var responseText = await response.text();
|
||||
var savedMessage = '';
|
||||
try {
|
||||
var doc = new DOMParser().parseFromString(responseText || '', 'text/html');
|
||||
var toastBody = doc.querySelector('.toast-body');
|
||||
if (toastBody && toastBody.textContent) {
|
||||
savedMessage = toastBody.textContent.trim();
|
||||
}
|
||||
} catch (_error) {
|
||||
savedMessage = '';
|
||||
}
|
||||
|
||||
showToast(savedMessage || 'Saved slide.');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
});
|
||||
|
||||
templateSelect.addEventListener('change', renderTemplate);
|
||||
templateFields.addEventListener('change', renderPreview);
|
||||
templateFields.addEventListener('input', renderPreview);
|
||||
if (existingTemplateId) {
|
||||
templateSelect.value = String(existingTemplateId);
|
||||
}
|
||||
renderTemplate();
|
||||
window.addEventListener('resize', renderPreview);
|
||||
})();
|
||||
@@ -0,0 +1,467 @@
|
||||
(function () {
|
||||
var dataElement = document.getElementById('template-editor-data');
|
||||
if (!dataElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 backgroundPreview = document.getElementById('background-preview');
|
||||
var backgroundEmpty = document.getElementById('background-empty');
|
||||
var addTextRegionButton = document.getElementById('add-text-region');
|
||||
var addImageRegionButton = document.getElementById('add-image-region');
|
||||
var regionsJsonInput = document.getElementById('regions-json');
|
||||
var draft = null;
|
||||
var selectedIndex = -1;
|
||||
|
||||
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 getCanvasSize() {
|
||||
return {
|
||||
width: Math.max(1, Number(canvasWidthInput.value || 1920)),
|
||||
height: Math.max(1, Number(canvasHeightInput.value || 1080))
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return 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 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; updateRegionFieldVisibility(card); }
|
||||
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 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)
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
backgroundPreview.src = reader.result;
|
||||
backgroundPreview.style.display = 'block';
|
||||
backgroundEmpty.style.display = 'none';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function updateRegionFieldVisibility(card) {
|
||||
var typeSelect = card.querySelector('[name="region_type[]"]');
|
||||
var fontField = card.querySelector('[name="font_family[]"]');
|
||||
if (!typeSelect || !fontField) {
|
||||
return;
|
||||
}
|
||||
fontField.value = typeSelect.value === 'text' || typeSelect.value === 'html' ? 'Arial' : '';
|
||||
}
|
||||
|
||||
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 nameInput = card.querySelector('[name="region_name[]"]');
|
||||
var typeSelect = card.querySelector('[name="region_type[]"]');
|
||||
nameInput.addEventListener('input', function () {
|
||||
syncRegionIdentity(card, nameInput.value);
|
||||
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;
|
||||
}
|
||||
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();
|
||||
});
|
||||
updateRegionFieldVisibility(card);
|
||||
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();
|
||||
}
|
||||
|
||||
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 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 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);
|
||||
renderOverlay();
|
||||
}
|
||||
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;
|
||||
renderOverlay();
|
||||
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 });
|
||||
renderOverlay();
|
||||
}
|
||||
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 });
|
||||
renderOverlay();
|
||||
}
|
||||
function upHandler() {
|
||||
document.removeEventListener('mousemove', moveHandler);
|
||||
document.removeEventListener('mouseup', upHandler);
|
||||
}
|
||||
document.addEventListener('mousemove', moveHandler);
|
||||
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')); });
|
||||
}
|
||||
backgroundInput.addEventListener('change', function () {
|
||||
var file = backgroundInput.files && backgroundInput.files[0];
|
||||
if (file) {
|
||||
updateBackgroundPreview(file);
|
||||
}
|
||||
});
|
||||
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);
|
||||
});
|
||||
document.getElementById('template-form').addEventListener('submit', function () {
|
||||
syncCanvasSizeSelection();
|
||||
regionsJsonInput.value = JSON.stringify(getCards().map(readCard));
|
||||
});
|
||||
|
||||
renderRegionList(existingRegions);
|
||||
syncCanvasSizeSelection();
|
||||
render();
|
||||
})();
|
||||
@@ -0,0 +1,12 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderAccountPage(currentUser, message) {
|
||||
return renderView('account/password', {
|
||||
title: 'My account',
|
||||
active: 'account',
|
||||
currentUser: currentUser || null,
|
||||
message: message || '',
|
||||
username: currentUser ? currentUser.username : '',
|
||||
name: currentUser ? String(currentUser.name || '') : ''
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderLoginPage(message) {
|
||||
return renderView('login', {
|
||||
title: 'Sign in',
|
||||
authShell: true,
|
||||
bodyClass: 'login-page-body',
|
||||
message: message || ''
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
function buildDefaultCanvasSize() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
width: 1920,
|
||||
height: 1080
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function renderCanvasSizeFormPage(canvasSize, mode, message, currentUser) {
|
||||
const isEdit = mode === 'edit';
|
||||
return renderView(isEdit ? 'canvas-sizes/edit' : 'canvas-sizes/add', {
|
||||
title: isEdit ? 'Edit canvas size' : 'Add canvas size',
|
||||
active: 'canvas-sizes',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
canvasSize: canvasSize || buildDefaultCanvasSize()
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
const renderCanvasSizeFormPage = require('./add');
|
||||
|
||||
module.exports = function renderCanvasSizeEditPage(canvasSize, _data, message, currentUser) {
|
||||
return renderCanvasSizeFormPage(canvasSize, 'edit', message, currentUser);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderCanvasSizesPage(data, message, currentUser) {
|
||||
const canvasSizes = (data.canvasSizes || []).map((size) => ({
|
||||
id: size.id,
|
||||
name: size.name,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
templateCount: (data.templates || []).filter((template) => Number(template.canvas_size_id) === Number(size.id)).length
|
||||
}));
|
||||
|
||||
return renderView('canvas-sizes/list', {
|
||||
title: 'Canvas sizes',
|
||||
active: 'canvas-sizes',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
canvasSizes: canvasSizes
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderConnectedClientsPage(data, message, currentUser) {
|
||||
return renderView('clients/list', {
|
||||
title: 'Connected clients',
|
||||
active: 'clients',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
clients: data.clients || []
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function screenPlayerUrl(slug) {
|
||||
return `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
function renderAdminShell(active, title, body, message) {
|
||||
const navItems = [
|
||||
{ key: 'dashboard', label: 'Dashboard', href: '/admin' },
|
||||
{ key: 'playlists', label: 'Playlists', href: '/admin/playlists' },
|
||||
{ key: 'screens', label: 'Screens', href: '/admin/screens' },
|
||||
{ key: 'slides', label: 'Slides', href: '/admin/slides' },
|
||||
{ key: 'templates', label: 'Slide templates', href: '/admin/templates' }
|
||||
].map((item) => `
|
||||
<a class="nav-item ${active === item.key ? 'active' : ''}" href="${item.href}">${escapeHtml(item.label)}</a>
|
||||
`).join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(title)} - Pulse Signage</title>
|
||||
<link rel="stylesheet" href="/assets/css/admin.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<h1>Pulse Signage</h1>
|
||||
<nav class="nav">${navItems}</nav>
|
||||
</aside>
|
||||
<main class="content">
|
||||
<div class="page-header">
|
||||
<h2>${escapeHtml(title)}</h2>
|
||||
</div>
|
||||
${body}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
escapeHtml,
|
||||
screenPlayerUrl,
|
||||
renderAdminShell
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderDashboardPage(data, message, currentUser) {
|
||||
return renderView('dashboard/index', {
|
||||
title: 'Dashboard',
|
||||
active: 'dashboard',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
playlists: data.playlists || [],
|
||||
screens: data.screens || [],
|
||||
clients: data.clients || [],
|
||||
slides: data.slides || [],
|
||||
connectedClientsCount: Number(data.connectedClientsCount || 0)
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
module.exports = {
|
||||
renderLoginPage: require('./auth/login'),
|
||||
renderAccountPage: require('./account/password'),
|
||||
renderUsersPage: require('./users/list'),
|
||||
renderUsersAddPage: require('./users/add'),
|
||||
renderUsersEditPage: require('./users/edit'),
|
||||
renderDashboardPage: require('./dashboard/index'),
|
||||
renderConnectedClientsPage: require('./clients/list'),
|
||||
renderPlaylistsPage: require('./playlists/list'),
|
||||
renderPlaylistFormPage: require('./playlists/add'),
|
||||
renderPlaylistEditPage: require('./playlists/edit'),
|
||||
renderPlaylistSlideConfigPage: require('./playlists/slide-config'),
|
||||
renderScreensPage: require('./screens/list'),
|
||||
renderScreenFormPage: require('./screens/add'),
|
||||
renderScreenEditPage: require('./screens/edit'),
|
||||
renderSlidesPage: require('./slides/list'),
|
||||
renderSlideFormPage: require('./slides/form'),
|
||||
renderTemplatesPage: require('./templates/list'),
|
||||
renderTemplateFormPage: require('./templates/add'),
|
||||
renderTemplateEditPage: require('./templates/edit'),
|
||||
renderCanvasSizesPage: require('./canvas-sizes/list'),
|
||||
renderCanvasSizeFormPage: require('./canvas-sizes/add'),
|
||||
renderCanvasSizeEditPage: require('./canvas-sizes/edit')
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderPlaylistFormPage(message, currentUser) {
|
||||
return renderView('playlists/add', {
|
||||
title: 'Create playlist',
|
||||
active: 'playlists',
|
||||
playlist: {
|
||||
fade_between_slides: false
|
||||
},
|
||||
message: message,
|
||||
currentUser: currentUser || null
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
return value ? String(value).slice(0, 5) : '';
|
||||
}
|
||||
|
||||
function formatDays(daysJson) {
|
||||
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
let days = [];
|
||||
if (Array.isArray(daysJson)) {
|
||||
days = daysJson;
|
||||
} else if (daysJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(daysJson);
|
||||
days = Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_error) {
|
||||
days = [];
|
||||
}
|
||||
}
|
||||
return days.map((day) => dayNames[Number(day)] || '').filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
function scheduleSummary(item) {
|
||||
const mode = String(item.schedule_mode || 'always');
|
||||
if (mode === 'dates') {
|
||||
const start = formatDateTime(item.schedule_start_datetime);
|
||||
const end = formatDateTime(item.schedule_end_datetime);
|
||||
return start && end ? `Dates: ${start} to ${end}` : 'Dates: not set';
|
||||
}
|
||||
if (mode === 'times') {
|
||||
const days = formatDays(item.schedule_days_json);
|
||||
const start = formatTime(item.schedule_start_time);
|
||||
const end = formatTime(item.schedule_end_time);
|
||||
return days && start && end ? `Times: ${days} ${start}-${end}` : 'Times: not set';
|
||||
}
|
||||
return 'Always visible';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
module.exports = function renderPlaylistEditPage(playlist, data, message, currentUser) {
|
||||
const playlistSlides = (data.playlistSlides || [])
|
||||
.filter((item) => item.playlist_id === playlist.id)
|
||||
.sort((a, b) => {
|
||||
if (a.position !== b.position) {
|
||||
return a.position - b.position;
|
||||
}
|
||||
return a.id - b.id;
|
||||
})
|
||||
.map((item, index, items) => Object.assign({}, item, {
|
||||
displayOrder: index + 1,
|
||||
isFirst: index === 0,
|
||||
isLast: index === items.length - 1,
|
||||
canvasSignature: getCanvasSignature(item.canvas_width, item.canvas_height) || '',
|
||||
scheduleSummary: scheduleSummary(item)
|
||||
}));
|
||||
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
|
||||
const playlistCanvasSignatures = Array.from(new Set(playlistSlides.map((item) => getCanvasSignature(item.canvas_width, item.canvas_height)).filter(Boolean)));
|
||||
const playlistCanvasSignature = playlistCanvasSignatures.length === 1 ? playlistCanvasSignatures[0] : null;
|
||||
const playlistCanvasMismatch = playlistCanvasSignatures.length > 1;
|
||||
const selectableSlides = (data.slides || []).filter((slide) => {
|
||||
if (playlistCanvasMismatch) {
|
||||
return false;
|
||||
}
|
||||
if (!playlistCanvasSignature) {
|
||||
return true;
|
||||
}
|
||||
return getCanvasSignature(slide.canvas_width, slide.canvas_height) === playlistCanvasSignature;
|
||||
}).map((slide) => Object.assign({}, slide, {
|
||||
canvasSignature: getCanvasSignature(slide.canvas_width, slide.canvas_height) || '',
|
||||
isAssigned: assignedSlideIds.has(slide.id)
|
||||
}));
|
||||
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned);
|
||||
|
||||
return renderView('playlists/edit', {
|
||||
title: 'Edit playlist',
|
||||
active: 'playlists',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
playlist: playlist,
|
||||
playlistFadeBetweenSlides: Boolean(playlist.fade_between_slides),
|
||||
playlistSlides: playlistSlides,
|
||||
slides: data.slides || [],
|
||||
hasSlides: (data.slides || []).length > 0,
|
||||
hasSelectableSlides: selectableSlides.length > 0,
|
||||
hasAddableSlides: hasAddableSlides,
|
||||
selectableSlides: selectableSlides,
|
||||
playlistCanvasSignature: playlistCanvasSignature,
|
||||
playlistCanvasMismatch: playlistCanvasMismatch,
|
||||
stylesheets: ['css/admin-editor.css'],
|
||||
scripts: ['js/playlist-schedule.js']
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderPlaylistsPage(data, message, currentUser) {
|
||||
const playlistSlides = data.playlistSlides || [];
|
||||
const playlists = (data.playlists || []).map((playlist) => {
|
||||
const slideCount = playlistSlides.filter((item) => item.playlist_id === playlist.id).length;
|
||||
return Object.assign({}, playlist, {
|
||||
slideCount: slideCount,
|
||||
slideCountIsOne: slideCount === 1
|
||||
});
|
||||
});
|
||||
|
||||
return renderView('playlists/list', {
|
||||
title: 'Playlists',
|
||||
active: 'playlists',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
playlists: playlists
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
const { renderFragment } = require('../../view');
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: 0, label: 'Sun' },
|
||||
{ value: 1, label: 'Mon' },
|
||||
{ value: 2, label: 'Tue' },
|
||||
{ value: 3, label: 'Wed' },
|
||||
{ value: 4, label: 'Thu' },
|
||||
{ value: 5, label: 'Fri' },
|
||||
{ value: 6, label: 'Sat' }
|
||||
];
|
||||
|
||||
function formatDateTimeInput(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function formatTimeInput(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
return String(value).slice(0, 5);
|
||||
}
|
||||
|
||||
module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide, message, rowKey, currentUser) {
|
||||
const scheduleMode = String(playlistSlide.schedule_mode || 'always');
|
||||
const selectedDays = new Set(Array.isArray(playlistSlide.schedule_days) ? playlistSlide.schedule_days.map(Number) : []);
|
||||
|
||||
return renderFragment('playlists/slide-config', {
|
||||
title: 'Slide schedule',
|
||||
hideFrameHeader: true,
|
||||
hideThemeToggle: true,
|
||||
frameBodyClass: 'admin-shell-body--frame-plain',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
playlist: playlist,
|
||||
playlistSlide: playlistSlide,
|
||||
scheduleMode: scheduleMode,
|
||||
isAlwaysMode: scheduleMode === 'always',
|
||||
isDatesMode: scheduleMode === 'dates',
|
||||
isTimesMode: scheduleMode === 'times',
|
||||
rowKey: String(rowKey || ''),
|
||||
startDateTimeValue: formatDateTimeInput(playlistSlide.schedule_start_datetime),
|
||||
endDateTimeValue: formatDateTimeInput(playlistSlide.schedule_end_datetime),
|
||||
startTimeValue: formatTimeInput(playlistSlide.schedule_start_time),
|
||||
endTimeValue: formatTimeInput(playlistSlide.schedule_end_time),
|
||||
dayOptions: DAY_OPTIONS.map((day) => ({
|
||||
...day,
|
||||
checked: selectedDays.has(day.value)
|
||||
})),
|
||||
stylesheets: ['css/admin-editor.css'],
|
||||
scripts: ['js/playlist-schedule.js']
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderScreenFormPage(data, message, currentUser) {
|
||||
return renderView('screens/add', {
|
||||
title: 'Add screen',
|
||||
active: 'screens',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
playlists: data.playlists || []
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderScreenEditPage(screen, data, message, currentUser) {
|
||||
return renderView('screens/edit', {
|
||||
title: 'Edit screen',
|
||||
active: 'screens',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
screen: screen,
|
||||
playlists: data.playlists || []
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderScreensPage(data, message, currentUser) {
|
||||
return renderView('screens/list', {
|
||||
title: 'Screens',
|
||||
active: 'screens',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
screens: data.screens || []
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderSlideFormPage(data, mode, slide, message, currentUser) {
|
||||
const templateRegions = data.templateRegions || [];
|
||||
const templates = (data.templates || []).map((template) => ({
|
||||
...template,
|
||||
canvas_size_width: template.canvas_size_width,
|
||||
canvas_size_height: template.canvas_size_height,
|
||||
regions: templateRegions.filter((region) => Number(region.template_id) === Number(template.id))
|
||||
}));
|
||||
return renderView('slides/form', {
|
||||
title: mode === 'edit' ? 'Edit slide' : 'Create slide',
|
||||
active: 'slides',
|
||||
message: message,
|
||||
isEdit: mode === 'edit',
|
||||
action: mode === 'edit' ? `/admin/slides/${slide.id}` : '/admin/slides',
|
||||
slide: slide || { title: '', template_id: null, content: {} },
|
||||
templates: templates,
|
||||
slideEditorData: {
|
||||
templates: templates,
|
||||
existingTemplateId: slide && slide.template_id ? slide.template_id : null,
|
||||
existingContent: slide && slide.content ? slide.content : {}
|
||||
},
|
||||
currentUser: currentUser || null,
|
||||
stylesheets: ['css/admin-editor.css']
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderSlidesPage(data, message, currentUser) {
|
||||
return renderView('slides/list', {
|
||||
title: 'Slides',
|
||||
active: 'slides',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
slides: data.slides || []
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
function buildDefaultTemplate() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
canvas_size_id: null,
|
||||
canvas_size_width: 1920,
|
||||
canvas_size_height: 1080,
|
||||
background_image_path: '',
|
||||
regions: [{
|
||||
region_key: 'region_1',
|
||||
label: 'Region 1',
|
||||
region_type: 'text',
|
||||
x: 120,
|
||||
y: 120,
|
||||
width: 420,
|
||||
height: 160,
|
||||
z_index: 1
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTemplateCanvasSize(template, canvasSizes) {
|
||||
const currentWidth = Math.max(1, Number(template.canvas_size_width || 1920));
|
||||
const currentHeight = Math.max(1, Number(template.canvas_size_height || 1080));
|
||||
const sizeId = template.canvas_size_id ? Number(template.canvas_size_id) : null;
|
||||
const matched = (canvasSizes || []).find((size) => Number(size.id) === sizeId)
|
||||
|| (canvasSizes || []).find((size) => Number(size.width) === currentWidth && Number(size.height) === currentHeight);
|
||||
|
||||
if (matched) {
|
||||
return Object.assign({}, template, {
|
||||
canvas_size_id: matched.id,
|
||||
canvas_size_label: matched.name,
|
||||
canvas_size_width: matched.width,
|
||||
canvas_size_height: matched.height
|
||||
});
|
||||
}
|
||||
|
||||
return Object.assign({}, template, {
|
||||
canvas_size_id: '',
|
||||
canvas_size_label: 'Current size (' + currentWidth + 'x' + currentHeight + ')',
|
||||
canvas_size_width: currentWidth,
|
||||
canvas_size_height: currentHeight
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = function renderTemplateFormPage(template, mode, message, canvasSizes, currentUser) {
|
||||
const isEdit = mode === 'edit';
|
||||
const current = resolveTemplateCanvasSize(template || buildDefaultTemplate(), canvasSizes || []);
|
||||
if (!current.regions || !current.regions.length) {
|
||||
current.regions = buildDefaultTemplate().regions;
|
||||
}
|
||||
|
||||
return renderView(isEdit ? 'templates/edit' : 'templates/add', {
|
||||
title: isEdit ? 'Edit template' : 'Create Template',
|
||||
active: 'templates',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
template: current,
|
||||
canvasSizes: canvasSizes || [],
|
||||
stylesheets: ['css/template-designer.css'],
|
||||
scripts: ['js/template-designer.js']
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
const renderTemplateFormPage = require('./add');
|
||||
|
||||
module.exports = function renderTemplateEditPage(template, data, message, currentUser) {
|
||||
return renderTemplateFormPage(template, 'edit', message, data && data.canvasSizes ? data.canvasSizes : [], currentUser);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderTemplatesPage(data, message, currentUser) {
|
||||
const templates = (data.templates || []).map((template) => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
canvas_size_width: template.canvas_size_width,
|
||||
canvas_size_height: template.canvas_size_height,
|
||||
regionCount: (data.templateRegions || []).filter((region) => region.template_id === template.id).length
|
||||
}));
|
||||
|
||||
return renderView('templates/list', {
|
||||
title: 'Slide templates',
|
||||
active: 'templates',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
templates: templates
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderUsersAddPage(message, currentUser) {
|
||||
return renderView('users/add', {
|
||||
title: 'Add user',
|
||||
active: 'users',
|
||||
message: message,
|
||||
currentUser: currentUser || null
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderUsersEditPage(user, message, currentUser) {
|
||||
return renderView('users/edit', {
|
||||
title: 'Edit user',
|
||||
active: 'users',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
user: user
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderUsersPage(data, message, currentUser) {
|
||||
return renderView('users/list', {
|
||||
title: 'Users',
|
||||
active: 'users',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
users: data.users || []
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Handlebars = require('handlebars');
|
||||
|
||||
const VIEWS_ROOT = path.join(__dirname, 'views');
|
||||
const cache = new Map();
|
||||
|
||||
Handlebars.registerHelper('eq', function (left, right) {
|
||||
return left === right;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('playerUrl', function (slug) {
|
||||
const base = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
return `${base}/screen/${encodeURIComponent(slug)}`;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('json', function (value) {
|
||||
return new Handlebars.SafeString(JSON.stringify(value).replace(/</g, '\\u003c'));
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('usernameInitial', function (username) {
|
||||
const value = String(username || '').trim();
|
||||
if (!value) {
|
||||
return 'A';
|
||||
}
|
||||
return value.charAt(0).toUpperCase();
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('userInitial', function (name, username) {
|
||||
const value = String(name || '').trim() || String(username || '').trim();
|
||||
if (!value) {
|
||||
return 'A';
|
||||
}
|
||||
return value.charAt(0).toUpperCase();
|
||||
});
|
||||
|
||||
function loadTemplate(relativePath) {
|
||||
const filePath = path.join(VIEWS_ROOT, relativePath);
|
||||
const stat = fs.statSync(filePath);
|
||||
const cached = cache.get(filePath);
|
||||
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
||||
return cached.template;
|
||||
}
|
||||
const template = Handlebars.compile(fs.readFileSync(filePath, 'utf8'));
|
||||
cache.set(filePath, { mtimeMs: stat.mtimeMs, template: template });
|
||||
return template;
|
||||
}
|
||||
|
||||
function renderView(viewName, context) {
|
||||
const viewContext = Object.assign({ stylesheets: [], scripts: [] }, context || {});
|
||||
const page = loadTemplate(`${viewName}.hbs`);
|
||||
const layout = loadTemplate(path.join('layout.hbs'));
|
||||
const body = page(viewContext);
|
||||
return layout(Object.assign({}, viewContext, { body: body }));
|
||||
}
|
||||
|
||||
function renderFragment(viewName, context) {
|
||||
const viewContext = Object.assign({ stylesheets: [], scripts: [] }, context || {});
|
||||
const page = loadTemplate(`${viewName}.hbs`);
|
||||
const layout = loadTemplate(path.join('frame-layout.hbs'));
|
||||
const body = page(viewContext);
|
||||
return layout(Object.assign({}, viewContext, { body: body }));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderView,
|
||||
renderFragment
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>My account</h2>
|
||||
<p>Update your display name and password.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card account-card">
|
||||
<form method="post" action="/admin/account/name" class="account-form account-form--name">
|
||||
<label>
|
||||
Display name
|
||||
<input type="text" name="name" value="{{name}}" autocomplete="name" required />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Save name</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card account-card">
|
||||
<form method="post" action="/admin/account/password" class="account-form">
|
||||
<label>
|
||||
Current password
|
||||
<input type="password" name="current_password" autocomplete="current-password" required />
|
||||
</label>
|
||||
<label>
|
||||
New password
|
||||
<input type="password" name="new_password" autocomplete="new-password" minlength="8" required />
|
||||
</label>
|
||||
<label>
|
||||
Confirm new password
|
||||
<input type="password" name="confirm_password" autocomplete="new-password" minlength="8" required />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Update password</button>
|
||||
<a class="button-link secondary" href="/admin">Back to dashboard</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Create canvas size</h2>
|
||||
<p>Add a reusable canvas dimension for templates.</p>
|
||||
</div>
|
||||
<a class="button-link" href="/admin/canvas-sizes">Back to sizes</a>
|
||||
</div>
|
||||
|
||||
<div class="card canvas-size-card">
|
||||
<form method="post" action="/admin/canvas-sizes" class="canvas-size-form">
|
||||
<label>Name
|
||||
<input name="name" value="{{canvasSize.name}}" required />
|
||||
</label>
|
||||
<div class="row">
|
||||
<label style="flex:1">Width
|
||||
<input name="width" type="number" min="1" value="{{canvasSize.width}}" required />
|
||||
</label>
|
||||
<label style="flex:1">Height
|
||||
<input name="height" type="number" min="1" value="{{canvasSize.height}}" required />
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Create size</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Edit canvas size</h2>
|
||||
<p>Update the stored canvas dimensions.</p>
|
||||
</div>
|
||||
<a class="button-link" href="/admin/canvas-sizes">Back to sizes</a>
|
||||
</div>
|
||||
|
||||
<div class="card canvas-size-card">
|
||||
<form method="post" action="/admin/canvas-sizes/{{canvasSize.id}}" class="canvas-size-form">
|
||||
<label>Name
|
||||
<input name="name" value="{{canvasSize.name}}" required />
|
||||
</label>
|
||||
<div class="row">
|
||||
<label style="flex:1">Width
|
||||
<input name="width" type="number" min="1" value="{{canvasSize.width}}" required />
|
||||
</label>
|
||||
<label style="flex:1">Height
|
||||
<input name="height" type="number" min="1" value="{{canvasSize.height}}" required />
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Save changes</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Canvas sizes</h2>
|
||||
<p>Keep template dimensions standardized across the system.</p>
|
||||
</div>
|
||||
<a class="button-link" href="/admin/canvas-sizes/new">Add canvas size</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Saved sizes</h3>
|
||||
<table class="table-cards">
|
||||
<thead><tr><th>Name</th><th>Dimensions</th><th>Templates</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{#if canvasSizes.length}}
|
||||
{{#each canvasSizes}}
|
||||
<tr>
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="Dimensions">{{width}}x{{height}}</td>
|
||||
<td data-label="Templates">{{templateCount}}</td>
|
||||
<td data-label="Actions">
|
||||
<div class="actions">
|
||||
<a class="button-link list-action" href="/admin/canvas-sizes/{{id}}/edit">Edit</a>
|
||||
<form class="inline-form" method="post" action="/admin/canvas-sizes/{{id}}/delete" data-confirm-message="Delete this canvas size?">
|
||||
<button class="danger list-action" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="4" class="empty">No canvas sizes yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,98 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Connected clients</h2>
|
||||
<p>Inspect live player connections and send commands to individual clients.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Active connections</h3>
|
||||
<table id="dashboard-clients-table" class="table-cards">
|
||||
<thead><tr><th>Client/Connection ID</th><th>IP</th><th>Viewport</th><th>Connected</th><th>Screen</th><th>Actions</th></tr></thead>
|
||||
<tbody id="dashboard-clients-table-body">
|
||||
{{#if clients.length}}
|
||||
{{#each clients}}
|
||||
<tr>
|
||||
<td data-label="Client/Connection ID">
|
||||
{{#if clientId}}
|
||||
<div class="connection-count">{{clientId}}</div>
|
||||
{{else}}
|
||||
<span class="empty">Unknown</span>
|
||||
{{/if}}
|
||||
{{#if id}}
|
||||
<div class="connection-id">{{id}}</div>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="IP">
|
||||
{{#if clientIp}}
|
||||
{{clientIp}}
|
||||
{{else}}
|
||||
{{#if remoteAddress}}
|
||||
{{remoteAddress}}
|
||||
{{else}}
|
||||
<span class="empty">Unknown</span>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Viewport">
|
||||
{{#if viewport}}
|
||||
{{viewport.width}}x{{viewport.height}}
|
||||
{{else}}
|
||||
<span class="empty">Unknown</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Connected">
|
||||
{{#if connectedAt}}
|
||||
<div>{{connectedAtLabel}}</div>
|
||||
{{#if lastSeenAt}}
|
||||
<div class="subtle">{{lastSeenAtLabel}}</div>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<span class="empty">Unknown</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Screen">
|
||||
<div>{{screen_name}}</div>
|
||||
{{#if currentSlideTitle}}
|
||||
<div class="subtle">Showing: {{currentSlideTitle}}</div>
|
||||
{{else}}
|
||||
<div class="subtle">No slide currently showing</div>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
<div class="actions">
|
||||
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-confirm-message="Reloading will restart the player page. Continue?" data-async-command>
|
||||
<input type="hidden" name="command" value="reload" />
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
<button type="submit" class="button-link list-action is-danger">Reload</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
||||
<input type="hidden" name="command" value="previous" />
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
<button type="submit" class="button-link list-action list-action--nav" aria-label="Previous slide">◀</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
||||
<input type="hidden" name="command" value="next" />
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
<button type="submit" class="button-link list-action list-action--nav" aria-label="Next slide">▶</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
||||
<input type="hidden" name="command" value="pause" />
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
<button type="submit" class="button-link list-action{{#if paused}} is-paused{{/if}}">{{#if paused}}Resume{{else}}Pause{{/if}}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
||||
<input type="hidden" name="command" value="blackout" />
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
<button type="submit" class="button-link list-action{{#if blackout}} is-blackout{{/if}}">{{#if blackout}}Restore{{else}}Blackout{{/if}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="6" class="empty">No connected clients yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,55 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Dashboard</h2>
|
||||
<p>Monitor screens and quick actions from one place.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<section class="stat"><strong id="dashboard-playlist-count">{{playlists.length}}</strong><span>Playlists</span></section>
|
||||
<section class="stat"><strong id="dashboard-slide-count">{{slides.length}}</strong><span>Slides</span></section>
|
||||
<section class="stat"><strong id="dashboard-screen-count">{{screens.length}}</strong><span>Screens</span></section>
|
||||
<section class="stat"><strong id="dashboard-client-count">{{connectedClientsCount}}</strong><span>Connected clients</span></section>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Quick actions</h3>
|
||||
<div class="actions">
|
||||
<form method="post" action="/admin/commands" class="inline-form" data-confirm-message="Reload all connected clients?" data-async-command>
|
||||
<input type="hidden" name="command" value="reload" />
|
||||
<button type="submit" class="button-link list-action is-danger" id="dashboard-reload-all-button">Reload all clients</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/commands" class="inline-form" data-confirm-message="Blackout all connected clients?" data-async-command>
|
||||
<input type="hidden" name="command" value="blackout" />
|
||||
<input type="hidden" name="blackout" value="1" />
|
||||
<button type="submit" class="button-link list-action" id="dashboard-blackout-all-button">Blackout all clients</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Current screens</h3>
|
||||
<table id="dashboard-screens-table" class="table-cards">
|
||||
<thead><tr><th>Name</th><th>Player URL</th><th>Playlist</th><th>Connected clients</th></tr></thead>
|
||||
<tbody>
|
||||
{{#if screens.length}}
|
||||
{{#each screens}}
|
||||
<tr data-client-key="{{id}}">
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="Player URL"><a href="{{playerUrl slug}}" target="_blank">{{playerUrl slug}}</a></td>
|
||||
<td data-label="Playlist">{{playlist_name}}</td>
|
||||
<td data-label="Connected clients">
|
||||
{{#if player_connection_count}}
|
||||
<div class="connection-count">{{player_connection_count}} connected</div>
|
||||
{{else}}
|
||||
<span class="empty">No clients connected.</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="4" class="empty">No screens yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,65 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{title}} - Signage</title>
|
||||
<script>
|
||||
(function () {
|
||||
var storageKey = 'webui-theme';
|
||||
var theme = 'light';
|
||||
try {
|
||||
var storedTheme = window.localStorage.getItem(storageKey);
|
||||
if (storedTheme === 'dark' || storedTheme === 'light') {
|
||||
theme = storedTheme;
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
theme = 'dark';
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
theme = 'dark';
|
||||
}
|
||||
}
|
||||
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}());
|
||||
</script>
|
||||
<link rel="stylesheet" href="/assets/css/admin.css" />
|
||||
{{#if stylesheets.length}}
|
||||
{{#each stylesheets}}
|
||||
<link rel="stylesheet" href="/assets/{{this}}" />
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</head>
|
||||
<body class="admin-shell-body admin-shell-body--frame {{frameBodyClass}}">
|
||||
<div class="app-shell app-shell--frame">
|
||||
<div class="shell-main shell-main--frame">
|
||||
{{#unless hideThemeToggle}}
|
||||
<button type="button" class="theme-toggle theme-toggle--floating" data-theme-toggle aria-pressed="false" aria-label="Switch to dark mode">
|
||||
<span class="theme-toggle__icon theme-toggle__icon--moon" aria-hidden="true"></span>
|
||||
</button>
|
||||
{{/unless}}
|
||||
{{#unless hideFrameHeader}}
|
||||
<header class="topbar topbar--frame">
|
||||
<div class="topbar-copy">
|
||||
<span class="eyebrow">Slide workspace</span>
|
||||
<h1>{{title}}</h1>
|
||||
</div>
|
||||
</header>
|
||||
{{/unless}}
|
||||
<main class="content content--frame">
|
||||
<div class="page-shell page-shell--frame">
|
||||
{{{body}}}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/js/admin-page.js"></script>
|
||||
{{#if scripts.length}}
|
||||
{{#each scripts}}
|
||||
<script src="/assets/{{this}}"></script>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,127 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>{{title}} - Signage</title>
|
||||
<script>
|
||||
(function () {
|
||||
var storageKey = 'webui-theme';
|
||||
var theme = 'light';
|
||||
try {
|
||||
var storedTheme = window.localStorage.getItem(storageKey);
|
||||
if (storedTheme === 'dark' || storedTheme === 'light') {
|
||||
theme = storedTheme;
|
||||
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
theme = 'dark';
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
theme = 'dark';
|
||||
}
|
||||
}
|
||||
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}());
|
||||
</script>
|
||||
<link rel="stylesheet" href="/assets/css/admin.css" />
|
||||
{{#if stylesheets.length}}
|
||||
{{#each stylesheets}}
|
||||
<link rel="stylesheet" href="/assets/{{this}}" />
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</head>
|
||||
<body class="admin-shell-body {{#if authShell}}auth-shell-body{{/if}} {{bodyClass}}">
|
||||
{{#if authShell}}
|
||||
<button type="button" class="theme-toggle theme-toggle--floating" data-theme-toggle aria-pressed="false" aria-label="Switch to dark mode">
|
||||
<span class="theme-toggle__icon theme-toggle__icon--moon" aria-hidden="true"></span>
|
||||
</button>
|
||||
<main class="auth-page">
|
||||
{{{body}}}
|
||||
</main>
|
||||
<script src="/assets/js/admin-page.js"></script>
|
||||
{{else}}
|
||||
{{#if message}}
|
||||
<div class="toast" id="app-toast" role="status" aria-live="polite">
|
||||
<div class="toast-body">{{message}}</div>
|
||||
<button type="button" class="toast-close" id="app-toast-close" aria-label="Dismiss notification">×</button>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" aria-label="Primary navigation">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">PS</div>
|
||||
<div class="brand-copy">
|
||||
<strong>Pulse Signage</strong>
|
||||
<span>Admin Panel</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-footer">
|
||||
<span class="status-dot status-dot--unknown" id="sidebar-status-dot"></span>
|
||||
<div>
|
||||
<strong>System status</strong>
|
||||
<span id="sidebar-status-text">Checking player service</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<span class="nav-label">Manage</span>
|
||||
<a class="nav-item {{#if (eq active 'dashboard')}}active{{/if}}" href="/admin">Dashboard</a>
|
||||
<a class="nav-item {{#if (eq active 'clients')}}active{{/if}}" href="/admin/clients">Connected clients</a>
|
||||
<a class="nav-item {{#if (eq active 'screens')}}active{{/if}}" href="/admin/screens">Screens</a>
|
||||
<a class="nav-item {{#if (eq active 'playlists')}}active{{/if}}" href="/admin/playlists">Playlists</a>
|
||||
<a class="nav-item {{#if (eq active 'slides')}}active{{/if}}" href="/admin/slides">Slides</a>
|
||||
<a class="nav-item {{#if (eq active 'templates')}}active{{/if}}" href="/admin/templates">Slide templates</a>
|
||||
<a class="nav-item {{#if (eq active 'canvas-sizes')}}active{{/if}}" href="/admin/canvas-sizes">Canvas sizes</a>
|
||||
</nav>
|
||||
<div class="sidebar-divider" aria-hidden="true"></div>
|
||||
<nav class="nav nav--sidebar-bottom" aria-label="Secondary navigation">
|
||||
<a class="nav-item {{#if (eq active 'users')}}active{{/if}}" href="/admin/users">Users</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer sidebar-footer--account">
|
||||
{{#if currentUser}}
|
||||
<div class="user-avatar" aria-hidden="true">{{userInitial currentUser.name currentUser.username}}</div>
|
||||
{{else}}
|
||||
<div class="user-avatar user-avatar--fallback" aria-hidden="true">A</div>
|
||||
{{/if}}
|
||||
<div>
|
||||
<strong class="sidebar-account-name">{{#if currentUser}}{{#if currentUser.name}}{{currentUser.name}}{{else}}{{currentUser.username}}{{/if}}{{else}}Account{{/if}}</strong>
|
||||
<span>{{#if currentUser}}{{currentUser.username}}{{else}}Not signed in{{/if}}</span>
|
||||
<div class="sidebar-footer-links">
|
||||
{{#if currentUser}}
|
||||
<a href="/admin/account">Account</a>
|
||||
<a href="/logout">Sign out</a>
|
||||
{{else}}
|
||||
<a href="/login">Sign in</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="theme-toggle theme-toggle--sidebar" data-theme-toggle aria-pressed="false" aria-label="Switch to dark mode">
|
||||
<span class="theme-toggle__icon theme-toggle__icon--moon" aria-hidden="true"></span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<button type="button" class="sidebar-backdrop" data-sidebar-backdrop aria-label="Close navigation"></button>
|
||||
|
||||
<div class="shell-main">
|
||||
<button type="button" class="sidebar-toggle sidebar-toggle--shell" data-sidebar-toggle aria-label="Toggle navigation" aria-expanded="false">
|
||||
<span class="sidebar-toggle-icon" aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
<main class="content">
|
||||
<div class="page-shell">
|
||||
{{{body}}}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/js/admin-page.js"></script>
|
||||
{{/if}}
|
||||
{{#if scripts.length}}
|
||||
{{#each scripts}}
|
||||
<script src="/assets/{{this}}"></script>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
<div class="auth-shell">
|
||||
<div class="auth-card card">
|
||||
<div class="auth-brand">
|
||||
<div class="brand-mark">PS</div>
|
||||
<div>
|
||||
<h1>Sign in</h1>
|
||||
<p>Sign in with your account.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if message}}
|
||||
<div class="auth-message">{{message}}</div>
|
||||
{{/if}}
|
||||
|
||||
<form method="post" action="/login" class="auth-form">
|
||||
<label>
|
||||
Username
|
||||
<input type="text" name="username" autocomplete="username" required autofocus />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="password" autocomplete="current-password" required />
|
||||
</label>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Create playlist</h2>
|
||||
<p>Name the playlist and start adding slides.</p>
|
||||
</div>
|
||||
<a class="button-link" href="/admin/playlists">Back to playlists</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post" action="/admin/playlists" data-async-save>
|
||||
<label>Name
|
||||
<input name="name" placeholder="Lobby loop" required />
|
||||
</label>
|
||||
<div class="switch-field">
|
||||
<span class="switch-label">Fade:</span>
|
||||
<label class="switch-control">
|
||||
<input type="checkbox" name="fade_between_slides" value="1" />
|
||||
<span class="switch-ui" aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="muted">Disabled by default.</div>
|
||||
<button type="submit">Create playlist</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,119 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Edit playlist</h2>
|
||||
<p>Adjust the playlist details, slide order, and scheduling.</p>
|
||||
</div>
|
||||
<a class="button-link" href="/admin/playlists">Back to playlists</a>
|
||||
</div>
|
||||
|
||||
<form id="playlist-edit-form" method="post" action="/admin/playlists/{{playlist.id}}" data-async-save>
|
||||
<div class="card playlist-name-card">
|
||||
<div class="row playlist-edit-header">
|
||||
<label class="playlist-name-field">Name
|
||||
<input name="name" value="{{playlist.name}}" required />
|
||||
</label>
|
||||
<div class="row playlist-edit-actions">
|
||||
<div class="playlist-fade-field switch-field">
|
||||
<span class="switch-label">Fade:</span>
|
||||
<label class="switch-control">
|
||||
<input type="checkbox" name="fade_between_slides" value="1" {{#if playlistFadeBetweenSlides}}checked{{/if}} />
|
||||
<span class="switch-ui" aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" form="playlist-edit-form">Save changes</button>
|
||||
<button type="submit" class="danger" form="delete-playlist-form">Delete playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Playlist items</h3>
|
||||
<table class="table-cards">
|
||||
<thead><tr><th>Order</th><th>Slide</th><th>Duration</th><th>Schedule</th><th>Actions</th></tr></thead>
|
||||
<tbody id="playlist-items-body">
|
||||
{{#if playlistSlides.length}}
|
||||
{{#each playlistSlides}}
|
||||
<tr data-playlist-slide-row data-row-key="existing-{{id}}" data-slide-id="{{slide_id}}" data-canvas-signature="{{canvasSignature}}">
|
||||
<td class="playlist-order-cell" data-label="Order">{{displayOrder}}</td>
|
||||
<td data-label="Slide">
|
||||
{{title}}
|
||||
<input type="hidden" name="slide_id[]" value="{{slide_id}}" />
|
||||
</td>
|
||||
<td data-label="Duration">
|
||||
<input name="duration_seconds[]" type="number" min="1" value="{{duration_seconds}}" required />
|
||||
</td>
|
||||
<td data-label="Schedule">
|
||||
<div class="playlist-schedule-summary">{{scheduleSummary}}</div>
|
||||
<input type="hidden" name="schedule_mode[]" value="{{schedule_mode}}" />
|
||||
<input type="hidden" name="schedule_start_datetime[]" value="{{schedule_start_datetime}}" />
|
||||
<input type="hidden" name="schedule_end_datetime[]" value="{{schedule_end_datetime}}" />
|
||||
<input type="hidden" name="schedule_start_time[]" value="{{schedule_start_time}}" />
|
||||
<input type="hidden" name="schedule_end_time[]" value="{{schedule_end_time}}" />
|
||||
<input type="hidden" name="schedule_days_json[]" value="{{schedule_days_json}}" />
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
<div class="actions playlist-item-actions">
|
||||
<button type="button" class="secondary" data-playlist-move="up" {{#if isFirst}}disabled{{/if}}>Up</button>
|
||||
<button type="button" class="secondary" data-playlist-move="down" {{#if isLast}}disabled{{/if}}>Down</button>
|
||||
<button type="button" class="schedule-config-button" data-schedule-config="/admin/playlists/{{../playlist.id}}/slides/{{id}}/config" data-schedule-config-row="existing-{{id}}">Schedule</button>
|
||||
<button type="button" class="danger" data-playlist-remove-row>Remove</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr class="playlist-empty-row"><td colspan="5" class="empty">No slides assigned yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card" id="playlist-add-section">
|
||||
<h3>Add slide to playlist</h3>
|
||||
{{#if hasSlides}}
|
||||
{{#if hasSelectableSlides}}
|
||||
<form id="playlist-add-slide-form">
|
||||
<div class="row">
|
||||
<label style="flex: 2;">Slide
|
||||
<select id="playlist-add-slide-select" required>
|
||||
{{#each selectableSlides}}
|
||||
<option value="{{id}}" data-canvas-signature="{{canvasSignature}}" {{#if isAssigned}}disabled{{/if}}>{{title}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<label style="width: 160px;">Visible for (seconds)
|
||||
<input id="playlist-add-duration" type="number" min="1" value="10" required />
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" id="playlist-add-slide-button">Add slide</button>
|
||||
</form>
|
||||
<div id="playlist-add-slide-empty" class="empty" style="display: none;">No slides are currently available for this playlist.</div>
|
||||
{{else}}
|
||||
<div class="empty">
|
||||
{{#if playlistCanvasMismatch}}
|
||||
This playlist contains slides with different canvas sizes. Remove the mismatched items before adding more.
|
||||
{{else if playlistCanvasSignature}}
|
||||
No slides match this playlist canvas size.
|
||||
{{else}}
|
||||
No slides are currently available for this playlist.
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<div class="empty">Create slides first.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<form id="delete-playlist-form" method="post" action="/admin/playlists/{{playlist.id}}/delete" data-confirm-message="Delete this playlist?"></form>
|
||||
|
||||
<dialog id="slide-schedule-dialog" class="slide-schedule-dialog">
|
||||
<div class="slide-schedule-dialog-head">
|
||||
<div>
|
||||
<strong>Slide schedule</strong>
|
||||
<div class="muted">Configure availability for this playlist item.</div>
|
||||
</div>
|
||||
<button type="button" class="secondary" id="slide-schedule-close">Close</button>
|
||||
</div>
|
||||
<iframe id="slide-schedule-frame" title="Slide schedule configuration"></iframe>
|
||||
</dialog>
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Playlists</h2>
|
||||
<p>Organize slide rotations and manage scheduled playback.</p>
|
||||
</div>
|
||||
<a class="button-link" href="/admin/playlists/new">Create playlist</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Existing playlists</h3>
|
||||
<table class="table-cards">
|
||||
<thead><tr><th>Name</th><th>Slides</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{#if playlists.length}}
|
||||
{{#each playlists}}
|
||||
<tr>
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="Slides"><span class="chip">{{slideCount}} slide{{#unless slideCountIsOne}}s{{/unless}}</span></td>
|
||||
<td data-label="Actions">
|
||||
<div class="actions">
|
||||
<a class="button-link list-action" href="/admin/playlists/{{id}}/edit">Edit</a>
|
||||
<form class="inline-form" method="post" action="/admin/playlists/{{id}}/delete" data-confirm-message="Delete this playlist?">
|
||||
<button class="danger list-action" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="3" class="empty">No playlists yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
<form method="post" action="/admin/playlists/{{playlist.id}}/slides/{{playlistSlide.id}}/config">
|
||||
<input type="hidden" name="playlist_slide_id" value="{{playlistSlide.id}}" />
|
||||
<input type="hidden" name="row_key" value="{{rowKey}}" />
|
||||
<label class="schedule-type-field">
|
||||
<select name="schedule_mode" id="schedule-mode-select">
|
||||
<option value="always" {{#if isAlwaysMode}}selected{{/if}}>Always visible</option>
|
||||
<option value="dates" {{#if isDatesMode}}selected{{/if}}>Between two datetimes</option>
|
||||
<option value="times" {{#if isTimesMode}}selected{{/if}}>Between times on selected days</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="card" id="schedule-dates-panel">
|
||||
<h3>Date range</h3>
|
||||
<div class="row">
|
||||
<label style="flex: 1;">Start datetime
|
||||
<input type="datetime-local" name="schedule_start_datetime" value="{{startDateTimeValue}}" />
|
||||
</label>
|
||||
<label style="flex: 1;">End datetime
|
||||
<input type="datetime-local" name="schedule_end_datetime" value="{{endDateTimeValue}}" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="schedule-times-panel">
|
||||
<h3>Time range</h3>
|
||||
<div class="row">
|
||||
<label style="flex: 1;">Start time
|
||||
<input type="time" name="schedule_start_time" value="{{startTimeValue}}" />
|
||||
</label>
|
||||
<label style="flex: 1;">End time
|
||||
<input type="time" name="schedule_end_time" value="{{endTimeValue}}" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="schedule-day-grid">
|
||||
{{#each dayOptions}}
|
||||
<label class="schedule-day-option switch-control">
|
||||
<input type="checkbox" name="schedule_days" value="{{value}}" {{#if checked}}checked{{/if}} />
|
||||
<span class="switch-ui" aria-hidden="true"></span>
|
||||
<span class="schedule-day-label">{{label}}</span>
|
||||
</label>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="schedule-save-actions">
|
||||
<button type="submit">Save schedule</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,24 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Add screen</h2>
|
||||
<p>Register a player endpoint and connect it to a playlist.</p>
|
||||
</div>
|
||||
<a class="button-link" href="/admin/screens">Back to screens</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<form method="post" action="/admin/screens" data-async-save>
|
||||
<label>Name
|
||||
<input name="name" placeholder="Front desk TV" required />
|
||||
</label>
|
||||
<label>Playlist
|
||||
<select name="playlist_id">
|
||||
<option value="">-- not assigned --</option>
|
||||
{{#each playlists}}
|
||||
<option value="{{id}}">{{name}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Create screen</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Edit screen</h2>
|
||||
<p>Update the display name and assigned playlist.</p>
|
||||
</div>
|
||||
<a class="button-link" href="/admin/screens">Back to screens</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Edit screen</h3>
|
||||
<form id="screen-edit-form" class="screen-edit-form" method="post" action="/admin/screens/{{screen.id}}" data-async-save>
|
||||
<label>Name
|
||||
<input name="name" value="{{screen.name}}" required />
|
||||
</label>
|
||||
<label>Playlist
|
||||
<select name="playlist_id">
|
||||
<option value="">-- not assigned --</option>
|
||||
{{#each playlists}}
|
||||
<option value="{{id}}" {{#if (eq ../screen.playlist_id id)}}selected{{/if}}>{{name}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<div class="screen-edit-actions">
|
||||
<button type="submit" form="screen-edit-form">Save changes</button>
|
||||
<form class="inline-form" method="post" action="/admin/screens/{{screen.id}}/delete" data-confirm-message="Delete this screen?">
|
||||
<button class="danger" type="submit">Delete screen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Player URL</h3>
|
||||
<p><a href="{{playerUrl screen.slug}}" target="_blank">{{playerUrl screen.slug}}</a></p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Screens</h2>
|
||||
<p>Register display endpoints and keep playlist assignments in sync.</p>
|
||||
</div>
|
||||
<a class="button-link" href="/admin/screens/new">Add screen</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Existing screens</h3>
|
||||
<table class="table-cards">
|
||||
<thead><tr><th>Name</th><th>Player URL</th><th>Playlist</th><th>Connected clients</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{#if screens.length}}
|
||||
{{#each screens}}
|
||||
<tr>
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="Player URL"><a href="{{playerUrl slug}}" target="_blank">{{playerUrl slug}}</a></td>
|
||||
<td data-label="Playlist">{{playlist_name}}</td>
|
||||
<td data-label="Connected clients">
|
||||
{{#if player_connection_count}}
|
||||
<div class="connection-count">{{player_connection_count}} connected</div>
|
||||
{{else}}
|
||||
<span class="empty">No clients connected.</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
<div class="actions">
|
||||
<a class="button-link list-action" href="/admin/screens/{{id}}/edit">Edit</a>
|
||||
<form class="inline-form" method="post" action="/admin/screens/{{id}}/delete" data-confirm-message="Delete this screen?">
|
||||
<button class="danger list-action" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="5" class="empty">No screens yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,50 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>{{#if isEdit}}Edit slide{{else}}Create slide{{/if}}</h2>
|
||||
<p>Build slide content and preview it against the selected template.</p>
|
||||
</div>
|
||||
<a class="button-link" href="/admin/slides">Back to slides</a>
|
||||
</div>
|
||||
|
||||
<div class="slide-editor-top">
|
||||
<form method="post" action="{{action}}" enctype="multipart/form-data" id="slide-form">
|
||||
<div class="card slide-template-card">
|
||||
<div class="row">
|
||||
<label style="flex: 2;">Title
|
||||
<input name="title" value="{{slide.title}}" required />
|
||||
</label>
|
||||
<label style="flex: 3;">Template
|
||||
<select name="template_id" id="template-select">
|
||||
<option value="">-- none --</option>
|
||||
{{#each templates}}
|
||||
<option value="{{id}}" {{#if (eq ../slide.template_id id)}}selected{{/if}}>{{name}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="template-fields"></div>
|
||||
</form>
|
||||
<div class="card slide-preview-card">
|
||||
<div class="slide-preview-header">
|
||||
<h3>Preview</h3>
|
||||
<div class="muted" id="slide-preview-meta">Select a template to preview the slide.</div>
|
||||
</div>
|
||||
<div class="slide-preview-scroll">
|
||||
<button type="submit" form="slide-form">{{#if isEdit}}Save changes{{else}}Create slide{{/if}}</button>
|
||||
<div class="slide-preview-stage" id="slide-preview-stage">
|
||||
<img id="slide-preview-background" class="slide-preview-background" alt="" style="display:none;" />
|
||||
<div class="slide-preview-empty" id="slide-preview-empty">Select a template to preview the slide.</div>
|
||||
<div class="slide-preview-overlay" id="slide-preview-overlay"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/js/compact/editor.header.min.js"></script>
|
||||
<script src="/assets/js/compact/editor.list.min.js"></script>
|
||||
<script src="/assets/js/compact/editor.marker.min.js"></script>
|
||||
<script src="/assets/js/compact/editor.table.min.js"></script>
|
||||
<script src="/assets/js/compact/editor.min.js"></script>
|
||||
<textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea>
|
||||
<script src="/assets/js/slide-form.js"></script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Slides</h2>
|
||||
<p>Manage individual slide content and template assignments.</p>
|
||||
</div>
|
||||
<a class="button-link" href="/admin/slides/new">Create slide</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Existing slides</h3>
|
||||
<table class="table-cards">
|
||||
<thead><tr><th>Title</th><th>Template</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{#if slides.length}}
|
||||
{{#each slides}}
|
||||
<tr>
|
||||
<td data-label="Title">{{title}}</td>
|
||||
<td data-label="Template">{{template_name}}</td>
|
||||
<td data-label="Actions">
|
||||
<div class="actions">
|
||||
<a class="button-link list-action" href="/admin/slides/{{id}}/edit">Edit</a>
|
||||
<form class="inline-form" method="post" action="/admin/slides/{{id}}/delete" data-confirm-message="Delete this slide?">
|
||||
<button class="danger list-action" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="3" class="empty">No slides yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,72 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Create template</h2>
|
||||
<p>Define a canvas, upload a background, and place content regions.</p>
|
||||
</div>
|
||||
<a class="button-link" href="/admin/templates">Back to templates</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/templates" enctype="multipart/form-data" id="template-form">
|
||||
<div class="card template-meta-card">
|
||||
<div class="template-meta-row">
|
||||
<label>
|
||||
Name
|
||||
<input name="name" id="template-name" value="{{template.name}}" required />
|
||||
</label>
|
||||
<label>
|
||||
Canvas size
|
||||
<select id="canvas-size-select" data-lock-on-existing-template="false">
|
||||
{{#unless template.canvas_size_id}}
|
||||
<option value="" data-width="{{template.canvas_size_width}}" data-height="{{template.canvas_size_height}}" selected>{{template.canvas_size_label}}</option>
|
||||
{{/unless}}
|
||||
{{#if canvasSizes.length}}
|
||||
{{#each canvasSizes}}
|
||||
<option value="{{id}}" data-width="{{width}}" data-height="{{height}}" {{#if (eq ../template.canvas_size_id id)}}selected{{/if}}>{{name}} ({{width}}x{{height}})</option>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Background image
|
||||
<input type="file" name="background_image" id="background-image" accept="image/*" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="canvas_size_id" id="canvas-size-id" value="{{template.canvas_size_id}}" />
|
||||
<input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" />
|
||||
<input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" />
|
||||
<input type="hidden" id="canvas-size-summary" />
|
||||
<input type="hidden" name="existing_background_image_path" value="{{template.background_image_path}}" />
|
||||
<input type="hidden" name="regions_json" id="regions-json" value="" />
|
||||
|
||||
<div class="template-designer-layout">
|
||||
<div class="card">
|
||||
<div class="template-designer-main">
|
||||
<div class="designer-stage" id="designer-stage" style="aspect-ratio: {{template.canvas_size_width}} / {{template.canvas_size_height}};">
|
||||
<img id="background-preview" src="{{template.background_image_path}}" alt="" style="{{#if template.background_image_path}}{{else}}display:none;{{/if}}" />
|
||||
<div class="empty-bg" id="background-empty" style="{{#if template.background_image_path}}display:none;{{/if}}">No background image selected</div>
|
||||
<div class="designer-overlay" id="designer-overlay"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-designer-sidebar">
|
||||
<button type="submit" class="template-save-button">Create template</button>
|
||||
<div class="card">
|
||||
<div class="row template-designer-actions">
|
||||
<button type="button" class="secondary" id="add-text-region">Add text region</button>
|
||||
<button type="button" class="secondary" id="add-html-region">Add HTML region</button>
|
||||
<button type="button" class="secondary" id="add-image-region">Add image region</button>
|
||||
<button type="button" class="secondary" id="add-webpage-region">Add webpage region</button>
|
||||
</div>
|
||||
<h3>Region information</h3>
|
||||
<label>Select region
|
||||
<select id="region-select" class="region-select"></select>
|
||||
</label>
|
||||
<div id="region-list" class="region-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<textarea id="template-editor-data" hidden>{{json template.regions}}</textarea>
|
||||
@@ -0,0 +1,72 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Edit template</h2>
|
||||
<p>Adjust the canvas and region layout for this template.</p>
|
||||
</div>
|
||||
<a class="button-link" href="/admin/templates">Back to templates</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/templates/{{template.id}}" enctype="multipart/form-data" id="template-form">
|
||||
<div class="card template-meta-card">
|
||||
<div class="template-meta-row">
|
||||
<label>
|
||||
Name
|
||||
<input name="name" id="template-name" value="{{template.name}}" required />
|
||||
</label>
|
||||
<label>
|
||||
Canvas size
|
||||
<select id="canvas-size-select" data-lock-on-existing-template="true">
|
||||
{{#unless template.canvas_size_id}}
|
||||
<option value="" data-width="{{template.canvas_size_width}}" data-height="{{template.canvas_size_height}}" selected>{{template.canvas_size_label}}</option>
|
||||
{{/unless}}
|
||||
{{#if canvasSizes.length}}
|
||||
{{#each canvasSizes}}
|
||||
<option value="{{id}}" data-width="{{width}}" data-height="{{height}}" {{#if (eq ../template.canvas_size_id id)}}selected{{/if}}>{{name}} ({{width}}x{{height}})</option>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Background image
|
||||
<input type="file" name="background_image" id="background-image" accept="image/*" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="canvas_size_id" id="canvas-size-id" value="{{template.canvas_size_id}}" />
|
||||
<input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" />
|
||||
<input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" />
|
||||
<input type="hidden" id="canvas-size-summary" />
|
||||
<input type="hidden" name="existing_background_image_path" value="{{template.background_image_path}}" />
|
||||
<input type="hidden" name="regions_json" id="regions-json" value="" />
|
||||
|
||||
<div class="template-designer-layout">
|
||||
<div class="card">
|
||||
<div class="template-designer-main">
|
||||
<div class="designer-stage" id="designer-stage" style="aspect-ratio: {{template.canvas_size_width}} / {{template.canvas_size_height}};">
|
||||
<img id="background-preview" src="{{template.background_image_path}}" alt="" style="{{#if template.background_image_path}}{{else}}display:none;{{/if}}" />
|
||||
<div class="empty-bg" id="background-empty" style="{{#if template.background_image_path}}display:none;{{/if}}">No background image selected</div>
|
||||
<div class="designer-overlay" id="designer-overlay"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-designer-sidebar">
|
||||
<button type="submit" class="template-save-button">Save changes</button>
|
||||
<div class="card">
|
||||
<div class="row template-designer-actions">
|
||||
<button type="button" class="secondary" id="add-text-region">Add text</button>
|
||||
<button type="button" class="secondary" id="add-html-region">Add HTML</button>
|
||||
<button type="button" class="secondary" id="add-image-region">Add image</button>
|
||||
<button type="button" class="secondary" id="add-webpage-region">Add webpage</button>
|
||||
</div>
|
||||
<h3>Region information</h3>
|
||||
<label>Select region
|
||||
<select id="region-select" class="region-select"></select>
|
||||
</label>
|
||||
<div id="region-list" class="region-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<textarea id="template-editor-data" hidden>{{json template.regions}}</textarea>
|
||||
@@ -0,0 +1,35 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Slide templates</h2>
|
||||
<p>Design reusable canvas layouts and region definitions.</p>
|
||||
</div>
|
||||
<a class="button-link" href="/admin/templates/new">Create Template</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Existing templates</h3>
|
||||
<table class="table-cards">
|
||||
<thead><tr><th>Name</th><th>Canvas</th><th>Regions</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{{#if templates.length}}
|
||||
{{#each templates}}
|
||||
<tr>
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="Canvas">{{canvas_size_width}}x{{canvas_size_height}}</td>
|
||||
<td data-label="Regions">{{regionCount}}</td>
|
||||
<td data-label="Actions">
|
||||
<div class="actions">
|
||||
<a class="button-link list-action" href="/admin/templates/{{id}}/edit">Edit</a>
|
||||
<form class="inline-form" method="post" action="/admin/templates/{{id}}/delete" data-confirm-message="Delete this template?">
|
||||
<button class="danger list-action" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="4" class="empty">No templates yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Add user</h2>
|
||||
<p>Create a new login for the web UI.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card user-create-card">
|
||||
<form method="post" action="/admin/users" class="user-create-form">
|
||||
<section class="user-create-section">
|
||||
<h3>Account details</h3>
|
||||
<div class="user-create-fields">
|
||||
<label>
|
||||
Username
|
||||
<input type="text" name="username" autocomplete="username" required />
|
||||
</label>
|
||||
<label>
|
||||
Name
|
||||
<input type="text" name="name" autocomplete="name" required />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="user-create-section">
|
||||
<h3>Set password</h3>
|
||||
<div class="user-create-fields">
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="password" autocomplete="new-password" minlength="8" required />
|
||||
</label>
|
||||
<label>
|
||||
Confirm password
|
||||
<input type="password" name="confirm_password" autocomplete="new-password" minlength="8" required />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="actions user-create-actions">
|
||||
<button type="submit" class="user-create-button">Create user</button>
|
||||
<a class="button-link secondary" href="/admin/users">Back to users</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Edit user</h2>
|
||||
<p>Update the name or password for {{user.username}}.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card user-edit-card">
|
||||
<div class="user-edit-header">
|
||||
<div class="user-cell">
|
||||
<div class="user-avatar" aria-hidden="true">{{usernameInitial user.username}}</div>
|
||||
<div>
|
||||
<strong>{{user.username}}</strong>
|
||||
{{#if user.isCurrentUser}}
|
||||
<div class="subtle">Current signed-in user</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<a class="button-link secondary" href="/admin/users">Back to users</a>
|
||||
</div>
|
||||
|
||||
<div class="user-edit-grid">
|
||||
<form method="post" action="/admin/users/{{user.id}}/username" class="card user-edit-panel">
|
||||
<h3>Edit user</h3>
|
||||
<label>
|
||||
Username
|
||||
<input type="text" name="username" value="{{user.username}}" autocomplete="username" required disabled />
|
||||
</label>
|
||||
<label>
|
||||
Name
|
||||
<input type="text" name="name" value="{{user.name}}" autocomplete="name" required />
|
||||
</label>
|
||||
<button type="submit" class="user-edit-button">Save changes</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/admin/users/{{user.id}}/password" class="card user-edit-panel">
|
||||
<h3>Reset password</h3>
|
||||
<label>
|
||||
New password
|
||||
<input type="password" name="password" autocomplete="new-password" minlength="8" required />
|
||||
</label>
|
||||
<label>
|
||||
Confirm password
|
||||
<input type="password" name="confirm_password" autocomplete="new-password" minlength="8" required />
|
||||
</label>
|
||||
<button type="submit" class="user-edit-button">Save password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Users</h2>
|
||||
<p>Create accounts, rename users, reset passwords, and remove unused logins.</p>
|
||||
</div>
|
||||
<div class="page-toolbar">
|
||||
<a class="button-link" href="/admin/users/new">Add user</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Existing users</h3>
|
||||
<table class="table-cards users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Name</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#if users.length}}
|
||||
{{#each users}}
|
||||
<tr>
|
||||
<td data-label="User">
|
||||
<div class="user-cell">
|
||||
<div class="user-avatar user-avatar--small" aria-hidden="true">{{usernameInitial name}}</div>
|
||||
<div>
|
||||
<strong>{{username}}</strong>
|
||||
{{#if isCurrentUser}}
|
||||
<div class="subtle">Current signed-in user</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Name">
|
||||
<div class="user-cell">
|
||||
<div>
|
||||
<strong>{{#if name}}{{name}}{{else}}-{{/if}}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
<div class="actions users-row-actions">
|
||||
{{#unless isCurrentUser}}
|
||||
<a class="button-link list-action" href="/admin/users/{{id}}/edit">Edit</a>
|
||||
<form method="post" action="/admin/users/{{id}}/delete" class="inline-form" data-confirm-message="Delete {{username}}?">
|
||||
<button type="submit" class="button-link list-action is-danger">Delete</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<span class="empty">Protected</span>
|
||||
{{/unless}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="3" class="empty">No users found.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
Reference in New Issue
Block a user