Save worktree changes
This commit is contained in:
Vendored
+18
-5
@@ -1,6 +1,7 @@
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { createDashboardStateService } = require('./dashboard-state');
|
||||
const { createUploadSyncService } = require('./upload-sync');
|
||||
const { createDashboardStateService } = require('./lib/dashboard-state');
|
||||
const { createUploadSyncService } = require('./lib/upload-sync');
|
||||
const { createRequestAuthHeaders } = require('../request-auth');
|
||||
|
||||
function createWebBootstrap(options) {
|
||||
const pool = options && options.pool;
|
||||
@@ -11,6 +12,7 @@ function createWebBootstrap(options) {
|
||||
const dashboardRefreshIntervalMs = Number(options && options.dashboardRefreshIntervalMs || 2000);
|
||||
const formatDashboardDate = options && options.formatDashboardDate;
|
||||
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
|
||||
if (!pool || !common || !uploadDir || typeof formatDashboardDate !== 'function' || typeof notifyPlayerScreens !== 'function') {
|
||||
throw new Error('createWebBootstrap requires the web bootstrap dependencies.');
|
||||
@@ -51,7 +53,14 @@ function createWebBootstrap(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = new WebSocket(getPlayerSnapshotSocketUrl(key));
|
||||
const socketUrl = getPlayerSnapshotSocketUrl(key);
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: `/ws/screens/${encodeURIComponent(key)}/events`
|
||||
});
|
||||
const socket = new WebSocket(socketUrl, {
|
||||
headers: authHeaders
|
||||
});
|
||||
playerSnapshotSockets.set(key, socket);
|
||||
|
||||
socket.onmessage = function (event) {
|
||||
@@ -99,10 +108,12 @@ function createWebBootstrap(options) {
|
||||
const buildDashboardState = dashboardStateService.buildDashboardState;
|
||||
|
||||
const uploadSyncService = createUploadSyncService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
playerInternalBaseUrl: playerInternalBaseUrl,
|
||||
playerSnapshotCache: playerSnapshotCache,
|
||||
notifyPlayerScreens: notifyPlayerScreens
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
backgroundTaskQueue: backgroundTaskQueue
|
||||
});
|
||||
|
||||
const upload = uploadSyncService.createUploadMiddleware(uploadDir);
|
||||
@@ -111,6 +122,7 @@ function createWebBootstrap(options) {
|
||||
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
|
||||
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
|
||||
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
|
||||
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
|
||||
|
||||
async function sendDashboardStateToSocket(socket) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
@@ -151,7 +163,7 @@ function createWebBootstrap(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname !== '/ws/admin/dashboard') {
|
||||
if (pathname !== '/ws/dashboard') {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
@@ -200,6 +212,7 @@ function createWebBootstrap(options) {
|
||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
|
||||
runMediaSyncTask: runMediaSyncTask,
|
||||
broadcastDashboardState: broadcastDashboardState,
|
||||
installDashboardWebsocket: installDashboardWebsocket
|
||||
};
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function toIsoDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
function normalizeIntervalMs(value, unit) {
|
||||
const numericValue = Math.max(1, Number(value) || 0);
|
||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return numericValue * 1000;
|
||||
}
|
||||
return numericValue * 60 * 1000;
|
||||
}
|
||||
|
||||
function createBackgroundTaskQueue(options) {
|
||||
const pool = options && options.pool;
|
||||
const maxConcurrent = Math.max(1, Number(options && options.maxConcurrent) || 1);
|
||||
const taskHandlers = new Map();
|
||||
const tasksById = new Map();
|
||||
const recurringJobsByKey = new Map();
|
||||
const taskIdToRecurringKey = new Map();
|
||||
const pendingIds = [];
|
||||
let nextTaskId = 1;
|
||||
let activeCount = 0;
|
||||
let drainScheduled = false;
|
||||
let initializationPromise = null;
|
||||
|
||||
function createTaskCompletionController() {
|
||||
let resolveCompletion = null;
|
||||
let rejectCompletion = null;
|
||||
const completionPromise = new Promise(function (resolve, reject) {
|
||||
resolveCompletion = resolve;
|
||||
rejectCompletion = reject;
|
||||
});
|
||||
|
||||
return {
|
||||
promise: completionPromise,
|
||||
resolve: resolveCompletion,
|
||||
reject: rejectCompletion
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonValue(value, fallback) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(String(value));
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyJsonValue(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function buildSnapshot(task) {
|
||||
return {
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
taskType: task.taskType || '',
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt,
|
||||
startedAt: task.startedAt,
|
||||
finishedAt: task.finishedAt,
|
||||
errorMessage: task.errorMessage,
|
||||
attempts: task.attempts || 0,
|
||||
metadata: task.metadata,
|
||||
payload: task.payload || null
|
||||
};
|
||||
}
|
||||
|
||||
function getTaskById(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tasksById.get(numericTaskId) || null;
|
||||
}
|
||||
|
||||
function buildTaskFromRow(row) {
|
||||
const task = {
|
||||
id: Number(row.id),
|
||||
key: String(row.task_key || '').trim(),
|
||||
taskType: String(row.task_type || '').trim(),
|
||||
title: String(row.title || 'Background task').trim() || 'Background task',
|
||||
category: String(row.category || 'general').trim() || 'general',
|
||||
status: String(row.status || 'queued').trim() || 'queued',
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
|
||||
startedAt: row.started_at ? new Date(row.started_at).toISOString() : '',
|
||||
finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : '',
|
||||
errorMessage: String(row.error_message || ''),
|
||||
attempts: Math.max(0, Number(row.attempts) || 0),
|
||||
metadata: parseJsonValue(row.metadata_json, {}),
|
||||
payload: parseJsonValue(row.payload_json, null),
|
||||
completionPromise: null,
|
||||
resolveCompletion: null,
|
||||
rejectCompletion: null,
|
||||
persisted: true,
|
||||
run: typeof row.run === 'function' ? row.run : function () {
|
||||
return Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const completionController = createTaskCompletionController();
|
||||
task.completionPromise = completionController.promise;
|
||||
task.resolveCompletion = completionController.resolve;
|
||||
task.rejectCompletion = completionController.reject;
|
||||
return task;
|
||||
}
|
||||
|
||||
function buildTaskRecord(task) {
|
||||
return {
|
||||
task_key: task.key || null,
|
||||
task_type: task.taskType || 'general',
|
||||
title: task.title,
|
||||
category: task.category || 'general',
|
||||
status: task.status,
|
||||
payload_json: stringifyJsonValue(task.payload),
|
||||
metadata_json: stringifyJsonValue(task.metadata),
|
||||
attempts: Math.max(0, Number(task.attempts) || 0),
|
||||
created_at: task.createdAt ? new Date(task.createdAt) : new Date(),
|
||||
started_at: task.startedAt ? new Date(task.startedAt) : null,
|
||||
finished_at: task.finishedAt ? new Date(task.finishedAt) : null,
|
||||
error_message: task.errorMessage || null
|
||||
};
|
||||
}
|
||||
|
||||
async function persistTaskInsert(task) {
|
||||
if (!pool || !task.taskType) {
|
||||
return task;
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(task);
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO background_tasks (
|
||||
task_key,
|
||||
task_type,
|
||||
title,
|
||||
category,
|
||||
status,
|
||||
payload_json,
|
||||
metadata_json,
|
||||
attempts,
|
||||
created_at,
|
||||
started_at,
|
||||
finished_at,
|
||||
error_message
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
record.task_key,
|
||||
record.task_type,
|
||||
record.title,
|
||||
record.category,
|
||||
record.status,
|
||||
record.payload_json,
|
||||
record.metadata_json,
|
||||
record.attempts,
|
||||
record.created_at,
|
||||
record.started_at,
|
||||
record.finished_at,
|
||||
record.error_message
|
||||
]
|
||||
);
|
||||
|
||||
task.id = Number(result.insertId);
|
||||
task.persisted = true;
|
||||
return task;
|
||||
}
|
||||
|
||||
async function persistTaskUpdate(task) {
|
||||
if (!pool || !task.persisted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(task);
|
||||
await pool.query(
|
||||
`UPDATE background_tasks
|
||||
SET task_key = ?, task_type = ?, title = ?, category = ?, status = ?, payload_json = ?, metadata_json = ?, attempts = ?, started_at = ?, finished_at = ?, error_message = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
record.task_key,
|
||||
record.task_type,
|
||||
record.title,
|
||||
record.category,
|
||||
record.status,
|
||||
record.payload_json,
|
||||
record.metadata_json,
|
||||
record.attempts,
|
||||
record.started_at,
|
||||
record.finished_at,
|
||||
record.error_message,
|
||||
task.id
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function persistTaskDelete(taskId) {
|
||||
if (!pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM background_tasks WHERE id = ?', [taskId]);
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
if (initializationPromise) {
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
initializationPromise = (async function () {
|
||||
if (!pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message FROM background_tasks ORDER BY id ASC'
|
||||
);
|
||||
|
||||
let highestTaskId = 0;
|
||||
for (const row of rows || []) {
|
||||
const task = buildTaskFromRow(row);
|
||||
if (!Number.isInteger(task.id) || task.id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
highestTaskId = Math.max(highestTaskId, task.id);
|
||||
tasksById.set(task.id, task);
|
||||
|
||||
if (task.status === 'running') {
|
||||
task.status = 'queued';
|
||||
task.startedAt = '';
|
||||
task.finishedAt = '';
|
||||
task.errorMessage = '';
|
||||
await pool.query(
|
||||
'UPDATE background_tasks SET status = ?, started_at = NULL, finished_at = NULL, error_message = NULL WHERE id = ?',
|
||||
['queued', task.id]
|
||||
);
|
||||
}
|
||||
|
||||
if (task.status === 'queued' || task.status === 'running') {
|
||||
pendingIds.push(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
nextTaskId = Math.max(nextTaskId, highestTaskId + 1);
|
||||
if (pendingIds.length) {
|
||||
scheduleDrain();
|
||||
}
|
||||
})();
|
||||
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
function scheduleDrain() {
|
||||
if (drainScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
drainScheduled = true;
|
||||
setTimeout(function () {
|
||||
drainScheduled = false;
|
||||
processQueue();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function clearRecurringTimer(job) {
|
||||
if (job && job.timerId) {
|
||||
clearTimeout(job.timerId);
|
||||
job.timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setTaskHandler(taskType, handler) {
|
||||
const normalizedTaskType = normalizeText(taskType);
|
||||
if (!normalizedTaskType || typeof handler !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
taskHandlers.set(normalizedTaskType, handler);
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleRecurringRun(job, delayMs) {
|
||||
if (!job || job.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRecurringTimer(job);
|
||||
const safeDelay = Math.max(1, Number(delayMs) || job.intervalMs || 0);
|
||||
job.nextRunAt = toIsoDate(new Date(Date.now() + safeDelay));
|
||||
job.timerId = setTimeout(function () {
|
||||
job.timerId = null;
|
||||
triggerRecurringJob(job.key);
|
||||
}, safeDelay);
|
||||
}
|
||||
|
||||
function triggerRecurringJob(recurringKey) {
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job || job.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.activeTaskId && tasksById.has(job.activeTaskId)) {
|
||||
scheduleRecurringRun(job, job.intervalMs);
|
||||
return;
|
||||
}
|
||||
|
||||
job.activeTaskId = -1;
|
||||
enqueueTask({
|
||||
key: `${job.key}:${Date.now()}`,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
taskType: job.taskType || '',
|
||||
metadata: Object.assign({}, job.metadata || {}, {
|
||||
recurringKey: job.key,
|
||||
recurringTitle: job.title
|
||||
}),
|
||||
payload: Object.assign({}, job.payload || {}, {
|
||||
recurringKey: job.key
|
||||
}),
|
||||
run: job.run,
|
||||
persist: Boolean(job.taskType)
|
||||
}).then(function (task) {
|
||||
if (task && Number.isInteger(task.id)) {
|
||||
job.activeTaskId = task.id;
|
||||
taskIdToRecurringKey.set(task.id, job.key);
|
||||
} else {
|
||||
job.activeTaskId = null;
|
||||
}
|
||||
}).catch(function (error) {
|
||||
job.activeTaskId = null;
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
scheduleRecurringRun(job, job.intervalMs);
|
||||
}
|
||||
|
||||
function syncRecurringTaskState(task, status, errorMessage) {
|
||||
const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey);
|
||||
if (!recurringKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job) {
|
||||
taskIdToRecurringKey.delete(task.id);
|
||||
return;
|
||||
}
|
||||
|
||||
job.activeTaskId = null;
|
||||
job.lastRunAt = toIsoDate(new Date());
|
||||
job.lastStatus = status;
|
||||
job.lastError = errorMessage ? String(errorMessage) : '';
|
||||
taskIdToRecurringKey.delete(task.id);
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
while (activeCount < maxConcurrent) {
|
||||
const nextTaskId = pendingIds.shift();
|
||||
if (!nextTaskId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const task = tasksById.get(nextTaskId);
|
||||
if (!task || task.status !== 'queued') {
|
||||
continue;
|
||||
}
|
||||
|
||||
activeCount += 1;
|
||||
task.status = 'running';
|
||||
task.startedAt = toIsoDate(new Date());
|
||||
task.errorMessage = '';
|
||||
task.attempts = Math.max(0, Number(task.attempts) || 0) + 1;
|
||||
|
||||
try {
|
||||
await persistTaskUpdate(task);
|
||||
} catch (error) {
|
||||
task.status = 'failed';
|
||||
task.errorMessage = String(error && error.message ? error.message : 'Unable to update task state.');
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
if (typeof task.rejectCompletion === 'function') {
|
||||
const completionError = new Error(task.errorMessage);
|
||||
completionError.task = buildSnapshot(task);
|
||||
task.rejectCompletion(completionError);
|
||||
}
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
scheduleDrain();
|
||||
continue;
|
||||
}
|
||||
|
||||
Promise.resolve()
|
||||
.then(function () {
|
||||
if (task.taskType) {
|
||||
const handler = taskHandlers.get(task.taskType);
|
||||
if (!handler) {
|
||||
throw new Error('No handler registered for task type ' + task.taskType + '.');
|
||||
}
|
||||
|
||||
return handler({
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
taskType: task.taskType,
|
||||
payload: task.payload,
|
||||
metadata: task.metadata,
|
||||
attempts: task.attempts
|
||||
});
|
||||
}
|
||||
|
||||
return task.run({
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
metadata: task.metadata
|
||||
});
|
||||
})
|
||||
.then(function () {
|
||||
task.status = 'completed';
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
if (typeof task.resolveCompletion === 'function') {
|
||||
task.resolveCompletion(buildSnapshot(task));
|
||||
}
|
||||
syncRecurringTaskState(task, task.status, '');
|
||||
return persistTaskUpdate(task);
|
||||
})
|
||||
.catch(function (error) {
|
||||
task.status = 'failed';
|
||||
task.errorMessage = String(error && error.message ? error.message : 'Background task failed.');
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
if (typeof task.rejectCompletion === 'function') {
|
||||
const completionError = new Error(task.errorMessage);
|
||||
completionError.task = buildSnapshot(task);
|
||||
task.rejectCompletion(completionError);
|
||||
}
|
||||
syncRecurringTaskState(task, task.status, task.errorMessage);
|
||||
return persistTaskUpdate(task);
|
||||
})
|
||||
.finally(function () {
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
scheduleDrain();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueTask(definition) {
|
||||
const normalizedKey = normalizeText(definition && definition.key);
|
||||
const normalizedTitle = normalizeText(definition && definition.title) || 'Background task';
|
||||
const normalizedTaskType = normalizeText(definition && definition.taskType);
|
||||
const shouldPersist = Boolean((definition && definition.persist) || (pool && normalizedTaskType));
|
||||
const existingTask = normalizedKey
|
||||
? Array.from(tasksById.values()).find(function (task) {
|
||||
return task.key === normalizedKey && task.status === 'queued' && (!normalizedTaskType || task.taskType === normalizedTaskType);
|
||||
})
|
||||
: null;
|
||||
|
||||
if (existingTask) {
|
||||
existingTask.title = normalizedTitle;
|
||||
existingTask.category = normalizeText(definition && definition.category) || existingTask.category || 'general';
|
||||
existingTask.metadata = definition && definition.metadata ? definition.metadata : {};
|
||||
existingTask.taskType = normalizedTaskType || existingTask.taskType || '';
|
||||
existingTask.payload = definition && definition.payload !== undefined ? definition.payload : existingTask.payload;
|
||||
existingTask.run = typeof definition.run === 'function' ? definition.run : existingTask.run;
|
||||
existingTask.createdAt = toIsoDate(new Date());
|
||||
existingTask.errorMessage = '';
|
||||
existingTask.persisted = existingTask.persisted || shouldPersist;
|
||||
await persistTaskUpdate(existingTask);
|
||||
return buildSnapshot(existingTask);
|
||||
}
|
||||
|
||||
const task = {
|
||||
id: shouldPersist ? 0 : nextTaskId,
|
||||
key: normalizedKey,
|
||||
taskType: normalizedTaskType,
|
||||
title: normalizedTitle,
|
||||
category: normalizeText(definition && definition.category) || 'general',
|
||||
status: 'queued',
|
||||
createdAt: toIsoDate(new Date()),
|
||||
startedAt: '',
|
||||
finishedAt: '',
|
||||
errorMessage: '',
|
||||
attempts: 0,
|
||||
metadata: definition && definition.metadata ? definition.metadata : {},
|
||||
payload: definition && definition.payload !== undefined ? definition.payload : null,
|
||||
persisted: shouldPersist,
|
||||
run: typeof definition.run === 'function' ? definition.run : function () {
|
||||
return Promise.resolve();
|
||||
},
|
||||
completionPromise: null,
|
||||
resolveCompletion: null,
|
||||
rejectCompletion: null
|
||||
};
|
||||
|
||||
const completionController = createTaskCompletionController();
|
||||
task.completionPromise = completionController.promise;
|
||||
task.resolveCompletion = completionController.resolve;
|
||||
task.rejectCompletion = completionController.reject;
|
||||
|
||||
if (shouldPersist) {
|
||||
await persistTaskInsert(task);
|
||||
} else {
|
||||
nextTaskId += 1;
|
||||
}
|
||||
|
||||
tasksById.set(task.id, task);
|
||||
pendingIds.push(task.id);
|
||||
scheduleDrain();
|
||||
return buildSnapshot(task);
|
||||
}
|
||||
|
||||
function registerRecurringTask(definition) {
|
||||
const normalizedKey = normalizeText(definition && definition.key);
|
||||
if (!normalizedKey) {
|
||||
throw new Error('Recurring tasks require a key.');
|
||||
}
|
||||
|
||||
const intervalMs = Math.max(1000, Number(definition && definition.intervalMs) || 0);
|
||||
if (!Number.isFinite(intervalMs) || intervalMs < 1000) {
|
||||
throw new Error('Recurring tasks require a valid interval.');
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(normalizedKey) || {
|
||||
key: normalizedKey,
|
||||
activeTaskId: null,
|
||||
lastRunAt: '',
|
||||
lastStatus: '',
|
||||
lastError: '',
|
||||
nextRunAt: '',
|
||||
timerId: null,
|
||||
enabled: true
|
||||
};
|
||||
|
||||
clearRecurringTimer(job);
|
||||
job.title = normalizeText(definition && definition.title) || 'Background task';
|
||||
job.category = normalizeText(definition && definition.category) || 'general';
|
||||
job.intervalMs = intervalMs;
|
||||
job.metadata = definition && definition.metadata ? definition.metadata : {};
|
||||
job.run = typeof definition.run === 'function' ? definition.run : function () {
|
||||
return Promise.resolve();
|
||||
};
|
||||
job.enabled = definition && definition.enabled === false ? false : true;
|
||||
recurringJobsByKey.set(normalizedKey, job);
|
||||
|
||||
if (job.enabled) {
|
||||
scheduleRecurringRun(job, intervalMs);
|
||||
}
|
||||
|
||||
return buildRecurringSnapshot(job);
|
||||
}
|
||||
|
||||
function removeRecurringTask(recurringKey) {
|
||||
const normalizedKey = normalizeText(recurringKey);
|
||||
const job = recurringJobsByKey.get(normalizedKey);
|
||||
if (!job) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearRecurringTimer(job);
|
||||
recurringJobsByKey.delete(normalizedKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRecurringSnapshot(job) {
|
||||
return {
|
||||
key: job.key,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
intervalMs: job.intervalMs,
|
||||
enabled: job.enabled !== false,
|
||||
activeTaskId: job.activeTaskId || null,
|
||||
createdAt: job.createdAt || '',
|
||||
nextRunAt: job.nextRunAt || '',
|
||||
lastRunAt: job.lastRunAt || '',
|
||||
lastStatus: job.lastStatus || '',
|
||||
lastError: job.lastError || '',
|
||||
metadata: job.metadata || {}
|
||||
};
|
||||
}
|
||||
|
||||
function listTasks() {
|
||||
return Array.from(tasksById.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
const statusRank = {
|
||||
running: 0,
|
||||
queued: 1,
|
||||
failed: 2,
|
||||
completed: 3,
|
||||
canceled: 4
|
||||
};
|
||||
|
||||
const leftRank = Object.prototype.hasOwnProperty.call(statusRank, left.status) ? statusRank[left.status] : 9;
|
||||
const rightRank = Object.prototype.hasOwnProperty.call(statusRank, right.status) ? statusRank[right.status] : 9;
|
||||
if (leftRank !== rightRank) {
|
||||
return leftRank - rightRank;
|
||||
}
|
||||
|
||||
return right.id - left.id;
|
||||
})
|
||||
.map(buildSnapshot);
|
||||
}
|
||||
|
||||
function listRecurringTasks() {
|
||||
return Array.from(recurringJobsByKey.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
return left.key.localeCompare(right.key);
|
||||
})
|
||||
.map(buildRecurringSnapshot);
|
||||
}
|
||||
|
||||
function clearFinishedTasks() {
|
||||
let removedCount = 0;
|
||||
Array.from(tasksById.values()).forEach(function (task) {
|
||||
if (task.status === 'running' || task.status === 'queued') {
|
||||
return;
|
||||
}
|
||||
tasksById.delete(task.id);
|
||||
removedCount += 1;
|
||||
persistTaskDelete(task.id).catch(function (error) {
|
||||
console.warn('Unable to delete finished task from persistence:', error);
|
||||
});
|
||||
});
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
function cancelTask(taskId) {
|
||||
const task = getTaskById(taskId);
|
||||
if (!task || task.status !== 'queued') {
|
||||
return false;
|
||||
}
|
||||
|
||||
task.status = 'canceled';
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
const pendingIndex = pendingIds.indexOf(task.id);
|
||||
if (pendingIndex >= 0) {
|
||||
pendingIds.splice(pendingIndex, 1);
|
||||
}
|
||||
|
||||
if (typeof task.rejectCompletion === 'function') {
|
||||
const cancellationError = new Error('Task canceled.');
|
||||
cancellationError.task = buildSnapshot(task);
|
||||
task.rejectCompletion(cancellationError);
|
||||
}
|
||||
|
||||
persistTaskUpdate(task).catch(function (error) {
|
||||
console.warn('Unable to persist canceled task:', error);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function retryTask(taskId) {
|
||||
const task = getTaskById(taskId);
|
||||
if (!task || task.status !== 'failed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return enqueueTask({
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
metadata: task.metadata,
|
||||
run: task.run
|
||||
});
|
||||
}
|
||||
|
||||
function getSummary() {
|
||||
const counts = {
|
||||
queued: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
canceled: 0
|
||||
};
|
||||
|
||||
listTasks().forEach(function (task) {
|
||||
if (Object.prototype.hasOwnProperty.call(counts, task.status)) {
|
||||
counts[task.status] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
activeCount: activeCount,
|
||||
counts: counts,
|
||||
scheduledCount: recurringJobsByKey.size,
|
||||
total: listTasks().length
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enqueueTask: enqueueTask,
|
||||
enqueueTaskAndWait: function (definition) {
|
||||
return enqueueTask(definition).then(function (snapshot) {
|
||||
const task = snapshot && snapshot.id ? getTaskById(snapshot.id) : null;
|
||||
if (!task || !task.completionPromise) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
return task.completionPromise;
|
||||
});
|
||||
},
|
||||
initialize: initialize,
|
||||
setTaskHandler: setTaskHandler,
|
||||
registerRecurringTask: registerRecurringTask,
|
||||
removeRecurringTask: removeRecurringTask,
|
||||
listTasks: listTasks,
|
||||
listRecurringTasks: listRecurringTasks,
|
||||
getTaskById: getTaskById,
|
||||
getSummary: getSummary,
|
||||
cancelTask: cancelTask,
|
||||
retryTask: retryTask,
|
||||
clearFinishedTasks: clearFinishedTasks
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBackgroundTaskQueue: createBackgroundTaskQueue,
|
||||
normalizeIntervalMs: normalizeIntervalMs
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
async function refreshApiSource(pool, common, apiSourceId, apiUrl, actorId) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
let responseDetails = null;
|
||||
let pullError = '';
|
||||
|
||||
try {
|
||||
responseDetails = await common.fetchApiSourceResponse(apiUrl);
|
||||
} catch (error) {
|
||||
pullError = String(error && error.message ? error.message : 'Unable to load API response.');
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSourceId]
|
||||
);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
let updatedItems = [];
|
||||
let pullError = '';
|
||||
|
||||
try {
|
||||
updatedItems = await common.fetchRssFeedItems(feedUrl, itemLimit);
|
||||
} catch (error) {
|
||||
pullError = String(error && error.message ? error.message : 'Unable to load feed items.');
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
if (typeof common.replaceRssFeedItems === 'function') {
|
||||
await common.replaceRssFeedItems(connection, rssFeedId, updatedItems);
|
||||
}
|
||||
await connection.commit();
|
||||
|
||||
if (pullError) {
|
||||
console.error('[data-source-refresh] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError);
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
refreshApiSource: refreshApiSource,
|
||||
refreshRssFeed: refreshRssFeed
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
|
||||
function createPlayerActionService(options) {
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const common = options && options.common;
|
||||
@@ -13,11 +15,17 @@ function createPlayerActionService(options) {
|
||||
if (connectionId) {
|
||||
payload.connectionId = connectionId;
|
||||
}
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'POST',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/commands`,
|
||||
body: payload
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
@@ -35,10 +43,15 @@ function createPlayerActionService(options) {
|
||||
}
|
||||
|
||||
async function getScreenConnections(slug) {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/connections`
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('../rbac');
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac');
|
||||
|
||||
function parseCsvIds(value) {
|
||||
return String(value || '')
|
||||
@@ -1,4 +1,4 @@
|
||||
const { normalizePermissionKeys } = require('../rbac');
|
||||
const { normalizePermissionKeys } = require('../../rbac');
|
||||
|
||||
function createSessionService(options) {
|
||||
const sessionCookieName = String(options && options.sessionCookieName || '').trim();
|
||||
@@ -2,16 +2,19 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
|
||||
function normalizeUploadRoot(uploadDir) {
|
||||
return path.resolve(String(uploadDir || '').trim());
|
||||
}
|
||||
|
||||
function createUploadSyncService(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const playerSnapshotCache = options && options.playerSnapshotCache;
|
||||
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
|
||||
let playerUploadSyncMode = null;
|
||||
let playerUploadSyncModePromise = null;
|
||||
@@ -42,7 +45,7 @@ function createUploadSyncService(options) {
|
||||
|
||||
function normalizeUploadReference(uploadPath) {
|
||||
const value = String(uploadPath || '').trim();
|
||||
if (!value || !value.startsWith('/uploads/')) {
|
||||
if (!value || !value.startsWith('/media/')) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
@@ -157,16 +160,21 @@ function createUploadSyncService(options) {
|
||||
|
||||
playerUploadSyncModePromise = (async function () {
|
||||
try {
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/uploads/config`, {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: '/api/media/config'
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/config`, {
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const data = await response.json();
|
||||
const playerUploadDir = data && data.uploadDir ? normalizeUploadRoot(data.uploadDir) : null;
|
||||
const playerUploadDir = data && data.mediaDir ? normalizeUploadRoot(data.mediaDir) : null;
|
||||
if (!playerUploadDir) {
|
||||
return null;
|
||||
}
|
||||
@@ -237,10 +245,16 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/uploads/${encodeURIComponent(filename)}`, {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'PUT',
|
||||
pathname: `/api/media/${encodeURIComponent(filename)}`,
|
||||
body: fileBuffer
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream'
|
||||
'Content-Type': 'application/octet-stream',
|
||||
...authHeaders
|
||||
},
|
||||
body: fileBuffer
|
||||
});
|
||||
@@ -262,10 +276,15 @@ function createUploadSyncService(options) {
|
||||
|
||||
const filename = path.basename(uploadPath);
|
||||
try {
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/uploads/${encodeURIComponent(filename)}`, {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'DELETE',
|
||||
pathname: `/api/media/${encodeURIComponent(filename)}`
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
}
|
||||
});
|
||||
if (!response.ok && response.status !== 404) {
|
||||
@@ -298,30 +317,10 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
|
||||
async function syncExistingUploadsToPlayer(pool, localUploadDir) {
|
||||
if (!(await shouldMirrorUploads(localUploadDir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await common.fetchAdminData(pool);
|
||||
const uploadRefs = new Set();
|
||||
(data.slides || []).forEach(function (slide) {
|
||||
collectUploadReferencesFromSlide(slide).forEach(function (reference) {
|
||||
uploadRefs.add(reference);
|
||||
});
|
||||
return queueMediaSyncTask('media-sync:initial', 'Initial media sync', {
|
||||
mode: 'initial',
|
||||
uploadDir: localUploadDir
|
||||
});
|
||||
(data.templates || []).forEach(function (template) {
|
||||
collectUploadReferencesFromTemplate(template).forEach(function (reference) {
|
||||
uploadRefs.add(reference);
|
||||
});
|
||||
});
|
||||
Array.from(uploadRefs).forEach(function (uploadPath) {
|
||||
queuePlayerUploadSync({
|
||||
type: 'put',
|
||||
uploadPath: uploadPath,
|
||||
uploadDir: localUploadDir
|
||||
});
|
||||
});
|
||||
await flushPendingPlayerUploadSyncs();
|
||||
}
|
||||
|
||||
function getVisibleCurrentSlideIds() {
|
||||
@@ -436,30 +435,10 @@ function createUploadSyncService(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.previousUploadRefs.length) {
|
||||
const nextUploadRefSet = new Set(operation.nextUploadRefs);
|
||||
await removeUnusedUploadFiles(operation.pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
|
||||
return !nextUploadRefSet.has(reference);
|
||||
}));
|
||||
}
|
||||
|
||||
if (operation.nextUploadRefs.length) {
|
||||
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
|
||||
}
|
||||
|
||||
if (operation.refreshScreenSlugs.length) {
|
||||
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
|
||||
if (refreshTargets.ready.length) {
|
||||
await notifyPlayerScreens(refreshTargets.ready, 'refresh');
|
||||
}
|
||||
refreshTargets.blocked.forEach(function (screenSlug) {
|
||||
queuePlaylistUploadSync({
|
||||
key: operation.key + ':refresh:' + screenSlug,
|
||||
blockedSlideIds: operation.blockedSlideIds,
|
||||
refreshScreenSlugs: [screenSlug]
|
||||
});
|
||||
});
|
||||
}
|
||||
return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', {
|
||||
mode: 'playlist',
|
||||
operation: operation
|
||||
});
|
||||
}
|
||||
|
||||
async function flushPendingPlaylistUploadSyncs() {
|
||||
@@ -529,6 +508,96 @@ function createUploadSyncService(options) {
|
||||
return pendingPlayerUploadSyncFlushInFlight;
|
||||
}
|
||||
|
||||
async function runMediaSyncTask(payload) {
|
||||
const taskPayload = payload || {};
|
||||
const mode = String(taskPayload.mode || '').trim();
|
||||
|
||||
if (mode === 'initial') {
|
||||
const uploadDir = String(taskPayload.uploadDir || '').trim();
|
||||
if (!(await shouldMirrorUploads(uploadDir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await common.fetchAdminData(pool);
|
||||
const uploadRefs = new Set();
|
||||
(data.slides || []).forEach(function (slide) {
|
||||
collectUploadReferencesFromSlide(slide).forEach(function (reference) {
|
||||
uploadRefs.add(reference);
|
||||
});
|
||||
});
|
||||
(data.templates || []).forEach(function (template) {
|
||||
collectUploadReferencesFromTemplate(template).forEach(function (reference) {
|
||||
uploadRefs.add(reference);
|
||||
});
|
||||
});
|
||||
Array.from(uploadRefs).forEach(function (uploadPath) {
|
||||
queuePlayerUploadSync({
|
||||
type: 'put',
|
||||
uploadPath: uploadPath,
|
||||
uploadDir: uploadDir
|
||||
});
|
||||
});
|
||||
await flushPendingPlayerUploadSyncs();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'playlist') {
|
||||
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
|
||||
|
||||
if (operation.previousUploadRefs.length) {
|
||||
const nextUploadRefSet = new Set(operation.nextUploadRefs);
|
||||
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
|
||||
return !nextUploadRefSet.has(reference);
|
||||
}));
|
||||
}
|
||||
|
||||
if (operation.nextUploadRefs.length) {
|
||||
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
|
||||
}
|
||||
|
||||
if (operation.refreshScreenSlugs.length) {
|
||||
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
|
||||
if (refreshTargets.ready.length) {
|
||||
await notifyPlayerScreens(refreshTargets.ready, 'refresh');
|
||||
}
|
||||
refreshTargets.blocked.forEach(function (screenSlug) {
|
||||
queuePlaylistUploadSync({
|
||||
key: operation.key + ':refresh:' + screenSlug,
|
||||
blockedSlideIds: operation.blockedSlideIds,
|
||||
refreshScreenSlugs: [screenSlug]
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('Unknown media sync task mode.');
|
||||
}
|
||||
|
||||
async function queueMediaSyncTask(taskKey, title, payload) {
|
||||
const safePayload = Object.assign({}, payload || {});
|
||||
delete safePayload.pool;
|
||||
if (safePayload.operation && typeof safePayload.operation === 'object') {
|
||||
safePayload.operation = Object.assign({}, safePayload.operation);
|
||||
delete safePayload.operation.pool;
|
||||
}
|
||||
|
||||
const definition = {
|
||||
key: taskKey,
|
||||
title: title,
|
||||
category: 'media-sync',
|
||||
taskType: 'media-sync',
|
||||
payload: safePayload,
|
||||
persist: true
|
||||
};
|
||||
|
||||
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTaskAndWait === 'function') {
|
||||
return backgroundTaskQueue.enqueueTaskAndWait(definition);
|
||||
}
|
||||
|
||||
return runMediaSyncTask(safePayload);
|
||||
}
|
||||
|
||||
return {
|
||||
createUploadMiddleware: createUploadMiddleware,
|
||||
normalizeUploadReference: normalizeUploadReference,
|
||||
@@ -554,7 +623,9 @@ function createUploadSyncService(options) {
|
||||
schedulePendingPlaylistUploadSyncFlush: schedulePendingPlaylistUploadSyncFlush,
|
||||
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||
flushPendingPlaylistUploadSyncs: flushPendingPlaylistUploadSyncs,
|
||||
flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs
|
||||
flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs,
|
||||
runMediaSyncTask: runMediaSyncTask,
|
||||
queueMediaSyncTask: queueMediaSyncTask
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
const { renderView } = require('../view');
|
||||
|
||||
function getErrorCopy(statusCode, message) {
|
||||
const normalizedStatusCode = Number(statusCode) || 500;
|
||||
|
||||
if (normalizedStatusCode === 403) {
|
||||
return {
|
||||
title: 'Access denied',
|
||||
errorTitle: 'Access denied',
|
||||
errorMessage: message || 'You do not have permission to access this area.',
|
||||
backLabel: 'Back to dashboard'
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedStatusCode === 404) {
|
||||
return {
|
||||
title: 'Not found',
|
||||
errorTitle: 'Oops! Page not found.',
|
||||
errorMessage: message || 'We could not find the page you were looking for. Meanwhile, you may return to the dashboard or try searching for what you need.',
|
||||
backLabel: 'Back to dashboard'
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedStatusCode >= 500) {
|
||||
return {
|
||||
title: 'Something went wrong',
|
||||
errorTitle: 'Something went wrong.',
|
||||
errorMessage: message || 'An unexpected error occurred. Please try again in a moment.',
|
||||
backLabel: 'Back to dashboard'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Error',
|
||||
errorTitle: 'Error',
|
||||
errorMessage: message || 'An unexpected error occurred.',
|
||||
backLabel: 'Back to dashboard'
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function renderErrorPage(options, currentUser) {
|
||||
const errorOptions = options || {};
|
||||
const statusCode = Number(errorOptions.statusCode || 500);
|
||||
const copy = getErrorCopy(statusCode, String(errorOptions.message || '').trim());
|
||||
return renderView('error/error', {
|
||||
title: String(errorOptions.title || copy.title || 'Error').trim(),
|
||||
active: '',
|
||||
messageVariant: 'primary',
|
||||
currentUser: currentUser || null,
|
||||
statusCode: statusCode,
|
||||
errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(),
|
||||
errorMessage: String(errorOptions.message || copy.errorMessage || 'An unexpected error occurred.').trim(),
|
||||
detail: String(errorOptions.detail || '').trim(),
|
||||
backUrl: String(errorOptions.backUrl || '/dashboard').trim() || '/dashboard',
|
||||
backLabel: String(errorOptions.backLabel || copy.backLabel || 'Back to dashboard').trim() || 'Back to dashboard',
|
||||
searchUrl: String(errorOptions.searchUrl || '').trim(),
|
||||
bodyClass: 'error-page bg-dark text-white',
|
||||
errorShell: true,
|
||||
stylesheets: [],
|
||||
scripts: []
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
const path = require('path');
|
||||
|
||||
function routePath(...segments) {
|
||||
return path.join(__dirname, '..', 'routes', ...segments);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderLoginPage: require(routePath('auth', 'login')),
|
||||
renderAccountPage: require(routePath('account', 'password')),
|
||||
renderUsersPage: require(routePath('settings', 'users', 'list')),
|
||||
renderUsersAddPage: require(routePath('settings', 'users', 'add')),
|
||||
renderUsersEditPage: require(routePath('settings', 'users', 'edit')),
|
||||
renderDashboardPage: require(routePath('signage', 'dashboard', 'index')),
|
||||
renderConnectedClientsPage: require(routePath('signage', 'clients', 'list')),
|
||||
renderPlaylistsPage: require(routePath('signage', 'playlists', 'list')),
|
||||
renderPlaylistFormPage: require(routePath('signage', 'playlists', 'add')),
|
||||
renderPlaylistEditPage: require(routePath('signage', 'playlists', 'edit')),
|
||||
renderPlaylistSlideConfigPage: require(routePath('signage', 'playlists', 'slide-config')),
|
||||
renderApiSourcesPage: require(routePath('data-sources', 'api-sources', 'list')),
|
||||
renderApiSourceFormPage: require(routePath('data-sources', 'api-sources', 'add')),
|
||||
renderApiSourceEditPage: require(routePath('data-sources', 'api-sources', 'edit')),
|
||||
renderRssFeedsPage: require(routePath('data-sources', 'rss-feeds', 'list')),
|
||||
renderRssFeedFormPage: require(routePath('data-sources', 'rss-feeds', 'add')),
|
||||
renderRssFeedEditPage: require(routePath('data-sources', 'rss-feeds', 'edit')),
|
||||
renderScreensPage: require(routePath('signage', 'screens', 'list')),
|
||||
renderScreenFormPage: require(routePath('signage', 'screens', 'add')),
|
||||
renderScreenEditPage: require(routePath('signage', 'screens', 'edit')),
|
||||
renderSlidesPage: require(routePath('signage', 'slides', 'list')),
|
||||
renderSlideFormPage: require(routePath('signage', 'slides', 'form')),
|
||||
renderTemplatesPage: require(routePath('signage', 'templates', 'list')),
|
||||
renderTemplateFormPage: require(routePath('signage', 'templates', 'add')),
|
||||
renderTemplateEditPage: require(routePath('signage', 'templates', 'edit')),
|
||||
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')),
|
||||
renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')),
|
||||
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
|
||||
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')),
|
||||
renderErrorPage: require('./error'),
|
||||
renderRbacPage: require(routePath('settings', 'rbac', 'list')),
|
||||
renderRbacAddPage: require(routePath('settings', 'rbac', 'add')),
|
||||
renderRbacEditPage: require(routePath('settings', 'rbac', 'edit'))
|
||||
};
|
||||
@@ -527,6 +527,14 @@ td[data-label="Slides"] {
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-toolbar .ck-font-size-input,
|
||||
.template-field-card .ck.ck-toolbar .ck-font-size-input {
|
||||
flex: 0 0 5% !important;
|
||||
width: 5% !important;
|
||||
max-width: 5% !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.slide-form-stack .ck.ck-dropdown__panel,
|
||||
.slide-form-stack .ck.ck-list__panel,
|
||||
.slide-form-stack .ck.ck-balloon-panel,
|
||||
@@ -597,10 +605,14 @@ td[data-label="Slides"] {
|
||||
box-shadow: inset 0 0 0 1px var(--bs-primary), 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.18);
|
||||
}
|
||||
|
||||
.slide-form-stack .ck-editor__editable_inline,
|
||||
.template-field-card .ck-editor__editable_inline {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
.slide-form-stack .ck-content,
|
||||
.template-field-card .ck-content {
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@@ -624,6 +636,50 @@ td[data-label="Slides"] {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list__panel,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-balloon-panel {
|
||||
background: var(--bs-body-bg) !important;
|
||||
background-color: var(--bs-body-bg) !important;
|
||||
border-color: var(--bs-border-color) !important;
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list__item .ck-button,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list__item .ck-button__label,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button__label,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel .ck-button__label,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel .ck-list__item-text,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-dropdown__panel .ck-list__item-label,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid,
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
color: var(--bs-body-color) !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-list-item-button {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-list-item-button:hover {
|
||||
background: var(--bs-secondary-bg) !important;
|
||||
background-color: var(--bs-secondary-bg) !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-list .ck-button:hover {
|
||||
background: var(--bs-secondary-bg) !important;
|
||||
}
|
||||
|
||||
html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
border-color: var(--bs-border-color) !important;
|
||||
}
|
||||
|
||||
|
||||
.slide-preview-dimensions-chip {
|
||||
display: inline-flex;
|
||||
@@ -1035,10 +1091,6 @@ td[data-label="Slides"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.template-field-card .ck-editor__editable_inline {
|
||||
min-height: 18rem;
|
||||
}
|
||||
|
||||
.template-field-card.is-expanded .ck-editor__editable_inline {
|
||||
min-height: 32rem;
|
||||
}
|
||||
|
||||
@@ -196,6 +196,8 @@
|
||||
}
|
||||
|
||||
function initAsyncSaveForms() {
|
||||
var refreshSequence = 0;
|
||||
|
||||
function setSaveActionValue(form, value) {
|
||||
if (!form) {
|
||||
return;
|
||||
@@ -212,6 +214,123 @@
|
||||
hiddenInput.value = String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getResponseQueryValue(responseUrl, key) {
|
||||
try {
|
||||
var url = new URL(responseUrl, window.location.href);
|
||||
return String(url.searchParams.get(key) || '').trim();
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function rebindRefreshTarget(targetElement) {
|
||||
if (!targetElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.initJsonTogglePanels === 'function') {
|
||||
window.initJsonTogglePanels(targetElement);
|
||||
}
|
||||
if (typeof window.initLocalDateTimes === 'function') {
|
||||
window.initLocalDateTimes(targetElement);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
|
||||
if (sequenceId !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(window.location.href, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html, application/xhtml+xml'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to refresh saved data.');
|
||||
}
|
||||
return response.text();
|
||||
}).then(function (text) {
|
||||
if (sequenceId !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||
var currentTarget = document.querySelector(refreshTargetSelector);
|
||||
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
|
||||
if (!currentTarget || !nextTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentTarget.outerHTML = nextTarget.outerHTML;
|
||||
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
||||
}).catch(function (_error) {
|
||||
// Ignore refresh replacement failures and leave the existing content in place.
|
||||
});
|
||||
}
|
||||
|
||||
function watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, sequenceId) {
|
||||
var pollDelayMs = 1000;
|
||||
var maxAttempts = 60;
|
||||
|
||||
function poll(attempt) {
|
||||
if (sequenceId !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
var stateUrl;
|
||||
try {
|
||||
stateUrl = new URL(refreshStateUrl, window.location.href);
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
stateUrl.searchParams.set('refresh_task_id', refreshTaskId);
|
||||
|
||||
fetch(stateUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to check refresh status.');
|
||||
}
|
||||
return response.json();
|
||||
}).then(function (payload) {
|
||||
if (sequenceId !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
var status = String(payload && payload.status || '').trim().toLowerCase();
|
||||
if (status === 'queued' || status === 'running') {
|
||||
if (attempt < maxAttempts) {
|
||||
window.setTimeout(function () {
|
||||
poll(attempt + 1);
|
||||
}, pollDelayMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId);
|
||||
}).catch(function () {
|
||||
if (attempt < maxAttempts) {
|
||||
window.setTimeout(function () {
|
||||
poll(attempt + 1);
|
||||
}, pollDelayMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
poll(0);
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.closest) {
|
||||
@@ -288,9 +407,21 @@
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return response.text().then(function (text) {
|
||||
throw new Error(text || 'Unable to save changes.');
|
||||
var error = new Error(text || 'Unable to save changes.');
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
var actionUrl = '';
|
||||
try {
|
||||
actionUrl = new URL(form.action, window.location.href).pathname;
|
||||
} catch (_error) {
|
||||
actionUrl = String(form.action || '');
|
||||
}
|
||||
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
|
||||
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
|
||||
var refreshTaskId = getResponseQueryValue(response.url || '', 'refresh_task_id');
|
||||
var shouldFollowRedirect = Boolean(form.hasAttribute('data-async-save-new-url')) && !/\/\d+(?:\/|$)/.test(actionUrl);
|
||||
if (submitterValue === 'close' || submitterValue === 'new') {
|
||||
var redirectUrl = submitterValue === 'close'
|
||||
? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href)
|
||||
@@ -298,21 +429,45 @@
|
||||
window.location.replace(redirectUrl);
|
||||
return;
|
||||
}
|
||||
if (shouldFollowRedirect && response.url) {
|
||||
clearFormDirty(form);
|
||||
window.location.replace(response.url);
|
||||
return;
|
||||
}
|
||||
clearFormDirty(form);
|
||||
return response.text().then(function (text) {
|
||||
var savedMessage = '';
|
||||
var responseDocument = null;
|
||||
try {
|
||||
var doc = new DOMParser().parseFromString(text || '', 'text/html');
|
||||
var toastBody = doc.querySelector('.toast-body');
|
||||
responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||
var toastBody = responseDocument.querySelector('.toast-body');
|
||||
if (toastBody && toastBody.textContent) {
|
||||
savedMessage = toastBody.textContent.trim();
|
||||
}
|
||||
} catch (_error) {
|
||||
savedMessage = '';
|
||||
}
|
||||
|
||||
if (refreshTargetSelector && refreshStateUrl && refreshTaskId) {
|
||||
refreshSequence += 1;
|
||||
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
|
||||
} else if (refreshTargetSelector && responseDocument) {
|
||||
var currentTarget = document.querySelector(refreshTargetSelector);
|
||||
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
|
||||
if (currentTarget && nextTarget) {
|
||||
currentTarget.outerHTML = nextTarget.outerHTML;
|
||||
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
||||
}
|
||||
}
|
||||
|
||||
showToast(savedMessage || 'Saved.', 'success');
|
||||
});
|
||||
}).catch(function (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
||||
showToast(error.message || 'Unable to save changes.', variant);
|
||||
return;
|
||||
}
|
||||
window.alert(error.message || 'Unable to save changes.');
|
||||
}).finally(function () {
|
||||
delete form.dataset.busy;
|
||||
@@ -336,6 +491,92 @@
|
||||
});
|
||||
}
|
||||
|
||||
function initJsonTogglePanels(root) {
|
||||
var scope = root && root.querySelectorAll ? root : document;
|
||||
var panels = scope.querySelectorAll('[data-json-toggle-panel]');
|
||||
Array.prototype.forEach.call(panels, function (panel) {
|
||||
var output = panel.querySelector('[data-json-toggle-output]');
|
||||
if (!output) {
|
||||
return;
|
||||
}
|
||||
|
||||
var card = panel.closest ? panel.closest('.card') : null;
|
||||
var button = card ? card.querySelector('[data-json-toggle]') : null;
|
||||
var label = button ? button.querySelector('[data-json-toggle-label]') : null;
|
||||
var sourceNode = panel.querySelector('[data-json-toggle-source]');
|
||||
var rawJson = '';
|
||||
try {
|
||||
rawJson = JSON.parse(String(sourceNode ? sourceNode.textContent : '""'));
|
||||
} catch (_error) {
|
||||
rawJson = '';
|
||||
}
|
||||
|
||||
if (typeof rawJson !== 'string' || !rawJson.trim()) {
|
||||
if (button) {
|
||||
button.classList.add('d-none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var parsedJson;
|
||||
try {
|
||||
parsedJson = JSON.parse(rawJson);
|
||||
} catch (_error) {
|
||||
if (button) {
|
||||
button.classList.add('d-none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var compactJson = JSON.stringify(parsedJson);
|
||||
var formattedJson = JSON.stringify(parsedJson, null, 2);
|
||||
var isFormatted = true;
|
||||
|
||||
function syncButtonLabel() {
|
||||
if (!button || !label) {
|
||||
return;
|
||||
}
|
||||
label.textContent = isFormatted
|
||||
? String(button.getAttribute('data-json-toggle-label-compact') || 'Unformat JSON')
|
||||
: String(button.getAttribute('data-json-toggle-label-formatted') || 'Format JSON');
|
||||
button.setAttribute('aria-pressed', isFormatted ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function syncOutput() {
|
||||
output.textContent = isFormatted ? formattedJson : compactJson;
|
||||
syncButtonLabel();
|
||||
}
|
||||
|
||||
output.textContent = formattedJson;
|
||||
syncButtonLabel();
|
||||
|
||||
if (button) {
|
||||
button.addEventListener('click', function () {
|
||||
isFormatted = !isFormatted;
|
||||
syncOutput();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initLocalDateTimes(root) {
|
||||
var scope = root && root.querySelectorAll ? root : document;
|
||||
var elements = scope.querySelectorAll('[data-local-datetime]');
|
||||
Array.prototype.forEach.call(elements, function (element) {
|
||||
var rawValue = String(element.getAttribute('datetime') || element.getAttribute('data-local-datetime') || element.textContent || '').trim();
|
||||
if (!rawValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
var date = new Date(rawValue);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.textContent = formatDashboardDate(date);
|
||||
});
|
||||
}
|
||||
|
||||
if (window.initSortableTables) {
|
||||
window.initSortableTables();
|
||||
}
|
||||
@@ -346,4 +587,8 @@
|
||||
initAsyncCommandForms();
|
||||
initAsyncSaveForms();
|
||||
initSubmitOnChange();
|
||||
initJsonTogglePanels();
|
||||
initLocalDateTimes();
|
||||
window.initJsonTogglePanels = initJsonTogglePanels;
|
||||
window.initLocalDateTimes = initLocalDateTimes;
|
||||
}());
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
(function () {
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatDashboardDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
var date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getClientRowKey(client) {
|
||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
||||
}
|
||||
|
||||
function getClientDisplayName(client) {
|
||||
if (client && client.client_name) {
|
||||
return String(client.client_name).trim();
|
||||
}
|
||||
var clientId = String(client && client.clientId ? client.clientId : '').trim();
|
||||
if (clientId) {
|
||||
return clientId;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function setButtonVariant(button, classesToRemove, classToAdd) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.classList) {
|
||||
classesToRemove.forEach(function (className) {
|
||||
button.classList.remove(className);
|
||||
});
|
||||
if (classToAdd) {
|
||||
button.classList.add(classToAdd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var className = String(button.className || '');
|
||||
classesToRemove.forEach(function (removeClass) {
|
||||
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
|
||||
});
|
||||
if (classToAdd) {
|
||||
className += ' ' + classToAdd;
|
||||
}
|
||||
button.className = className.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeDisplayIp(value) {
|
||||
var ip = String(value || '').trim();
|
||||
if (!ip) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
|
||||
return ip.slice(7).trim();
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
function initConfirmForms() {
|
||||
document.addEventListener('submit', function (event) {
|
||||
var form = event.target;
|
||||
if (!form || !form.getAttribute) {
|
||||
return;
|
||||
}
|
||||
if (form.hasAttribute && form.hasAttribute('data-async-command')) {
|
||||
return;
|
||||
}
|
||||
var message = form.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function markFormDirty(form) {
|
||||
if (!form || !form.hasAttribute || form.hasAttribute('data-clean-on-load')) {
|
||||
return;
|
||||
}
|
||||
form.dataset.dirty = 'true';
|
||||
}
|
||||
|
||||
function clearFormDirty(form) {
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
form.dataset.dirty = 'false';
|
||||
}
|
||||
|
||||
function isFormDirty(form) {
|
||||
return Boolean(form && form.dataset && form.dataset.dirty === 'true');
|
||||
}
|
||||
|
||||
function initDirtyTracking() {
|
||||
document.addEventListener('input', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.form) {
|
||||
return;
|
||||
}
|
||||
markFormDirty(target.form);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.form) {
|
||||
return;
|
||||
}
|
||||
markFormDirty(target.form);
|
||||
}, true);
|
||||
}
|
||||
|
||||
function initCancelConfirm() {
|
||||
document.addEventListener('click', function (event) {
|
||||
var cancelTarget = event.target.closest('[data-confirm-unsaved]');
|
||||
if (!cancelTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
var form = cancelTarget.form || cancelTarget.closest('form') || document.querySelector('form[data-dirty="true"]');
|
||||
if (!isFormDirty(form)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var message = cancelTarget.getAttribute('data-confirm-unsaved') || 'You have unsaved changes. Leave this page?';
|
||||
if (!window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
function initAsyncCommandForms() {
|
||||
document.addEventListener('submit', function (event) {
|
||||
var form = event.target;
|
||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-command')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.dataset && form.dataset.busy === 'true') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
var message = form.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
form.dataset.busy = 'true';
|
||||
|
||||
var formData = new FormData(form);
|
||||
var body = new URLSearchParams();
|
||||
formData.forEach(function (value, key) {
|
||||
body.append(key, value);
|
||||
});
|
||||
|
||||
fetch(form.action, {
|
||||
method: (form.method || 'POST').toUpperCase(),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
body: body.toString(),
|
||||
credentials: 'same-origin'
|
||||
}).finally(function () {
|
||||
delete form.dataset.busy;
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
|
||||
function initAsyncSaveForms() {
|
||||
var refreshSequence = 0;
|
||||
|
||||
function setSaveActionValue(form, value) {
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]');
|
||||
if (!hiddenInput) {
|
||||
hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
hiddenInput.name = 'save_action';
|
||||
form.appendChild(hiddenInput);
|
||||
}
|
||||
|
||||
hiddenInput.value = String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getResponseQueryValue(responseUrl, key) {
|
||||
try {
|
||||
var url = new URL(responseUrl, window.location.href);
|
||||
return String(url.searchParams.get(key) || '').trim();
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function rebindRefreshTarget(targetElement) {
|
||||
if (!targetElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.initJsonTogglePanels === 'function') {
|
||||
window.initJsonTogglePanels(targetElement);
|
||||
}
|
||||
if (typeof window.initLocalDateTimes === 'function') {
|
||||
window.initLocalDateTimes(targetElement);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
|
||||
if (sequenceId !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(window.location.href, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html, application/xhtml+xml'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to refresh saved data.');
|
||||
}
|
||||
return response.text();
|
||||
}).then(function (text) {
|
||||
if (sequenceId !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||
var currentTarget = document.querySelector(refreshTargetSelector);
|
||||
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
|
||||
if (!currentTarget || !nextTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentTarget.outerHTML = nextTarget.outerHTML;
|
||||
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
||||
}).catch(function (_error) {
|
||||
// Ignore refresh replacement failures and leave the existing content in place.
|
||||
});
|
||||
}
|
||||
|
||||
function watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, sequenceId) {
|
||||
var pollDelayMs = 1000;
|
||||
var maxAttempts = 60;
|
||||
|
||||
function poll(attempt) {
|
||||
if (sequenceId !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
var stateUrl;
|
||||
try {
|
||||
stateUrl = new URL(refreshStateUrl, window.location.href);
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
stateUrl.searchParams.set('refresh_task_id', refreshTaskId);
|
||||
|
||||
fetch(stateUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to check refresh status.');
|
||||
}
|
||||
return response.json();
|
||||
}).then(function (payload) {
|
||||
if (sequenceId !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
var status = String(payload && payload.status || '').trim().toLowerCase();
|
||||
if (status === 'queued' || status === 'running') {
|
||||
if (attempt < maxAttempts) {
|
||||
window.setTimeout(function () {
|
||||
poll(attempt + 1);
|
||||
}, pollDelayMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId);
|
||||
}).catch(function () {
|
||||
if (attempt < maxAttempts) {
|
||||
window.setTimeout(function () {
|
||||
poll(attempt + 1);
|
||||
}, pollDelayMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
poll(0);
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.closest) {
|
||||
return;
|
||||
}
|
||||
|
||||
var button = target.closest('button[name="save_action"]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
var form = button.form || button.closest('form');
|
||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaveActionValue(form, button.value || '');
|
||||
form.dataset.submitterValue = String(button.value || '').trim().toLowerCase();
|
||||
}, true);
|
||||
|
||||
document.addEventListener('submit', function (event) {
|
||||
var form = event.target;
|
||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.dataset && form.dataset.busy === 'true') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
var message = form.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
form.dataset.busy = 'true';
|
||||
|
||||
var hiddenSaveAction = form.querySelector('input[type="hidden"][name="save_action"]');
|
||||
var formData = new FormData(form);
|
||||
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
|
||||
if (event.submitter && event.submitter.name) {
|
||||
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
|
||||
formData.set(event.submitter.name, event.submitter.value || '');
|
||||
}
|
||||
var hasFileValue = false;
|
||||
formData.forEach(function (value) {
|
||||
if (value && typeof value === 'object' && typeof value.name === 'string') {
|
||||
hasFileValue = true;
|
||||
}
|
||||
});
|
||||
|
||||
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
|
||||
var body = isMultipart ? formData : new URLSearchParams();
|
||||
|
||||
if (!isMultipart) {
|
||||
formData.forEach(function (value, key) {
|
||||
body.append(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
fetch(form.action, {
|
||||
method: (form.method || 'POST').toUpperCase(),
|
||||
headers: Object.assign({
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html, application/json, text/plain, */*'
|
||||
}, isMultipart ? {} : {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||
}),
|
||||
body: isMultipart ? body : body.toString(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return response.text().then(function (text) {
|
||||
var error = new Error(text || 'Unable to save changes.');
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
var actionUrl = '';
|
||||
try {
|
||||
actionUrl = new URL(form.action, window.location.href).pathname;
|
||||
} catch (_error) {
|
||||
actionUrl = String(form.action || '');
|
||||
}
|
||||
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
|
||||
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
|
||||
var refreshTaskId = getResponseQueryValue(response.url || '', 'refresh_task_id');
|
||||
var shouldFollowRedirect = Boolean(form.hasAttribute('data-async-save-new-url')) && !/\/\d+(?:\/|$)/.test(actionUrl);
|
||||
if (submitterValue === 'close' || submitterValue === 'new') {
|
||||
var redirectUrl = submitterValue === 'close'
|
||||
? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href)
|
||||
: String(form.dataset.asyncSaveNewUrl || response.url || window.location.href);
|
||||
window.location.replace(redirectUrl);
|
||||
return;
|
||||
}
|
||||
if (shouldFollowRedirect && response.url) {
|
||||
clearFormDirty(form);
|
||||
window.location.replace(response.url);
|
||||
return;
|
||||
}
|
||||
clearFormDirty(form);
|
||||
return response.text().then(function (text) {
|
||||
var savedMessage = '';
|
||||
var responseDocument = null;
|
||||
try {
|
||||
responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||
var toastBody = responseDocument.querySelector('.toast-body');
|
||||
if (toastBody && toastBody.textContent) {
|
||||
savedMessage = toastBody.textContent.trim();
|
||||
}
|
||||
} catch (_error) {
|
||||
savedMessage = '';
|
||||
}
|
||||
|
||||
if (refreshTargetSelector && refreshStateUrl && refreshTaskId) {
|
||||
refreshSequence += 1;
|
||||
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
|
||||
} else if (refreshTargetSelector && responseDocument) {
|
||||
var currentTarget = document.querySelector(refreshTargetSelector);
|
||||
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
|
||||
if (currentTarget && nextTarget) {
|
||||
currentTarget.outerHTML = nextTarget.outerHTML;
|
||||
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
||||
}
|
||||
}
|
||||
|
||||
showToast(savedMessage || 'Saved.', 'success');
|
||||
});
|
||||
}).catch(function (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
||||
showToast(error.message || 'Unable to save changes.', variant);
|
||||
return;
|
||||
}
|
||||
window.alert(error.message || 'Unable to save changes.');
|
||||
}).finally(function () {
|
||||
delete form.dataset.busy;
|
||||
delete form.dataset.submitterValue;
|
||||
if (hiddenSaveAction) {
|
||||
hiddenSaveAction.value = '';
|
||||
}
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
|
||||
function initSubmitOnChange() {
|
||||
var fields = document.querySelectorAll('[data-submit-on-change]');
|
||||
Array.prototype.forEach.call(fields, function (field) {
|
||||
field.addEventListener('change', function () {
|
||||
var form = field.form || field.closest('form');
|
||||
if (form) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initJsonTogglePanels(root) {
|
||||
var scope = root && root.querySelectorAll ? root : document;
|
||||
var panels = scope.querySelectorAll('[data-json-toggle-panel]');
|
||||
Array.prototype.forEach.call(panels, function (panel) {
|
||||
var output = panel.querySelector('[data-json-toggle-output]');
|
||||
if (!output) {
|
||||
return;
|
||||
}
|
||||
|
||||
var card = panel.closest ? panel.closest('.card') : null;
|
||||
var button = card ? card.querySelector('[data-json-toggle]') : null;
|
||||
var label = button ? button.querySelector('[data-json-toggle-label]') : null;
|
||||
var sourceNode = panel.querySelector('[data-json-toggle-source]');
|
||||
var rawJson = '';
|
||||
try {
|
||||
rawJson = JSON.parse(String(sourceNode ? sourceNode.textContent : '""'));
|
||||
} catch (_error) {
|
||||
rawJson = '';
|
||||
}
|
||||
|
||||
if (typeof rawJson !== 'string' || !rawJson.trim()) {
|
||||
if (button) {
|
||||
button.classList.add('d-none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var parsedJson;
|
||||
try {
|
||||
parsedJson = JSON.parse(rawJson);
|
||||
} catch (_error) {
|
||||
if (button) {
|
||||
button.classList.add('d-none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var compactJson = JSON.stringify(parsedJson);
|
||||
var formattedJson = JSON.stringify(parsedJson, null, 2);
|
||||
var isFormatted = true;
|
||||
|
||||
function syncButtonLabel() {
|
||||
if (!button || !label) {
|
||||
return;
|
||||
}
|
||||
label.textContent = isFormatted
|
||||
? String(button.getAttribute('data-json-toggle-label-compact') || 'Unformat JSON')
|
||||
: String(button.getAttribute('data-json-toggle-label-formatted') || 'Format JSON');
|
||||
button.setAttribute('aria-pressed', isFormatted ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function syncOutput() {
|
||||
output.textContent = isFormatted ? formattedJson : compactJson;
|
||||
syncButtonLabel();
|
||||
}
|
||||
|
||||
output.textContent = formattedJson;
|
||||
syncButtonLabel();
|
||||
|
||||
if (button) {
|
||||
button.addEventListener('click', function () {
|
||||
isFormatted = !isFormatted;
|
||||
syncOutput();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initLocalDateTimes(root) {
|
||||
var scope = root && root.querySelectorAll ? root : document;
|
||||
var elements = scope.querySelectorAll('[data-local-datetime]');
|
||||
Array.prototype.forEach.call(elements, function (element) {
|
||||
var rawValue = String(element.getAttribute('datetime') || element.getAttribute('data-local-datetime') || element.textContent || '').trim();
|
||||
if (!rawValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
var date = new Date(rawValue);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.textContent = formatDashboardDate(date);
|
||||
});
|
||||
}
|
||||
|
||||
if (window.initSortableTables) {
|
||||
window.initSortableTables();
|
||||
}
|
||||
|
||||
initConfirmForms();
|
||||
initDirtyTracking();
|
||||
initCancelConfirm();
|
||||
initAsyncCommandForms();
|
||||
initAsyncSaveForms();
|
||||
initSubmitOnChange();
|
||||
initJsonTogglePanels();
|
||||
initLocalDateTimes();
|
||||
window.initJsonTogglePanels = initJsonTogglePanels;
|
||||
window.initLocalDateTimes = initLocalDateTimes;
|
||||
}());
|
||||
@@ -0,0 +1,111 @@
|
||||
(function () {
|
||||
function getGroupCheckboxes(group) {
|
||||
return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]'));
|
||||
}
|
||||
|
||||
function getPermissionKey(checkbox) {
|
||||
return String((checkbox && (checkbox.getAttribute('data-permission-key') || checkbox.value)) || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getActionKey(checkbox) {
|
||||
var permissionKey = getPermissionKey(checkbox);
|
||||
var parts = permissionKey.split('.');
|
||||
if (parts.length !== 2) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(parts[1] || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function syncPermissionGroup(group) {
|
||||
var checkboxes = getGroupCheckboxes(group);
|
||||
if (!checkboxes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var readCheckbox = null;
|
||||
var nonReadChecked = false;
|
||||
|
||||
checkboxes.forEach(function (checkbox) {
|
||||
if (getActionKey(checkbox) === 'read') {
|
||||
readCheckbox = checkbox;
|
||||
return;
|
||||
}
|
||||
if (checkbox.checked) {
|
||||
nonReadChecked = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!readCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!readCheckbox.checked && nonReadChecked) {
|
||||
readCheckbox.checked = true;
|
||||
}
|
||||
|
||||
if (!readCheckbox.checked) {
|
||||
checkboxes.forEach(function (checkbox) {
|
||||
if (getActionKey(checkbox) !== 'read') {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleGroupChange(event) {
|
||||
var checkbox = event.target && event.target.matches ? event.target : null;
|
||||
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
|
||||
return;
|
||||
}
|
||||
|
||||
var actionKey = getActionKey(checkbox);
|
||||
var group = checkbox.closest('.accordion-item');
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
|
||||
var checkboxes = getGroupCheckboxes(group);
|
||||
var readCheckbox = checkboxes.find(function (candidate) {
|
||||
return getActionKey(candidate) === 'read';
|
||||
}) || null;
|
||||
|
||||
if (!readCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionKey === 'read' && !checkbox.checked) {
|
||||
checkboxes.forEach(function (candidate) {
|
||||
if (candidate !== checkbox) {
|
||||
candidate.checked = false;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionKey !== 'read' && checkbox.checked) {
|
||||
readCheckbox.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
function initPermissionGroups() {
|
||||
document.querySelectorAll('.accordion-item').forEach(function (group) {
|
||||
syncPermissionGroup(group);
|
||||
});
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
var checkbox = event.target;
|
||||
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
|
||||
return;
|
||||
}
|
||||
if (String(checkbox.getAttribute('name') || '') !== 'permission_keys[]') {
|
||||
return;
|
||||
}
|
||||
|
||||
handleGroupChange(event);
|
||||
syncPermissionGroup(checkbox.closest('.accordion-item'));
|
||||
});
|
||||
}
|
||||
|
||||
initPermissionGroups();
|
||||
}());
|
||||
@@ -0,0 +1,102 @@
|
||||
(function () {
|
||||
function updateSidebarStatus(status, label) {
|
||||
var dot = document.getElementById('sidebar-status-dot');
|
||||
var text = document.getElementById('sidebar-status-text');
|
||||
var pill = document.getElementById('sidebar-status-pill');
|
||||
if (!dot && !text && !pill) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedStatus = status === 'online' || status === 'offline' || status === 'unknown'
|
||||
? status
|
||||
: (status ? 'online' : 'offline');
|
||||
var statusLabel = normalizedStatus === 'online'
|
||||
? (label || 'Connected to player feed')
|
||||
: normalizedStatus === 'offline'
|
||||
? 'Disconnected from player feed'
|
||||
: (label || 'Connecting to player feed');
|
||||
var pillLabel = normalizedStatus === 'online' ? 'Connected' : normalizedStatus === 'offline' ? 'Disconnected' : 'Connecting';
|
||||
|
||||
dot.classList.remove('status-dot--unknown', 'status-dot--online', 'status-dot--offline');
|
||||
dot.classList.add(normalizedStatus === 'online' ? 'status-dot--online' : normalizedStatus === 'offline' ? 'status-dot--offline' : 'status-dot--unknown');
|
||||
dot.setAttribute('aria-label', statusLabel);
|
||||
dot.setAttribute('title', statusLabel);
|
||||
|
||||
if (text) {
|
||||
text.textContent = statusLabel;
|
||||
}
|
||||
|
||||
if (pill) {
|
||||
pill.classList.remove('status-pill--unknown', 'status-pill--online', 'status-pill--offline');
|
||||
pill.classList.add(normalizedStatus === 'online' ? 'status-pill--online' : normalizedStatus === 'offline' ? 'status-pill--offline' : 'status-pill--unknown');
|
||||
pill.textContent = pillLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function connectDashboardSocket() {
|
||||
if (!window.WebSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
var hasStatusIndicators = document.getElementById('sidebar-status-dot') || document.getElementById('sidebar-status-text') || document.getElementById('sidebar-status-pill');
|
||||
if (!hasStatusIndicators) {
|
||||
return;
|
||||
}
|
||||
|
||||
var socket = null;
|
||||
var reconnectTimer = null;
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) {
|
||||
return;
|
||||
}
|
||||
reconnectTimer = window.setTimeout(function () {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/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') {
|
||||
if (typeof window.webHandleDashboardState === 'function') {
|
||||
window.webHandleDashboardState(payload.state);
|
||||
}
|
||||
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed');
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore malformed dashboard payloads.
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = function () {
|
||||
updateSidebarStatus('offline');
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = function () {
|
||||
updateSidebarStatus('offline');
|
||||
try {
|
||||
socket.close();
|
||||
} catch (_error) {
|
||||
// ignore close errors
|
||||
}
|
||||
};
|
||||
|
||||
socket.onopen = function () {
|
||||
updateSidebarStatus('unknown', 'Connecting to player feed');
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
}
|
||||
|
||||
connectDashboardSocket();
|
||||
}());
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
(function () {
|
||||
var THEME_STORAGE_KEY = 'web-theme';
|
||||
var CKEDITOR_THEME_STYLE_ID = 'ckeditor-dark-theme-overrides';
|
||||
|
||||
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 getOrCreateCkeditorThemeStyleElement() {
|
||||
var styleElement = document.getElementById(CKEDITOR_THEME_STYLE_ID);
|
||||
|
||||
if (styleElement) {
|
||||
return styleElement;
|
||||
}
|
||||
|
||||
styleElement = document.createElement('style');
|
||||
styleElement.id = CKEDITOR_THEME_STYLE_ID;
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
return styleElement;
|
||||
}
|
||||
|
||||
function syncCkeditorTheme(theme) {
|
||||
var styleElement = getOrCreateCkeditorThemeStyleElement();
|
||||
|
||||
if (theme !== 'dark') {
|
||||
styleElement.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
styleElement.textContent = [
|
||||
'.ck-body-wrapper .ck.ck-dropdown__panel,',
|
||||
'.ck-body-wrapper .ck.ck-list__panel,',
|
||||
'.ck-body-wrapper .ck.ck-list,',
|
||||
'.ck-body-wrapper .ck.ck-balloon-panel {',
|
||||
' background: var(--bs-body-bg) !important;',
|
||||
' background-color: var(--bs-body-bg) !important;',
|
||||
' border-color: var(--bs-border-color) !important;',
|
||||
' color: var(--bs-body-color) !important;',
|
||||
'}',
|
||||
'.ck-body-wrapper .ck.ck-list .ck-list-item-button {',
|
||||
' background: transparent !important;',
|
||||
' background-color: transparent !important;',
|
||||
' color: var(--bs-body-color) !important;',
|
||||
'}',
|
||||
'.ck-body-wrapper .ck.ck-list .ck-list-item-button:hover {',
|
||||
' background: var(--bs-secondary-bg) !important;',
|
||||
' background-color: var(--bs-secondary-bg) !important;',
|
||||
'}',
|
||||
'.ck-body-wrapper .ck.ck-color-grid,',
|
||||
'.ck-body-wrapper .ck.ck-color-grid__tile {',
|
||||
' color: var(--bs-body-color) !important;',
|
||||
'}',
|
||||
'.ck-body-wrapper .ck.ck-color-grid__tile {',
|
||||
' border-color: var(--bs-border-color) !important;',
|
||||
'}'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
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.bsTheme = normalizedTheme;
|
||||
document.documentElement.style.colorScheme = normalizedTheme;
|
||||
syncCkeditorTheme(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.bsTheme === 'dark' ? 'light' : 'dark';
|
||||
setStoredTheme(nextTheme);
|
||||
applyTheme(nextTheme);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initSidebarToggle() {
|
||||
var body = document.body;
|
||||
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]'));
|
||||
var backdrop = document.querySelector('[data-sidebar-backdrop]');
|
||||
|
||||
if (!toggleButtons.length || !backdrop) {
|
||||
return;
|
||||
}
|
||||
|
||||
function setSidebarOpen(isOpen) {
|
||||
body.classList.toggle('is-sidebar-open', isOpen);
|
||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
||||
toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
setSidebarOpen(!body.classList.contains('is-sidebar-open'));
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
||||
toggleButton.addEventListener('click', function () {
|
||||
toggleSidebar();
|
||||
});
|
||||
});
|
||||
|
||||
backdrop.addEventListener('click', function () {
|
||||
setSidebarOpen(false);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape') {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.getPreferredTheme = getPreferredTheme;
|
||||
window.getStoredTheme = getStoredTheme;
|
||||
window.setStoredTheme = setStoredTheme;
|
||||
window.applyTheme = applyTheme;
|
||||
window.initThemeToggle = initThemeToggle;
|
||||
window.initSidebarToggle = initSidebarToggle;
|
||||
|
||||
initThemeToggle();
|
||||
initSidebarToggle();
|
||||
}());
|
||||
@@ -0,0 +1,134 @@
|
||||
(function () {
|
||||
function getBootstrapToast(toast) {
|
||||
if (!toast || !window.bootstrap || !window.bootstrap.Toast) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.bootstrap.Toast.getOrCreateInstance(toast, {
|
||||
autohide: true,
|
||||
delay: 4000
|
||||
});
|
||||
}
|
||||
|
||||
function removeToast(toast) {
|
||||
if (toast && toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}
|
||||
|
||||
function dismissToast(toast) {
|
||||
var instance = getBootstrapToast(toast);
|
||||
if (instance) {
|
||||
instance.hide();
|
||||
return;
|
||||
}
|
||||
removeToast(toast);
|
||||
}
|
||||
|
||||
function setToastVariant(toast, variant) {
|
||||
if (!toast || !toast.classList) {
|
||||
return;
|
||||
}
|
||||
|
||||
var nextVariant = String(variant || 'info').trim().toLowerCase();
|
||||
var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'];
|
||||
variants.forEach(function (value) {
|
||||
toast.classList.remove('text-bg-' + value);
|
||||
});
|
||||
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant));
|
||||
}
|
||||
|
||||
function getMessageVariant(message, fallbackVariant) {
|
||||
var text = String(message || '').trim();
|
||||
if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
|
||||
return 'danger';
|
||||
}
|
||||
if (/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i.test(text)) {
|
||||
return 'warning';
|
||||
}
|
||||
return String(fallbackVariant || 'info').trim().toLowerCase() || 'info';
|
||||
}
|
||||
|
||||
function showToast(message, variant) {
|
||||
var text = String(message || '').trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
var container = document.getElementById('app-toast-container');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
var existingToast = document.getElementById('app-toast');
|
||||
if (existingToast) {
|
||||
var existingBody = existingToast.querySelector('.toast-body');
|
||||
if (existingBody) {
|
||||
existingBody.textContent = text;
|
||||
}
|
||||
var nextVariant = getMessageVariant(text, variant);
|
||||
existingToast.setAttribute('data-toast-variant', nextVariant);
|
||||
setToastVariant(existingToast, nextVariant);
|
||||
var existingInstance = getBootstrapToast(existingToast);
|
||||
if (existingInstance) {
|
||||
existingInstance.show();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'toast align-items-center border-0';
|
||||
toast.id = 'app-toast';
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
toast.setAttribute('aria-atomic', 'true');
|
||||
toast.setAttribute('data-bs-autohide', 'true');
|
||||
toast.setAttribute('data-bs-delay', '4000');
|
||||
toast.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>';
|
||||
var toastVariant = getMessageVariant(text, variant);
|
||||
toast.setAttribute('data-toast-variant', toastVariant);
|
||||
setToastVariant(toast, toastVariant);
|
||||
toast.querySelector('.toast-body').textContent = text;
|
||||
|
||||
toast.addEventListener('hidden.bs.toast', function () {
|
||||
removeToast(toast);
|
||||
});
|
||||
|
||||
container.appendChild(toast);
|
||||
var instance = getBootstrapToast(toast);
|
||||
if (instance) {
|
||||
instance.show();
|
||||
}
|
||||
}
|
||||
|
||||
function initToast() {
|
||||
var toast = document.getElementById('app-toast');
|
||||
if (!toast) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var url = new URL(window.location.href);
|
||||
if (url.searchParams.has('message')) {
|
||||
url.searchParams.delete('message');
|
||||
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore URL cleanup failures
|
||||
}
|
||||
|
||||
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'info');
|
||||
setToastVariant(toast, existingVariant);
|
||||
|
||||
var instance = getBootstrapToast(toast);
|
||||
if (instance) {
|
||||
instance.show();
|
||||
}
|
||||
}
|
||||
|
||||
window.dismissToast = dismissToast;
|
||||
window.showToast = showToast;
|
||||
window.initToast = initToast;
|
||||
|
||||
initToast();
|
||||
}());
|
||||
@@ -91,7 +91,7 @@
|
||||
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
|
||||
var blackoutCommandValue = blackout ? 'false' : 'true';
|
||||
|
||||
return '<div class="actions justify-content-end"><form method="post" action="/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi bi-' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi bi-' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
|
||||
return '<div class="actions justify-content-end"><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi bi-' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi bi-' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
|
||||
}
|
||||
|
||||
function updateClientActionCell(cell, client) {
|
||||
@@ -119,7 +119,7 @@
|
||||
if (connectionInput) {
|
||||
connectionInput.value = client.id || '';
|
||||
}
|
||||
pauseForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
pauseForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
|
||||
var reloadButton = cell.querySelector('button[data-action="reload"]');
|
||||
@@ -132,7 +132,7 @@
|
||||
if (reloadInput) {
|
||||
reloadInput.value = client.id || '';
|
||||
}
|
||||
reloadForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
reloadForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
reloadForm.setAttribute('data-confirm-message', 'Reloading will restart the player page. Continue?');
|
||||
}
|
||||
}
|
||||
@@ -162,7 +162,7 @@
|
||||
if (blackoutConnectionInput) {
|
||||
blackoutConnectionInput.value = client.id || '';
|
||||
}
|
||||
blackoutForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
blackoutForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
|
||||
var previousButton = cell.querySelector('button[data-action="previous"]');
|
||||
@@ -182,7 +182,7 @@
|
||||
if (previousConnectionInput) {
|
||||
previousConnectionInput.value = client.id || '';
|
||||
}
|
||||
previousForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
previousForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
|
||||
var nextButton = cell.querySelector('button[data-action="next"]');
|
||||
@@ -202,7 +202,7 @@
|
||||
if (nextConnectionInput) {
|
||||
nextConnectionInput.value = client.id || '';
|
||||
}
|
||||
nextForm.action = '/admin/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
nextForm.action = '/clients/' + encodeURIComponent(client.screen_slug) + '/commands';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,7 +416,7 @@
|
||||
body.append('deviceId', String(deviceId || '').trim());
|
||||
body.append('clientName', String(clientName || '').trim());
|
||||
|
||||
return fetch('/admin/clients/' + encodeURIComponent(String(screenSlug || '').trim()) + '/commands', {
|
||||
return fetch('/clients/' + encodeURIComponent(String(screenSlug || '').trim()) + '/commands', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
@@ -495,4 +495,4 @@
|
||||
window.webHandleDashboardState = handleDashboardState;
|
||||
|
||||
initClientRenameHandler();
|
||||
}());
|
||||
}());
|
||||
|
||||
@@ -553,7 +553,7 @@
|
||||
'</td>' +
|
||||
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required form="playlist-edit-form" /></td>' +
|
||||
'<td><div class="actions playlist-item-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/admin/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
|
||||
'</div></td>';
|
||||
return row;
|
||||
@@ -655,3 +655,4 @@
|
||||
initPlaylistScheduleForm();
|
||||
initPlaylistEditStaging();
|
||||
}());
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
(function () {
|
||||
var REFRESH_INTERVAL_MS = 10000;
|
||||
var pathname = window.location.pathname.replace(/\/$/, '');
|
||||
var stateNode = document.getElementById('background-tasks-state');
|
||||
var currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : '';
|
||||
|
||||
if (pathname !== '/settings/background-tasks') {
|
||||
return;
|
||||
}
|
||||
|
||||
function stripMessageParameter() {
|
||||
try {
|
||||
var url = new URL(window.location.href);
|
||||
if (!url.searchParams.has('message')) {
|
||||
return;
|
||||
}
|
||||
url.searchParams.delete('message');
|
||||
window.history.replaceState({}, document.title, url.pathname + (url.search ? url.search : '') + url.hash);
|
||||
} catch (_error) {
|
||||
// Ignore URL cleanup failures.
|
||||
}
|
||||
}
|
||||
|
||||
function refreshPage() {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/settings/background-tasks/state', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
return response.json();
|
||||
}).then(function (payload) {
|
||||
if (!payload || !payload.version) {
|
||||
return;
|
||||
}
|
||||
|
||||
var nextVersion = String(payload.version || '');
|
||||
if (!currentVersion) {
|
||||
currentVersion = nextVersion;
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextVersion !== currentVersion) {
|
||||
window.location.reload();
|
||||
}
|
||||
}).catch(function (_error) {
|
||||
// Ignore refresh probe errors and try again on the next interval.
|
||||
});
|
||||
}
|
||||
|
||||
stripMessageParameter();
|
||||
window.setInterval(refreshPage, REFRESH_INTERVAL_MS);
|
||||
}());
|
||||
+183
-33
@@ -3,6 +3,7 @@ import {
|
||||
BlockQuote,
|
||||
Bold,
|
||||
ClassicEditor,
|
||||
Base64UploadAdapter,
|
||||
ClipboardPipeline,
|
||||
Essentials,
|
||||
FontBackgroundColor,
|
||||
@@ -11,6 +12,20 @@ import {
|
||||
FontSize,
|
||||
Heading,
|
||||
HorizontalLine,
|
||||
Image,
|
||||
ImageBlockEditing,
|
||||
ImageCaption,
|
||||
ImageInlineEditing,
|
||||
ImageInsert,
|
||||
ImageInsertUI,
|
||||
ImageInsertViaUrl,
|
||||
ImageInsertViaUrlUI,
|
||||
ImageResize,
|
||||
ImageResizeEditing,
|
||||
ImageResizeHandles,
|
||||
ImageStyle,
|
||||
ImageTextAlternative,
|
||||
ImageUpload,
|
||||
Indent,
|
||||
IndentBlock,
|
||||
Italic,
|
||||
@@ -25,6 +40,8 @@ import {
|
||||
import { createTemplateSelectorLockController } from '/assets/js/slides/slide-form-template-lock.js';
|
||||
|
||||
(function () {
|
||||
var DEFAULT_FONT_SIZE = 32;
|
||||
|
||||
var dataElement = document.getElementById('slide-editor-data');
|
||||
if (!dataElement) {
|
||||
return;
|
||||
@@ -94,7 +111,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
function getCurrentFontSizeValue() {
|
||||
var value = fontSizeCommand.value;
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
return String(DEFAULT_FONT_SIZE);
|
||||
}
|
||||
|
||||
var parsed = Math.round(Number(String(value).replace(/[^0-9.]/g, '')));
|
||||
@@ -118,6 +135,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
view.element.inputMode = 'numeric';
|
||||
view.element.autocomplete = 'off';
|
||||
view.element.spellcheck = false;
|
||||
view.element.style.width = '3.5em';
|
||||
syncValue();
|
||||
});
|
||||
|
||||
@@ -170,6 +188,10 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
var clipboardPipeline = editor.plugins.get(ClipboardPipeline);
|
||||
|
||||
this.listenTo(clipboardPipeline, 'inputTransformation', function (_event, data) {
|
||||
if (clipboardContainsImageContent(data.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var plainText = data.dataTransfer.getData('text/plain');
|
||||
data.content = editor.data.processor.toView(plainTextToHtml(plainText));
|
||||
}, { priority: 'high' });
|
||||
@@ -211,6 +233,22 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return paragraphs.length ? paragraphs.join('') : '<p></p>';
|
||||
}
|
||||
|
||||
function clipboardContainsImageContent(dataTransfer) {
|
||||
if (!dataTransfer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var files = Array.from(dataTransfer.files || []);
|
||||
if (files.some(function (file) {
|
||||
return file && String(file.type || '').indexOf('image/') === 0;
|
||||
})) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var html = dataTransfer.getData('text/html') || '';
|
||||
return /<img\b|<figure\b[^>]*\bclass=["'][^"']*\bimage\b/i.test(html);
|
||||
}
|
||||
|
||||
function getTemplateById(id) {
|
||||
return templates.find(function (item) { return Number(item.id) === Number(id); }) || null;
|
||||
}
|
||||
@@ -240,30 +278,108 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
}
|
||||
|
||||
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\S]*?)(\/?)>$/i);
|
||||
if (!match) {
|
||||
return '';
|
||||
var container = document.createElement('div');
|
||||
container.innerHTML = String(html || '');
|
||||
return sanitizePreviewNode(container);
|
||||
}
|
||||
|
||||
function sanitizePreviewNode(node) {
|
||||
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
var output = '';
|
||||
|
||||
Array.from(node.childNodes || []).forEach(function (child) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
output += escapeHtml(child.textContent);
|
||||
return;
|
||||
}
|
||||
var closing = Boolean(match[1]);
|
||||
var name = String(match[2] || '').toLowerCase();
|
||||
var attrText = String(match[3] || '');
|
||||
var selfClosing = Boolean(match[4]) || name === 'br' || name === 'hr';
|
||||
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
|
||||
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) {
|
||||
return;
|
||||
}
|
||||
|
||||
var name = String(child.tagName || '').toLowerCase();
|
||||
if (allowed.indexOf(name) === -1) {
|
||||
return '';
|
||||
return;
|
||||
}
|
||||
if (closing) {
|
||||
return '</' + name + '>';
|
||||
|
||||
if (name === 'br' || name === 'hr') {
|
||||
output += '<' + name + '>';
|
||||
return;
|
||||
}
|
||||
if (selfClosing) {
|
||||
return '<' + name + sanitizeTagAttributes(name, attrText) + '>';
|
||||
|
||||
var attrs = sanitizePreviewElementAttributes(child, name);
|
||||
if (name === 'img') {
|
||||
output += '<img' + attrs + '>';
|
||||
return;
|
||||
}
|
||||
return '<' + name + sanitizeTagAttributes(name, attrText) + '>';
|
||||
|
||||
output += '<' + name + attrs + '>' + sanitizePreviewNode(child) + '</' + name + '>';
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function sanitizePreviewElementAttributes(element, tagName) {
|
||||
var allowedAttributes = {
|
||||
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
|
||||
blockquote: ['class', 'style'],
|
||||
div: ['class', 'style'],
|
||||
figure: ['class', 'style'],
|
||||
figcaption: ['class', 'style'],
|
||||
h1: ['class', 'style'],
|
||||
h2: ['class', 'style'],
|
||||
h3: ['class', 'style'],
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
img: ['alt', 'class', 'decoding', 'height', 'loading', 'src', 'style', 'title', 'width'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
pre: ['class', 'style'],
|
||||
span: ['class', 'style'],
|
||||
table: ['class', 'style'],
|
||||
td: ['class', 'style', 'colspan', 'rowspan'],
|
||||
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
|
||||
tr: ['class', 'style'],
|
||||
ul: ['class', 'style']
|
||||
};
|
||||
var allowed = allowedAttributes[tagName] || [];
|
||||
var attrs = [];
|
||||
|
||||
Array.from(element.attributes || []).forEach(function (attribute) {
|
||||
var lowerKey = String(attribute.name || '').toLowerCase();
|
||||
if (allowed.indexOf(lowerKey) === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
var value = String(attribute.value || '');
|
||||
if (tagName === 'img' && lowerKey === 'src') {
|
||||
value = sanitizeImageSrc(value);
|
||||
}
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(value)) {
|
||||
return;
|
||||
}
|
||||
if (tagName === 'img' && lowerKey === 'src' && !value) {
|
||||
return;
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(value)) {
|
||||
return;
|
||||
}
|
||||
if (lowerKey === 'target') {
|
||||
var targetValue = value.trim();
|
||||
if (targetValue === '_blank') {
|
||||
attrs.push(' target="_blank"');
|
||||
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
|
||||
attrs.push(' rel="noreferrer noopener"');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
|
||||
});
|
||||
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
function sanitizeTagAttributes(tagName, attrText) {
|
||||
@@ -279,6 +395,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
h4: ['class', 'style'],
|
||||
h5: ['class', 'style'],
|
||||
h6: ['class', 'style'],
|
||||
img: ['alt', 'class', 'decoding', 'height', 'loading', 'src', 'style', 'title', 'width'],
|
||||
li: ['class', 'style'],
|
||||
ol: ['class', 'style', 'start'],
|
||||
p: ['class', 'style'],
|
||||
@@ -302,9 +419,15 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return '';
|
||||
}
|
||||
var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
|
||||
if (tagName === 'img' && lowerKey === 'src') {
|
||||
value = sanitizeImageSrc(value);
|
||||
}
|
||||
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
if (tagName === 'img' && lowerKey === 'src' && !value) {
|
||||
return '';
|
||||
}
|
||||
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
|
||||
return '';
|
||||
}
|
||||
@@ -325,6 +448,23 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return attrs.join('');
|
||||
}
|
||||
|
||||
function sanitizeImageSrc(value) {
|
||||
var raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (/^(?:https?:|\/|\.\.?\/|\/\/)/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
if (/^data:image\/(?:png|jpe?g|gif|webp|bmp);base64,/i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
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 : '');
|
||||
@@ -442,7 +582,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
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_size: Math.max(8, Number(current.font_size || DEFAULT_FONT_SIZE)),
|
||||
font_color: String(current.font_color || region.font_color || '#000000').trim() || '#000000'
|
||||
};
|
||||
}
|
||||
@@ -869,8 +1009,6 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
'undo',
|
||||
'redo',
|
||||
'|',
|
||||
'heading',
|
||||
'|',
|
||||
'fontSizeInput',
|
||||
'fontFamily',
|
||||
'fontColor',
|
||||
@@ -889,12 +1027,20 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
'alignment',
|
||||
'|',
|
||||
'outdent',
|
||||
'indent'
|
||||
'indent',
|
||||
'|',
|
||||
'imageInsert',
|
||||
'imageInsertViaUrl',
|
||||
'imageStyle:block',
|
||||
'imageStyle:inline',
|
||||
'imageTextAlternative',
|
||||
'imageResize'
|
||||
],
|
||||
shouldNotGroupWhenFull: false
|
||||
},
|
||||
plugins: [
|
||||
Alignment,
|
||||
Base64UploadAdapter,
|
||||
BlockQuote,
|
||||
Bold,
|
||||
Essentials,
|
||||
@@ -904,11 +1050,24 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
FontFamily,
|
||||
FontSize,
|
||||
FontSizeInputUI,
|
||||
Heading,
|
||||
HorizontalLine,
|
||||
Indent,
|
||||
IndentBlock,
|
||||
Italic,
|
||||
Image,
|
||||
ImageBlockEditing,
|
||||
ImageCaption,
|
||||
ImageInlineEditing,
|
||||
ImageInsert,
|
||||
ImageInsertUI,
|
||||
ImageInsertViaUrl,
|
||||
ImageInsertViaUrlUI,
|
||||
ImageResize,
|
||||
ImageResizeEditing,
|
||||
ImageResizeHandles,
|
||||
ImageStyle,
|
||||
ImageTextAlternative,
|
||||
ImageUpload,
|
||||
Paragraph,
|
||||
Strikethrough,
|
||||
Subscript,
|
||||
@@ -922,15 +1081,6 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
options: [20, 24, 28, 32, 36, 40, 44],
|
||||
supportAllValues: true
|
||||
},
|
||||
heading: {
|
||||
options: [
|
||||
{ model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
|
||||
{ model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
|
||||
{ model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' },
|
||||
{ model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' },
|
||||
{ model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ck-heading_heading4' }
|
||||
]
|
||||
},
|
||||
initialData: normalizeEditorData((source && source.value) || (hidden ? hidden.value : ''))
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
import { createTemplateSelectorLockController } from '/assets/js/slides/slide-form-template-lock.js';
|
||||
|
||||
(function () {
|
||||
var DEFAULT_FONT_SIZE = 32;
|
||||
|
||||
var dataElement = document.getElementById('slide-editor-data');
|
||||
if (!dataElement) {
|
||||
return;
|
||||
@@ -38,6 +40,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
}
|
||||
|
||||
var templates = Array.isArray(slideEditorData.templates) ? slideEditorData.templates : [];
|
||||
var rssFeeds = Array.isArray(slideEditorData.rssFeeds) ? slideEditorData.rssFeeds : [];
|
||||
var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : [];
|
||||
var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null;
|
||||
var existingContent = slideEditorData.existingContent || {};
|
||||
var templateSelect = document.getElementById('template-select');
|
||||
@@ -94,7 +98,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
function getCurrentFontSizeValue() {
|
||||
var value = fontSizeCommand.value;
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
return String(DEFAULT_FONT_SIZE);
|
||||
}
|
||||
|
||||
var parsed = Math.round(Number(String(value).replace(/[^0-9.]/g, '')));
|
||||
@@ -118,6 +122,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
view.element.inputMode = 'numeric';
|
||||
view.element.autocomplete = 'off';
|
||||
view.element.spellcheck = false;
|
||||
view.element.style.width = '3.5em';
|
||||
syncValue();
|
||||
});
|
||||
|
||||
@@ -406,6 +411,15 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return '<iframe class="slide-preview-webpage-frame" src="' + escapeHtml(src) + '" title="Webpage preview" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>';
|
||||
}
|
||||
|
||||
function renderPreviewRtmpRegion(value, disableAudio) {
|
||||
var src = String(value || '').trim();
|
||||
var label = src ? 'RTMP' : 'RTMP stream';
|
||||
if (disableAudio) {
|
||||
label += ' (muted)';
|
||||
}
|
||||
return '<div class="slide-preview-placeholder">' + label + '</div>';
|
||||
}
|
||||
|
||||
function renderPreviewHtmlRegion(value) {
|
||||
var html = String(value || '').trim();
|
||||
if (!html) {
|
||||
@@ -434,19 +448,286 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
}
|
||||
|
||||
function normalizeFontSizeValue(value) {
|
||||
var size = Math.max(1, Math.round(Number(String(value || '').replace(/[^0-9.]/g, ''))));
|
||||
return size ? String(size) : '';
|
||||
var raw = String(value || '').trim().toLowerCase();
|
||||
if (/^\d+(?:\.\d+)?px$/.test(raw)) {
|
||||
return String(Math.max(1, Math.round(Number(raw.replace(/px$/, '')))));
|
||||
}
|
||||
if (/^\d+(?:\.\d+)?$/.test(raw)) {
|
||||
return String(Math.max(1, Math.round(Number(raw))));
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function syncEditorFontSizeHidden(regionId, editor) {
|
||||
var fontSizeHidden = getEditorFontSizeHiddenInput(regionId);
|
||||
if (!fontSizeHidden || !editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
var fontSizeCommand = editor.commands.get('fontSize');
|
||||
fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand && fontSizeCommand.value) || String(DEFAULT_FONT_SIZE);
|
||||
}
|
||||
|
||||
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_size: Math.max(8, Number(current.font_size || DEFAULT_FONT_SIZE)),
|
||||
font_color: String(current.font_color || region.font_color || '#000000').trim() || '#000000'
|
||||
};
|
||||
}
|
||||
|
||||
function getRssFeedById(feedId) {
|
||||
var normalizedId = Number(feedId || 0);
|
||||
return rssFeeds.find(function (feed) {
|
||||
return Number(feed.id) === normalizedId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getRssFieldList(feedId) {
|
||||
var feed = getRssFeedById(feedId);
|
||||
var sampleItem = feed && Array.isArray(feed.items) ? feed.items[0] : null;
|
||||
var sampleJson = sampleItem && sampleItem.itemJson && typeof sampleItem.itemJson === 'object' ? sampleItem.itemJson : null;
|
||||
|
||||
function walkFields(value, prefix, output) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
if (key === 'rawXml') {
|
||||
return;
|
||||
}
|
||||
|
||||
var nextPath = prefix ? prefix + '.' + key : key;
|
||||
var nextValue = value[key];
|
||||
if (nextValue && typeof nextValue === 'object' && !Array.isArray(nextValue)) {
|
||||
walkFields(nextValue, nextPath, output);
|
||||
return;
|
||||
}
|
||||
|
||||
if (output.indexOf(nextPath) === -1) {
|
||||
output.push(nextPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var fields = [];
|
||||
walkFields(sampleJson, '', fields);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function updatePlaceholderChipList(card, fieldList, emptyLabel) {
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
var container = card.querySelector('[data-placeholder-chips]');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
var chips = Array.isArray(fieldList) ? fieldList.map(function (field) {
|
||||
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('') : '';
|
||||
|
||||
container.innerHTML = chips || '<span class="muted slide-image-file">' + escapeHtml(emptyLabel) + '</span>';
|
||||
}
|
||||
|
||||
function updateRssPlaceholderChips(regionId, feedId) {
|
||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
updatePlaceholderChipList(card, getRssFieldList(feedId), 'No RSS fields available.');
|
||||
}
|
||||
|
||||
function getCurrentRssConfig(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var parsedItemNumber = Math.max(1, Number(current.item_number || 1));
|
||||
return {
|
||||
feed_id: current.feed_id === undefined || current.feed_id === null || current.feed_id === '' ? '' : Number(current.feed_id),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
value: current.value !== undefined ? current.value : ''
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRssPath(value, path) {
|
||||
var current = value;
|
||||
if (!path) {
|
||||
return current;
|
||||
}
|
||||
String(path).split('.').forEach(function (segment) {
|
||||
if (current === undefined || current === null) {
|
||||
current = '';
|
||||
return;
|
||||
}
|
||||
current = current[segment];
|
||||
});
|
||||
return current === undefined || current === null ? '' : current;
|
||||
}
|
||||
|
||||
function normalizeRssVariableName(value) {
|
||||
var next = String(value || '').trim();
|
||||
return next || 'item';
|
||||
}
|
||||
|
||||
function substituteRssVariables(html, item) {
|
||||
var source = String(html || '');
|
||||
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName;
|
||||
return escapeHtml(resolveRssPath(item, key || ''));
|
||||
});
|
||||
}
|
||||
|
||||
function getRssItem(region, rssConfig) {
|
||||
var feed = getRssFeedById(rssConfig.feed_id);
|
||||
var items = feed && Array.isArray(feed.items) ? feed.items : [];
|
||||
var parsedItemNumber = Math.max(1, Number(rssConfig.item_number || 1));
|
||||
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
|
||||
return items[index] || null;
|
||||
}
|
||||
|
||||
function getRssPreviewFallback(item) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '<div class="slide-preview-placeholder">RSS item</div>';
|
||||
}
|
||||
|
||||
var title = String(item.title || '').trim();
|
||||
var description = String(item.description || '').trim();
|
||||
var summary = [];
|
||||
if (title) {
|
||||
summary.push('<h3>' + escapeHtml(title) + '</h3>');
|
||||
}
|
||||
if (description) {
|
||||
summary.push('<p>' + sanitizePreviewHtml(description) + '</p>');
|
||||
}
|
||||
if (!summary.length) {
|
||||
return '<div class="slide-preview-placeholder">RSS item</div>';
|
||||
}
|
||||
return summary.join('');
|
||||
}
|
||||
|
||||
function getApiSourceById(sourceId) {
|
||||
var normalizedId = Number(sourceId || 0);
|
||||
return apiSources.find(function (source) {
|
||||
return Number(source.id) === normalizedId;
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getApiSourceItems(sourceId) {
|
||||
var source = getApiSourceById(sourceId);
|
||||
var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null;
|
||||
if (Array.isArray(responseJson)) {
|
||||
return responseJson;
|
||||
}
|
||||
if (responseJson && Array.isArray(responseJson.items)) {
|
||||
return responseJson.items;
|
||||
}
|
||||
if (responseJson && Array.isArray(responseJson.results)) {
|
||||
return responseJson.results;
|
||||
}
|
||||
if (responseJson && Array.isArray(responseJson.data)) {
|
||||
return responseJson.data;
|
||||
}
|
||||
return responseJson ? [responseJson] : [];
|
||||
}
|
||||
|
||||
function getApiFieldList(sourceId) {
|
||||
var sampleItem = getApiSourceItems(sourceId)[0] || null;
|
||||
|
||||
function walkFields(value, prefix, output) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
var nextPath = prefix ? prefix + '.' + key : key;
|
||||
var nextValue = value[key];
|
||||
if (nextValue && typeof nextValue === 'object' && !Array.isArray(nextValue)) {
|
||||
walkFields(nextValue, nextPath, output);
|
||||
return;
|
||||
}
|
||||
|
||||
if (output.indexOf(nextPath) === -1) {
|
||||
output.push(nextPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var fields = [];
|
||||
walkFields(sampleItem && typeof sampleItem === 'object' ? sampleItem : null, '', fields);
|
||||
return fields;
|
||||
}
|
||||
|
||||
function updateApiPlaceholderChips(regionId, sourceId) {
|
||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
updatePlaceholderChipList(card, getApiFieldList(sourceId), 'No JSON fields available.');
|
||||
}
|
||||
|
||||
function getCurrentApiConfig(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var parsedItemNumber = Math.max(1, Number(current.item_number || 1));
|
||||
return {
|
||||
source_id: current.source_id === undefined || current.source_id === null || current.source_id === '' ? '' : Number(current.source_id),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
value: current.value !== undefined ? current.value : ''
|
||||
};
|
||||
}
|
||||
|
||||
function resolveApiPath(value, path) {
|
||||
var current = value;
|
||||
if (!path) {
|
||||
return current;
|
||||
}
|
||||
String(path).split('.').forEach(function (segment) {
|
||||
if (current === undefined || current === null) {
|
||||
current = '';
|
||||
return;
|
||||
}
|
||||
current = current[segment];
|
||||
});
|
||||
return current === undefined || current === null ? '' : current;
|
||||
}
|
||||
|
||||
function substituteApiVariables(html, item) {
|
||||
var source = String(html || '');
|
||||
return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
var key = tokenName === 'item' && tokenPath ? tokenPath : tokenName;
|
||||
return escapeHtml(resolveApiPath(item, key || ''));
|
||||
});
|
||||
}
|
||||
|
||||
function getApiItem(sourceId, itemNumber) {
|
||||
var items = getApiSourceItems(sourceId);
|
||||
var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
|
||||
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
|
||||
return items[index] || null;
|
||||
}
|
||||
|
||||
function getApiPreviewFallback(item) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '<div class="slide-preview-placeholder">API item</div>';
|
||||
}
|
||||
|
||||
var title = String(item.title || item.name || '').trim();
|
||||
var description = String(item.description || item.summary || item.text || '').trim();
|
||||
var summary = [];
|
||||
if (title) {
|
||||
summary.push('<h3>' + escapeHtml(title) + '</h3>');
|
||||
}
|
||||
if (description) {
|
||||
summary.push('<p>' + sanitizePreviewHtml(description) + '</p>');
|
||||
}
|
||||
if (!summary.length) {
|
||||
return '<div class="slide-preview-placeholder">API item</div>';
|
||||
}
|
||||
return summary.join('');
|
||||
}
|
||||
|
||||
function getTextStyleFromCard(_card, region) {
|
||||
return getCurrentTextStyle(region);
|
||||
}
|
||||
@@ -724,10 +1005,25 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
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 rtmpInput = card && card.querySelector('input[type="url"][name="region_rtmp_' + region.id + '"]');
|
||||
var disableAudioInput = card && card.querySelector('input[type="checkbox"][name="region_disable_audio_' + region.id + '"]');
|
||||
var rssFeedInput = card && card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]');
|
||||
var rssItemInput = card && card.querySelector('input[name="region_rss_item_number_' + region.id + '"]');
|
||||
var apiSourceInput = card && card.querySelector('select[name="region_api_source_id_' + region.id + '"]');
|
||||
var apiItemInput = card && card.querySelector('input[name="region_api_item_number_' + region.id + '"]');
|
||||
var existingRtmp = existingContent[region.region_key] || {};
|
||||
var existingRss = existingContent[region.region_key] || {};
|
||||
var existingApi = existingContent[region.region_key] || {};
|
||||
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 === 'rtmp'
|
||||
? (rtmpInput ? rtmpInput.value : '')
|
||||
: region.region_type === 'rss'
|
||||
? (hiddenInput ? hiddenInput.value : '')
|
||||
: region.region_type === 'api'
|
||||
? (hiddenInput ? hiddenInput.value : '')
|
||||
: region.region_type === 'html'
|
||||
? (htmlInput ? htmlInput.value : '')
|
||||
: (hiddenInput ? hiddenInput.value : '');
|
||||
@@ -735,6 +1031,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
? null
|
||||
: region.region_type === 'webpage'
|
||||
? null
|
||||
: region.region_type === 'rtmp'
|
||||
? null
|
||||
: region.region_type === 'html'
|
||||
? null
|
||||
: getTextStyleFromCard(card, region);
|
||||
@@ -742,6 +1040,23 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
? (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 === 'rtmp'
|
||||
? renderPreviewRtmpRegion(value, disableAudioInput ? disableAudioInput.checked : (existingRtmp.disable_audio === undefined ? true : Boolean(existingRtmp.disable_audio)))
|
||||
: region.region_type === 'rss'
|
||||
? (function () {
|
||||
var item = getRssItem(region, {
|
||||
feed_id: rssFeedInput ? rssFeedInput.value : existingRss.feed_id,
|
||||
item_number: rssItemInput ? rssItemInput.value : existingRss.item_number
|
||||
});
|
||||
var rendered = substituteRssVariables(value, item);
|
||||
return renderPreviewTextRegion(region, rendered ? rendered : getRssPreviewFallback(item), style, scale);
|
||||
}())
|
||||
: region.region_type === 'api'
|
||||
? (function () {
|
||||
var item = getApiItem(apiSourceInput ? apiSourceInput.value : existingApi.source_id, apiItemInput ? apiItemInput.value : existingApi.item_number);
|
||||
var rendered = substituteApiVariables(value, item);
|
||||
return renderPreviewTextRegion(region, rendered ? rendered : getApiPreviewFallback(item), style, scale);
|
||||
}())
|
||||
: region.region_type === 'html'
|
||||
? renderPreviewHtmlRegion(value)
|
||||
: renderPreviewTextRegion(region, value, style, scale);
|
||||
@@ -749,6 +1064,12 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
? ' slide-preview-image-region'
|
||||
: region.region_type === 'webpage'
|
||||
? ' slide-preview-webpage-region'
|
||||
: region.region_type === 'rtmp'
|
||||
? ' slide-preview-rtmp-region'
|
||||
: region.region_type === 'rss'
|
||||
? ' slide-preview-rss-region'
|
||||
: region.region_type === 'api'
|
||||
? ' slide-preview-api-region'
|
||||
: region.region_type === 'html'
|
||||
? ' slide-preview-html-region'
|
||||
: ' slide-preview-text-region';
|
||||
@@ -779,6 +1100,96 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderRssRegion(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var config = getCurrentRssConfig(region);
|
||||
var fieldList = getRssFieldList(config.feed_id);
|
||||
var placeholderChips = fieldList.map(function (field) {
|
||||
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('');
|
||||
var feedOptions = rssFeeds.map(function (feed) {
|
||||
var selected = Number(feed.id) === Number(config.feed_id) ? ' selected' : '';
|
||||
return '<option value="' + escapeHtml(feed.id) + '"' + selected + '>' + escapeHtml(feed.name || ('Feed ' + feed.id)) + '</option>';
|
||||
}).join('');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<div class="template-field-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-secondary template-editor-toggle" data-toggle-editor-height="' + region.id + '">Expand</button>' +
|
||||
'<span class="chip">RSS</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid gap-3">' +
|
||||
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
||||
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
||||
'</div>' +
|
||||
'<div class="row g-3 align-items-end">' +
|
||||
'<div class="col-12 col-md-8">' +
|
||||
'<label class="form-label" for="region_rss_feed_id_' + region.id + '">RSS feed</label>' +
|
||||
'<select name="region_rss_feed_id_' + region.id + '" class="form-select">' +
|
||||
'<option value="">Select a feed</option>' +
|
||||
feedOptions +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<div class="col-6 col-md-4">' +
|
||||
'<label class="form-label" for="region_rss_item_number_' + region.id + '">Entry number</label>' +
|
||||
'<input type="number" min="1" step="1" name="region_rss_item_number_' + region.id + '" class="form-control" value="' + escapeHtml(config.item_number) + '" />' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(getCurrentTextStyle(region).font_size) + '" />' +
|
||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
||||
'<div class="muted slide-image-file">Use placeholders like {{title}} or {{link}}. Available placeholders:</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No RSS fields available.</span>') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderApiRegion(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var config = getCurrentApiConfig(region);
|
||||
var fieldList = getApiFieldList(config.source_id);
|
||||
var placeholderChips = fieldList.map(function (field) {
|
||||
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('');
|
||||
var sourceOptions = apiSources.map(function (source) {
|
||||
var selected = Number(source.id) === Number(config.source_id) ? ' selected' : '';
|
||||
return '<option value="' + escapeHtml(source.id) + '"' + selected + '>' + escapeHtml(source.name || ('Source ' + source.id)) + '</option>';
|
||||
}).join('');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<div class="template-field-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-secondary template-editor-toggle" data-toggle-editor-height="' + region.id + '">Expand</button>' +
|
||||
'<span class="chip">API</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid gap-3">' +
|
||||
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
||||
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
||||
'</div>' +
|
||||
'<div class="row g-3 align-items-end">' +
|
||||
'<div class="col-12 col-md-8">' +
|
||||
'<label class="form-label" for="region_api_source_id_' + region.id + '">API source</label>' +
|
||||
'<select name="region_api_source_id_' + region.id + '" class="form-select">' +
|
||||
'<option value="">Select a source</option>' +
|
||||
sourceOptions +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
'<div class="col-6 col-md-4">' +
|
||||
'<label class="form-label" for="region_api_item_number_' + region.id + '">Item number</label>' +
|
||||
'<input type="number" min="1" step="1" name="region_api_item_number_' + region.id + '" class="form-control" value="' + escapeHtml(config.item_number) + '" />' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(getCurrentTextStyle(region).font_size) + '" />' +
|
||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
||||
'<div class="muted slide-image-file">Use placeholders like {{title}} or {{description}}. Available placeholders:</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderWebpageRegion(region) {
|
||||
var current = getCurrentRegionValue(region);
|
||||
return '' +
|
||||
@@ -796,6 +1207,29 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderRtmpRegion(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var url = String(current.value || '').trim();
|
||||
var disableAudio = current.disable_audio === undefined ? true : Boolean(current.disable_audio);
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">RTMP</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid gap-3">' +
|
||||
'<label style="display:block;">RTMP URL' +
|
||||
'<input type="url" name="region_rtmp_' + region.id + '" class="form-control" value="' + escapeHtml(url) + '" placeholder="rtmp://example.com/live/stream" />' +
|
||||
'</label>' +
|
||||
'<label class="form-check m-0">' +
|
||||
'<input class="form-check-input" type="checkbox" name="region_disable_audio_' + region.id + '" value="1"' + (disableAudio ? ' checked' : '') + ' />' +
|
||||
'<span class="form-check-label">Disable audio</span>' +
|
||||
'</label>' +
|
||||
'<div class="muted slide-image-file">Player controls stay hidden and the stream is not interactive.</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderHtmlRegion(region) {
|
||||
var current = getCurrentRegionValue(region);
|
||||
return '' +
|
||||
@@ -851,6 +1285,15 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
if (region.region_type === 'webpage') {
|
||||
return renderWebpageRegion(region);
|
||||
}
|
||||
if (region.region_type === 'rtmp') {
|
||||
return renderRtmpRegion(region);
|
||||
}
|
||||
if (region.region_type === 'rss') {
|
||||
return renderRssRegion(region);
|
||||
}
|
||||
if (region.region_type === 'api') {
|
||||
return renderApiRegion(region);
|
||||
}
|
||||
if (region.region_type === 'html') {
|
||||
return renderHtmlRegion(region);
|
||||
}
|
||||
@@ -869,8 +1312,6 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
'undo',
|
||||
'redo',
|
||||
'|',
|
||||
'heading',
|
||||
'|',
|
||||
'fontSizeInput',
|
||||
'fontFamily',
|
||||
'fontColor',
|
||||
@@ -904,7 +1345,6 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
FontFamily,
|
||||
FontSize,
|
||||
FontSizeInputUI,
|
||||
Heading,
|
||||
HorizontalLine,
|
||||
Indent,
|
||||
IndentBlock,
|
||||
@@ -922,29 +1362,19 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
options: [20, 24, 28, 32, 36, 40, 44],
|
||||
supportAllValues: true
|
||||
},
|
||||
heading: {
|
||||
options: [
|
||||
{ model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
|
||||
{ model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
|
||||
{ model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' },
|
||||
{ model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' },
|
||||
{ model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ck-heading_heading4' }
|
||||
]
|
||||
},
|
||||
initialData: normalizeEditorData((source && source.value) || (hidden ? hidden.value : ''))
|
||||
};
|
||||
|
||||
ClassicEditor.create(source || holder, editorConfig).then(function (editor) {
|
||||
editorInstances.set(regionId, editor);
|
||||
var fontSizeHidden = getEditorFontSizeHiddenInput(regionId);
|
||||
var fontSizeCommand = editor.commands.get('fontSize');
|
||||
if (hidden) {
|
||||
hidden.value = editor.getData();
|
||||
}
|
||||
if (fontSizeHidden && fontSizeCommand) {
|
||||
fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand.value) || fontSizeHidden.value;
|
||||
syncEditorFontSizeHidden(regionId, editor);
|
||||
if (fontSizeCommand) {
|
||||
fontSizeCommand.on('change:value', function () {
|
||||
fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand.value) || fontSizeHidden.value;
|
||||
syncEditorFontSizeHidden(regionId, editor);
|
||||
});
|
||||
}
|
||||
editor.model.document.on('change:data', function () {
|
||||
@@ -990,6 +1420,42 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
requestPreviewRender();
|
||||
});
|
||||
});
|
||||
templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_item_number_"]').forEach(function (input) {
|
||||
input.addEventListener('input', function () {
|
||||
var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, '');
|
||||
if (input.name.indexOf('region_rss_') === 0) {
|
||||
var rssFeed = input.name.indexOf('region_rss_feed_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]') || {}).value;
|
||||
updateRssPlaceholderChips(regionId, rssFeed);
|
||||
}
|
||||
if (input.name.indexOf('region_api_') === 0) {
|
||||
var apiSource = input.name.indexOf('region_api_source_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]') || {}).value;
|
||||
updateApiPlaceholderChips(regionId, apiSource);
|
||||
}
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
});
|
||||
input.addEventListener('change', function () {
|
||||
var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, '');
|
||||
if (input.name.indexOf('region_rss_') === 0) {
|
||||
var rssFeed = input.name.indexOf('region_rss_feed_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]') || {}).value;
|
||||
updateRssPlaceholderChips(regionId, rssFeed);
|
||||
}
|
||||
if (input.name.indexOf('region_api_') === 0) {
|
||||
var apiSource = input.name.indexOf('region_api_source_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]') || {}).value;
|
||||
updateApiPlaceholderChips(regionId, apiSource);
|
||||
}
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
});
|
||||
});
|
||||
|
||||
if (existingTemplateId) {
|
||||
templateSelectorLock.arm();
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
return;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(function () {
|
||||
templateSelectorLock.arm();
|
||||
});
|
||||
@@ -1001,6 +1467,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
var slideFormUrl = new URL(slideForm.action, window.location.href);
|
||||
var isCreateForm = slideFormUrl.pathname === '/slides';
|
||||
var submitterValue = event.submitter && event.submitter.name === 'save_action'
|
||||
? String(event.submitter.value || '').trim().toLowerCase()
|
||||
: '';
|
||||
@@ -1011,6 +1479,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
if (!editor || !hidden) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
syncEditorFontSizeHidden(regionId, editor);
|
||||
hidden.value = editor.getData();
|
||||
return Promise.resolve();
|
||||
});
|
||||
@@ -1043,6 +1512,20 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCreateForm) {
|
||||
try {
|
||||
if (response.url) {
|
||||
window.location.replace(response.url);
|
||||
return;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Fall through and show a toast if the redirect URL cannot be resolved.
|
||||
}
|
||||
slideForm.dataset.dirty = 'false';
|
||||
showToast('Saved slide.', 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
var responseText = await response.text();
|
||||
var savedMessage = '';
|
||||
try {
|
||||
@@ -1057,6 +1540,13 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
|
||||
showToast(savedMessage || 'Saved slide.', 'success');
|
||||
slideForm.dataset.dirty = 'false';
|
||||
} catch (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
||||
showToast(error && error.message ? error.message : 'Unable to save slide.', variant);
|
||||
} else {
|
||||
window.alert(error && error.message ? error.message : 'Unable to save slide.');
|
||||
}
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
@@ -1105,3 +1595,4 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
function connect() {
|
||||
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/admin/dashboard');
|
||||
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/dashboard');
|
||||
updateSidebarStatus('unknown', 'Connecting to player feed');
|
||||
|
||||
socket.onmessage = function (event) {
|
||||
@@ -98,4 +98,4 @@
|
||||
}
|
||||
|
||||
connectDashboardSocket();
|
||||
}());
|
||||
}());
|
||||
|
||||
@@ -230,7 +230,7 @@
|
||||
}
|
||||
|
||||
function getRegionChipLabel(regionType) {
|
||||
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : 'Text';
|
||||
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rss' ? 'RSS' : 'Text';
|
||||
}
|
||||
|
||||
function populateRegionCard(card, region) {
|
||||
|
||||
@@ -12,6 +12,62 @@
|
||||
return value === undefined || value === null || value === '' ? fallback : value;
|
||||
}
|
||||
|
||||
function normalizeLockRatio(value) {
|
||||
var raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (!/^\d+\s*:\s*\d+$/.test(raw)) {
|
||||
return '';
|
||||
}
|
||||
return raw.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function parseLockRatio(value) {
|
||||
var normalized = normalizeLockRatio(value);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = normalized.split(':');
|
||||
var width = Number(parts[0]);
|
||||
var height = Number(parts[1]);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: normalized,
|
||||
ratio: width / height
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultRegionSize(regionType, lockRatio) {
|
||||
var ratio = parseLockRatio(lockRatio);
|
||||
var locked = regionType === 'image' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss' || regionType === 'api';
|
||||
|
||||
if (ratio) {
|
||||
if (ratio.ratio >= 1) {
|
||||
var baseWidth = locked ? 420 : 300;
|
||||
return {
|
||||
width: Math.max(12, Math.round(baseWidth)),
|
||||
height: Math.max(12, Math.round(baseWidth / ratio.ratio))
|
||||
};
|
||||
}
|
||||
|
||||
var baseHeight = locked ? 300 : 240;
|
||||
return {
|
||||
width: Math.max(12, Math.round(baseHeight * ratio.ratio)),
|
||||
height: Math.max(12, Math.round(baseHeight))
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: locked ? 420 : 300,
|
||||
height: locked ? 240 : 120
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
@@ -34,6 +90,7 @@
|
||||
label: name,
|
||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
||||
font_family: card.querySelector('[name="font_family[]"]').value,
|
||||
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').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),
|
||||
@@ -52,6 +109,7 @@
|
||||
}
|
||||
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
|
||||
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
|
||||
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
|
||||
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); }
|
||||
@@ -112,6 +170,9 @@
|
||||
clampRegion: clampRegion,
|
||||
getOverlayRect: getOverlayRect,
|
||||
toCanvasPoint: toCanvasPoint,
|
||||
canvasRectToPixels: canvasRectToPixels
|
||||
canvasRectToPixels: canvasRectToPixels,
|
||||
normalizeLockRatio: normalizeLockRatio,
|
||||
parseLockRatio: parseLockRatio,
|
||||
getDefaultRegionSize: getDefaultRegionSize
|
||||
};
|
||||
}());
|
||||
|
||||
@@ -59,6 +59,44 @@
|
||||
return utils.clamp ? utils.clamp(value, min, max) : Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function normalizeLockRatio(value) {
|
||||
return utils.normalizeLockRatio ? utils.normalizeLockRatio(value) : String(value || '').trim().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function parseLockRatio(value) {
|
||||
if (utils.parseLockRatio) {
|
||||
return utils.parseLockRatio(value);
|
||||
}
|
||||
var normalized = normalizeLockRatio(value);
|
||||
if (!normalized || !/^\d+:\d+$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
var parts = normalized.split(':');
|
||||
var width = Number(parts[0]);
|
||||
var height = Number(parts[1]);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { label: normalized, ratio: width / height };
|
||||
}
|
||||
|
||||
function getDefaultRegionSize(regionType, lockRatio) {
|
||||
if (utils.getDefaultRegionSize) {
|
||||
return utils.getDefaultRegionSize(regionType, lockRatio);
|
||||
}
|
||||
var ratio = parseLockRatio(lockRatio);
|
||||
var locked = regionType === 'image' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss';
|
||||
if (ratio) {
|
||||
if (ratio.ratio >= 1) {
|
||||
var baseWidth = locked ? 420 : 300;
|
||||
return { width: Math.max(12, Math.round(baseWidth)), height: Math.max(12, Math.round(baseWidth / ratio.ratio)) };
|
||||
}
|
||||
var baseHeight = locked ? 300 : 240;
|
||||
return { width: Math.max(12, Math.round(baseHeight * ratio.ratio)), height: Math.max(12, Math.round(baseHeight)) };
|
||||
}
|
||||
return { width: locked ? 420 : 300, height: locked ? 240 : 120 };
|
||||
}
|
||||
|
||||
function getCards() {
|
||||
return Array.prototype.slice.call(regionList.querySelectorAll('.region-item'));
|
||||
}
|
||||
@@ -126,6 +164,7 @@
|
||||
label: getRegionName(card),
|
||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
||||
font_family: card.querySelector('[name="font_family[]"]').value,
|
||||
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').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),
|
||||
@@ -148,6 +187,7 @@
|
||||
}
|
||||
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
|
||||
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
|
||||
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
|
||||
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); }
|
||||
@@ -229,8 +269,50 @@
|
||||
stage.style.backgroundColor = backgroundColorInput && backgroundColorInput.value ? backgroundColorInput.value : '#111111';
|
||||
}
|
||||
|
||||
function updateRegionLockBadge(card) {
|
||||
var lockChip = card.querySelector('[data-region-lock-chip]');
|
||||
if (!lockChip) {
|
||||
return;
|
||||
}
|
||||
var lockRatio = normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value);
|
||||
if (!lockRatio) {
|
||||
lockChip.hidden = true;
|
||||
lockChip.textContent = '';
|
||||
return;
|
||||
}
|
||||
lockChip.hidden = false;
|
||||
lockChip.textContent = 'Locked ' + lockRatio;
|
||||
}
|
||||
|
||||
function getRegionLockRatio(card) {
|
||||
return normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value);
|
||||
}
|
||||
|
||||
function isRegionLocked(card) {
|
||||
return Boolean(getRegionLockRatio(card));
|
||||
}
|
||||
|
||||
function syncLockedDimensions(card, changedField) {
|
||||
var lockRatio = getRegionLockRatio(card);
|
||||
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
|
||||
if (!aspect) {
|
||||
return;
|
||||
}
|
||||
|
||||
var widthInput = card.querySelector('[name="region_width[]"]');
|
||||
var heightInput = card.querySelector('[name="region_height[]"]');
|
||||
var width = Math.max(1, Number(widthInput.value || 0));
|
||||
var height = Math.max(1, Number(heightInput.value || 0));
|
||||
|
||||
if (changedField === 'width') {
|
||||
heightInput.value = Math.max(1, Math.round(width / aspect.ratio));
|
||||
} else if (changedField === 'height') {
|
||||
widthInput.value = Math.max(1, Math.round(height * aspect.ratio));
|
||||
}
|
||||
}
|
||||
|
||||
function getRegionChipLabel(regionType) {
|
||||
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : 'Text';
|
||||
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
|
||||
}
|
||||
|
||||
function populateRegionCard(card, region) {
|
||||
@@ -252,7 +334,7 @@
|
||||
nameInput.value = region.region_key || region.label || '';
|
||||
}
|
||||
if (fontFamilyInput) {
|
||||
fontFamilyInput.value = region.region_type === 'image' ? '' : (region.font_family || 'Arial');
|
||||
fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial');
|
||||
}
|
||||
if (regionTypeInput) {
|
||||
regionTypeInput.value = region.region_type || 'text';
|
||||
@@ -263,11 +345,16 @@
|
||||
if (regionLabelInput) {
|
||||
regionLabelInput.value = region.label || region.region_key || '';
|
||||
}
|
||||
var regionLockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
||||
if (regionLockRatioInput) {
|
||||
regionLockRatioInput.value = normalizeLockRatio(region.lock_ratio);
|
||||
}
|
||||
card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80);
|
||||
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
|
||||
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
|
||||
card.querySelector('[name="region_width[]"]').value = valueOrDefault(region.width, 300);
|
||||
card.querySelector('[name="region_height[]"]').value = valueOrDefault(region.height, 120);
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
|
||||
function updateRegionLabel(card) {
|
||||
@@ -293,6 +380,9 @@
|
||||
}
|
||||
populateRegionCard(card, region);
|
||||
var nameInput = card.querySelector('[name="region_name[]"]');
|
||||
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
||||
var widthInput = card.querySelector('[name="region_width[]"]');
|
||||
var heightInput = card.querySelector('[name="region_height[]"]');
|
||||
nameInput.addEventListener('input', function () {
|
||||
syncRegionIdentity(card, nameInput.value);
|
||||
updateRegionLabel(card);
|
||||
@@ -300,6 +390,30 @@
|
||||
renderRegionSidebar();
|
||||
renderOverlay();
|
||||
});
|
||||
if (lockRatioInput) {
|
||||
lockRatioInput.addEventListener('input', function () {
|
||||
updateRegionLockBadge(card);
|
||||
requestOverlayRender();
|
||||
});
|
||||
}
|
||||
if (widthInput) {
|
||||
widthInput.addEventListener('input', function () {
|
||||
if (isRegionLocked(card)) {
|
||||
syncLockedDimensions(card, 'width');
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
requestOverlayRender();
|
||||
});
|
||||
}
|
||||
if (heightInput) {
|
||||
heightInput.addEventListener('input', function () {
|
||||
if (isRegionLocked(card)) {
|
||||
syncLockedDimensions(card, 'height');
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
requestOverlayRender();
|
||||
});
|
||||
}
|
||||
card.addEventListener('click', function (event) {
|
||||
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) {
|
||||
return;
|
||||
@@ -423,15 +537,17 @@
|
||||
function createDefaultRegion(type) {
|
||||
var count = getCards().length + 1;
|
||||
var name = 'region_' + count;
|
||||
var size = getDefaultRegionSize(type, '');
|
||||
return {
|
||||
region_key: name,
|
||||
label: name,
|
||||
region_type: type,
|
||||
font_family: type === 'text' || type === 'html' ? 'Arial' : '',
|
||||
font_family: type === 'text' || type === 'html' || type === 'rss' || type === 'api' ? 'Arial' : '',
|
||||
x: 80,
|
||||
y: 80,
|
||||
width: type === 'image' || type === 'webpage' || type === 'html' ? 420 : 300,
|
||||
height: type === 'image' || type === 'webpage' || type === 'html' ? 240 : 120,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
lock_ratio: '',
|
||||
z_index: 1
|
||||
};
|
||||
}
|
||||
@@ -499,15 +615,69 @@
|
||||
function resizeFromHandle(index, dir, event) {
|
||||
var startPoint = toCanvasPoint(event);
|
||||
var startRegion = readCard(cardAt(index));
|
||||
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
|
||||
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
|
||||
|
||||
function fitFromWidth(width) {
|
||||
var nextWidth = Math.max(12, Math.round(width));
|
||||
return {
|
||||
width: nextWidth,
|
||||
height: aspect ? Math.max(12, Math.round(nextWidth / aspect.ratio)) : startRegion.height
|
||||
};
|
||||
}
|
||||
|
||||
function fitFromHeight(height) {
|
||||
var nextHeight = Math.max(12, Math.round(height));
|
||||
return {
|
||||
width: aspect ? Math.max(12, Math.round(nextHeight * aspect.ratio)) : startRegion.width,
|
||||
height: nextHeight
|
||||
};
|
||||
}
|
||||
|
||||
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 (aspect) {
|
||||
if (dir === 'e') {
|
||||
var eastSize = fitFromWidth(startRegion.width + dx);
|
||||
next.width = eastSize.width;
|
||||
next.height = eastSize.height;
|
||||
next.y = startRegion.y + Math.round((startRegion.height - next.height) / 2);
|
||||
} else if (dir === 'w') {
|
||||
var westSize = fitFromWidth(startRegion.width - dx);
|
||||
next.width = westSize.width;
|
||||
next.height = westSize.height;
|
||||
next.x = startRegion.x + startRegion.width - next.width;
|
||||
next.y = startRegion.y + Math.round((startRegion.height - next.height) / 2);
|
||||
} else if (dir === 'n') {
|
||||
var northSize = fitFromHeight(startRegion.height - dy);
|
||||
next.width = northSize.width;
|
||||
next.height = northSize.height;
|
||||
next.x = startRegion.x + Math.round((startRegion.width - next.width) / 2);
|
||||
next.y = startRegion.y + startRegion.height - next.height;
|
||||
} else if (dir === 's') {
|
||||
var southSize = fitFromHeight(startRegion.height + dy);
|
||||
next.width = southSize.width;
|
||||
next.height = southSize.height;
|
||||
next.x = startRegion.x + Math.round((startRegion.width - next.width) / 2);
|
||||
} else {
|
||||
var useWidth = Math.abs(dx) >= Math.abs(dy * aspect.ratio);
|
||||
var cornerSize = useWidth ? fitFromWidth(dir.indexOf('w') !== -1 ? startRegion.width - dx : startRegion.width + dx) : fitFromHeight(dir.indexOf('n') !== -1 ? startRegion.height - dy : startRegion.height + dy);
|
||||
next.width = cornerSize.width;
|
||||
next.height = cornerSize.height;
|
||||
if (dir.indexOf('w') !== -1) { next.x = startRegion.x + startRegion.width - next.width; }
|
||||
if (dir.indexOf('n') !== -1) { next.y = startRegion.y + startRegion.height - next.height; }
|
||||
if (dir.indexOf('e') !== -1) { next.x = startRegion.x; }
|
||||
if (dir.indexOf('s') !== -1) { next.y = startRegion.y; }
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
@@ -586,6 +756,12 @@
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
getCards().forEach(function (card) {
|
||||
if (isRegionLocked(card)) {
|
||||
syncLockedDimensions(card, 'width');
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
});
|
||||
syncCanvasSizeSelection();
|
||||
event.formData.set('regions_json', JSON.stringify(getCards().map(readCard)));
|
||||
});
|
||||
@@ -594,6 +770,12 @@
|
||||
if (!validateRegionNames()) {
|
||||
return;
|
||||
}
|
||||
getCards().forEach(function (card) {
|
||||
if (isRegionLocked(card)) {
|
||||
syncLockedDimensions(card, 'width');
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
});
|
||||
syncCanvasSizeSelection();
|
||||
regionsJsonInput.value = JSON.stringify(getCards().map(readCard));
|
||||
});
|
||||
|
||||
@@ -1,6 +1,66 @@
|
||||
(function () {
|
||||
var storageKey = 'lte-theme';
|
||||
var theme = 'auto';
|
||||
var ckeditorThemeStyleId = 'ckeditor-dark-theme-overrides';
|
||||
|
||||
function getPreferredTheme() {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
return 'light';
|
||||
}
|
||||
|
||||
function getOrCreateCkeditorThemeStyleElement() {
|
||||
var styleElement = document.getElementById(ckeditorThemeStyleId);
|
||||
|
||||
if (styleElement) {
|
||||
return styleElement;
|
||||
}
|
||||
|
||||
styleElement = document.createElement('style');
|
||||
styleElement.id = ckeditorThemeStyleId;
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
return styleElement;
|
||||
}
|
||||
|
||||
function syncCkeditorTheme(currentTheme) {
|
||||
var styleElement = getOrCreateCkeditorThemeStyleElement();
|
||||
|
||||
if (currentTheme !== 'dark') {
|
||||
styleElement.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
styleElement.textContent = [
|
||||
'.ck.ck-dropdown__panel,',
|
||||
'.ck.ck-list__panel,',
|
||||
'.ck.ck-list,',
|
||||
'.ck.ck-balloon-panel {',
|
||||
' background: var(--bs-body-bg) !important;',
|
||||
' background-color: var(--bs-body-bg) !important;',
|
||||
' border-color: var(--bs-border-color) !important;',
|
||||
' color: var(--bs-body-color) !important;',
|
||||
'}',
|
||||
'.ck.ck-list .ck-list-item-button {',
|
||||
' background: transparent !important;',
|
||||
' background-color: transparent !important;',
|
||||
' color: var(--bs-body-color) !important;',
|
||||
'}',
|
||||
'.ck.ck-list .ck-list-item-button:hover {',
|
||||
' background: var(--bs-secondary-bg) !important;',
|
||||
' background-color: var(--bs-secondary-bg) !important;',
|
||||
'}',
|
||||
'.ck.ck-color-grid,',
|
||||
'.ck.ck-color-grid__tile {',
|
||||
' color: var(--bs-body-color) !important;',
|
||||
'}',
|
||||
'.ck.ck-color-grid__tile {',
|
||||
' border-color: var(--bs-border-color) !important;',
|
||||
'}'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
try {
|
||||
var storedTheme = window.localStorage.getItem(storageKey);
|
||||
@@ -13,4 +73,5 @@
|
||||
|
||||
document.documentElement.dataset.bsTheme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
syncCkeditorTheme(theme === 'auto' ? getPreferredTheme() : theme);
|
||||
}());
|
||||
@@ -30,12 +30,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var nextVariant = String(variant || 'success').trim().toLowerCase();
|
||||
var nextVariant = String(variant || 'info').trim().toLowerCase();
|
||||
var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'];
|
||||
variants.forEach(function (value) {
|
||||
toast.classList.remove('text-bg-' + value);
|
||||
});
|
||||
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'success' : nextVariant));
|
||||
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant));
|
||||
}
|
||||
|
||||
function getMessageVariant(message, fallbackVariant) {
|
||||
@@ -43,7 +43,10 @@
|
||||
if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
|
||||
return 'danger';
|
||||
}
|
||||
return String(fallbackVariant || 'success').trim().toLowerCase() || 'success';
|
||||
if (/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i.test(text)) {
|
||||
return 'warning';
|
||||
}
|
||||
return String(fallbackVariant || 'info').trim().toLowerCase() || 'info';
|
||||
}
|
||||
|
||||
function showToast(message, variant) {
|
||||
@@ -114,7 +117,7 @@
|
||||
// ignore URL cleanup failures
|
||||
}
|
||||
|
||||
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'success');
|
||||
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'info');
|
||||
setToastVariant(toast, existingVariant);
|
||||
|
||||
var instance = getBootstrapToast(toast);
|
||||
|
||||
@@ -2,11 +2,11 @@ const { renderView } = require('../../view');
|
||||
|
||||
function normalizeReturnUrl(value) {
|
||||
const url = String(value || '').trim();
|
||||
if (!url || url === '/admin/account' || url.indexOf('/admin/account?') === 0) {
|
||||
return '/admin';
|
||||
if (!url || url === '/account' || url.indexOf('/account?') === 0) {
|
||||
return '/dashboard';
|
||||
}
|
||||
if (!/^\/admin(?:\/|\?|$)/.test(url)) {
|
||||
return '/admin';
|
||||
if (!/^\/(?:account|dashboard)(?:\/|\?|$)/.test(url)) {
|
||||
return '/dashboard';
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
module.exports = function registerAdminPagesRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const buildDashboardState = deps.buildDashboardState;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
function requireQueryPermission(readPermissionKey, editPermissionKey) {
|
||||
return function (req, res, next) {
|
||||
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
|
||||
return requirePermission(permissionKey)(req, res, next);
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/admin', requirePermission('dashboard.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await buildDashboardState(pool);
|
||||
res.send(pages.renderDashboardPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/clients', requirePermission('clients.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await buildDashboardState(pool);
|
||||
res.send(pages.renderConnectedClientsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await buildDashboardState(pool);
|
||||
if (req.query.edit) {
|
||||
const screen = await common.fetchScreenById(pool, Number(req.query.edit));
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
const editData = await common.fetchScreenEditData(pool);
|
||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
}
|
||||
res.send(pages.renderScreensPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/screens/:id/edit', requirePermission('screens.update'), async function (req, res, next) {
|
||||
try {
|
||||
const screen = await common.fetchScreenById(pool, Number(req.params.id));
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
const editData = await common.fetchScreenEditData(pool);
|
||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/playlists', requireQueryPermission('playlists.read', 'playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
if (req.query.edit) {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
}
|
||||
res.send(pages.renderPlaylistsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/playlists/new', requirePermission('playlists.create'), function (req, res) {
|
||||
res.send(pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.get('/admin/playlists/:id/edit', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const data = await common.fetchAdminData(pool);
|
||||
res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,422 +0,0 @@
|
||||
module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const pages = deps.pages;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const rbacData = deps.rbacData;
|
||||
const permissions = Array.isArray(deps.permissions) ? deps.permissions : [];
|
||||
const readArrayField = deps.readArrayField;
|
||||
const normalizePermissionKeys = deps.normalizePermissionKeys;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
function slugifyRoleKey(name) {
|
||||
const value = String(name || '').trim().toLowerCase();
|
||||
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
return slug || 'role';
|
||||
}
|
||||
|
||||
function normalizeSelectedIds(values) {
|
||||
return Array.from(new Set((Array.isArray(values) ? values : []).map(function (value) {
|
||||
return Number(value);
|
||||
}).filter(function (value) {
|
||||
return Number.isInteger(value) && value > 0;
|
||||
})));
|
||||
}
|
||||
|
||||
function getActionLabel(actionKey) {
|
||||
const normalizedActionKey = String(actionKey || '').trim().toLowerCase();
|
||||
if (normalizedActionKey === 'edit') {
|
||||
return 'Update';
|
||||
}
|
||||
if (normalizedActionKey === 'allow') {
|
||||
return 'Allow';
|
||||
}
|
||||
if (!normalizedActionKey) {
|
||||
return 'Read';
|
||||
}
|
||||
return normalizedActionKey.charAt(0).toUpperCase() + normalizedActionKey.slice(1);
|
||||
}
|
||||
|
||||
async function createUniqueRoleKey(baseName) {
|
||||
const baseKey = slugifyRoleKey(baseName);
|
||||
let candidate = baseKey;
|
||||
let suffix = 2;
|
||||
|
||||
while (true) {
|
||||
const [rows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [candidate]);
|
||||
if (!rows.length) {
|
||||
return candidate;
|
||||
}
|
||||
candidate = `${baseKey}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function mapPermissionsForView(permissionRows, selectedPermissionKeys) {
|
||||
const selectedKeys = new Set(normalizePermissionKeys(selectedPermissionKeys));
|
||||
const permissionDefinitions = new Map(permissions.map(function (permission) {
|
||||
return [String(permission.key || '').trim(), permission];
|
||||
}));
|
||||
return (Array.isArray(permissionRows) ? permissionRows : []).map(function (permission) {
|
||||
const definition = permissionDefinitions.get(String(permission.permission_key || '').trim()) || null;
|
||||
return Object.assign({}, permission, {
|
||||
resourceKey: definition ? definition.sectionKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
resourceName: definition ? definition.name : permission.section_name,
|
||||
categoryName: definition ? definition.sectionName : permission.section_name,
|
||||
sectionOrder: definition ? definition.sectionOrder : 999,
|
||||
actionKey: definition ? definition.actionKey : 'read',
|
||||
actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey),
|
||||
isSelected: selectedKeys.has(String(permission.permission_key || '').trim())
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mapUsersForView(userRows, selectedUserIds) {
|
||||
const selectedIds = new Set(normalizeSelectedIds(selectedUserIds));
|
||||
return (Array.isArray(userRows) ? userRows : []).map(function (user) {
|
||||
return Object.assign({}, user, {
|
||||
isSelected: selectedIds.has(Number(user.id))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildPermissionGroups(permissionRows) {
|
||||
const groups = [];
|
||||
const groupIndex = new Map();
|
||||
|
||||
(Array.isArray(permissionRows) ? permissionRows : []).forEach(function (permission) {
|
||||
const sectionKey = String(permission.resourceKey || permission.sectionKey || permission.sectionName || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
if (!groupIndex.has(sectionKey)) {
|
||||
const group = {
|
||||
id: sectionKey || 'permissions',
|
||||
title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(),
|
||||
categoryName: String(permission.categoryName || '').trim(),
|
||||
sectionOrder: Number(permission.sectionOrder) || 999,
|
||||
permissions: []
|
||||
};
|
||||
groupIndex.set(sectionKey, group);
|
||||
groups.push(group);
|
||||
}
|
||||
groupIndex.get(sectionKey).permissions.push(permission);
|
||||
});
|
||||
|
||||
groups.forEach(function (group) {
|
||||
group.permissions.sort(function (left, right) {
|
||||
const actionOrder = { create: 1, read: 2, update: 3, edit: 3, delete: 4, allow: 5 };
|
||||
const leftOrder = actionOrder[String(left.actionKey || '').trim()] || 99;
|
||||
const rightOrder = actionOrder[String(right.actionKey || '').trim()] || 99;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left.name || '').localeCompare(String(right.name || ''));
|
||||
});
|
||||
});
|
||||
|
||||
groups.sort(function (left, right) {
|
||||
const leftOrder = Number(left.sectionOrder) || 999;
|
||||
const rightOrder = Number(right.sectionOrder) || 999;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left.title || '').localeCompare(String(right.title || ''));
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
async function buildRoleCreateViewModel(formValues, selectedPermissionKeys) {
|
||||
const permissionRows = await rbacData.fetchPermissions(pool);
|
||||
return {
|
||||
formValues: {
|
||||
name: String(formValues && formValues.name || '').trim(),
|
||||
description: String(formValues && formValues.description || '').trim()
|
||||
},
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys))
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRoleViewModel(roleId) {
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const permissionRows = await rbacData.fetchPermissions(pool);
|
||||
const selectedPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, role.id);
|
||||
const selectedUserIds = await rbacData.fetchRoleUserIds(pool, role.id);
|
||||
const users = await rbacData.fetchUsersWithRoles(pool);
|
||||
return {
|
||||
role: Object.assign({}, role, {
|
||||
permissionKeys: selectedPermissionKeys,
|
||||
permissionCount: Number(role.permission_count) || 0,
|
||||
userCount: Number(role.user_count) || 0
|
||||
}),
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
|
||||
users: mapUsersForView(users, selectedUserIds)
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/admin/rbac', requirePermission('rbac.read'), async function (req, res, next) {
|
||||
try {
|
||||
const roles = await rbacData.fetchRoles(pool);
|
||||
res.send(pages.renderRbacPage({ roles: roles }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/rbac/new', requirePermission('rbac.create'), function (req, res, next) {
|
||||
buildRoleCreateViewModel({
|
||||
name: String(req.query.name || '').trim(),
|
||||
description: String(req.query.description || '').trim()
|
||||
}, []).then(function (viewModel) {
|
||||
res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, 'primary'));
|
||||
}).catch(function (error) {
|
||||
next(error);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin/rbac', requirePermission('rbac.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const description = String(req.body.description || '').trim();
|
||||
const selectedPermissionKeys = normalizePermissionKeys(Array.isArray(req.body['permission_keys[]'])
|
||||
? req.body['permission_keys[]']
|
||||
: req.body.permission_keys
|
||||
? [].concat(req.body.permission_keys)
|
||||
: []);
|
||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||
return String(permission.key || '').trim();
|
||||
}));
|
||||
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys);
|
||||
if (!name) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
}
|
||||
|
||||
if (selectedPermissionKeys.some(function (permissionKey) {
|
||||
return !validPermissionKeys.has(permissionKey);
|
||||
})) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
}
|
||||
|
||||
const roleKey = await createUniqueRoleKey(name);
|
||||
const actorId = getAuditUserId(req);
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
||||
[roleKey, name, description || null, actorId, actorId]
|
||||
);
|
||||
if (selectedPermissionKeys.length) {
|
||||
await rbacData.syncRolePermissions(connection, Number(result.insertId), selectedPermissionKeys);
|
||||
}
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/admin/rbac?message=' + encodeURIComponent('Role created.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/rbac/:id/edit', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const viewModel = await loadRoleViewModel(roleId);
|
||||
if (!viewModel) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
|
||||
const users = Array.isArray(viewModel.users)
|
||||
? viewModel.users.filter(function (user) {
|
||||
return Number(user && user.id) !== currentUserId;
|
||||
})
|
||||
: [];
|
||||
|
||||
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/rbac/:id', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const name = String(req.body.name || '').trim();
|
||||
const description = String(req.body.description || '').trim();
|
||||
const shouldSyncPermissions = Object.prototype.hasOwnProperty.call(req.body || {}, 'permissions_present');
|
||||
const shouldSyncUsers = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present');
|
||||
const selectedPermissionKeys = shouldSyncPermissions
|
||||
? readArrayField(req.body, ['permission_keys[]', 'permission_keys'])
|
||||
: [];
|
||||
const selectedUserIds = shouldSyncUsers
|
||||
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
|
||||
: [];
|
||||
const normalizedPermissionKeys = shouldSyncPermissions ? normalizePermissionKeys(selectedPermissionKeys) : [];
|
||||
const normalizedUserIds = shouldSyncUsers ? normalizeSelectedIds(selectedUserIds) : [];
|
||||
|
||||
if (!name) {
|
||||
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.'));
|
||||
}
|
||||
|
||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||
return String(permission.key || '').trim();
|
||||
}));
|
||||
if (shouldSyncPermissions && normalizedPermissionKeys.some(function (permissionKey) {
|
||||
return !validPermissionKeys.has(permissionKey);
|
||||
})) {
|
||||
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||
}
|
||||
|
||||
let availableUsers = [];
|
||||
if (shouldSyncUsers) {
|
||||
availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||
}
|
||||
const validUserIds = new Set(availableUsers.map(function (user) {
|
||||
return Number(user.id);
|
||||
}));
|
||||
if (shouldSyncUsers && normalizedUserIds.some(function (userId) {
|
||||
return !validUserIds.has(userId);
|
||||
})) {
|
||||
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE roles SET name = ?, description = ?, modified_by = ? WHERE id = ?',
|
||||
[name, description || null, getAuditUserId(req), roleId]
|
||||
);
|
||||
if (shouldSyncPermissions) {
|
||||
await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys);
|
||||
}
|
||||
if (shouldSyncUsers) {
|
||||
await rbacData.syncRoleUsers(connection, roleId, normalizedUserIds);
|
||||
}
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/rbac/:id/permissions', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const selectedPermissionKeys = Array.isArray(req.body['permission_keys[]'])
|
||||
? req.body['permission_keys[]']
|
||||
: req.body.permission_keys
|
||||
? [].concat(req.body.permission_keys)
|
||||
: [];
|
||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||
return String(permission.key || '').trim();
|
||||
}));
|
||||
const normalizedPermissionKeys = normalizePermissionKeys(selectedPermissionKeys);
|
||||
if (normalizedPermissionKeys.some(function (permissionKey) {
|
||||
return !validPermissionKeys.has(permissionKey);
|
||||
})) {
|
||||
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||
}
|
||||
|
||||
await rbacData.syncRolePermissions(pool, roleId, normalizedPermissionKeys);
|
||||
res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/rbac/:id/users', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const selectedUserIds = Array.isArray(req.body['user_ids[]'])
|
||||
? req.body['user_ids[]']
|
||||
: req.body.user_ids
|
||||
? [].concat(req.body.user_ids)
|
||||
: [];
|
||||
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
|
||||
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||
const validUserIds = new Set(availableUsers.map(function (user) {
|
||||
return Number(user.id);
|
||||
}));
|
||||
|
||||
if (normalizedUserIds.some(function (userId) {
|
||||
return !validUserIds.has(userId);
|
||||
})) {
|
||||
return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
|
||||
}
|
||||
|
||||
await rbacData.syncRoleUsers(pool, roleId, normalizedUserIds);
|
||||
res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/rbac/:id/delete', requirePermission('rbac.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
if (String(role.role_key || '') === 'administrators') {
|
||||
return res.redirect('/admin/rbac?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.'));
|
||||
}
|
||||
if (Number(role.user_count) > 0) {
|
||||
return res.redirect('/admin/rbac?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM roles WHERE id = ?', [roleId]);
|
||||
res.redirect('/admin/rbac?message=' + encodeURIComponent('Role deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,289 +0,0 @@
|
||||
module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const pages = deps.pages;
|
||||
const formatDashboardDate = deps.formatDashboardDate;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const hashPassword = deps.hashPassword;
|
||||
const readArrayField = deps.readArrayField;
|
||||
const rbacData = deps.rbacData;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
async function fetchRoleOptions() {
|
||||
return rbacData.fetchRoles(pool);
|
||||
}
|
||||
|
||||
function mapRolesForForm(roles, selectedRoleIds) {
|
||||
const selectedIds = new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) {
|
||||
return Number(roleId);
|
||||
}).filter(function (roleId) {
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
}));
|
||||
|
||||
return (Array.isArray(roles) ? roles : []).map(function (role) {
|
||||
return Object.assign({}, role, {
|
||||
isSelected: selectedIds.has(Number(role.id))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function validateRoleIds(roleIds) {
|
||||
const availableRoles = await fetchRoleOptions();
|
||||
const validRoleIds = new Set(availableRoles.map(function (role) {
|
||||
return Number(role.id);
|
||||
}));
|
||||
const normalizedRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) {
|
||||
return Number(roleId);
|
||||
}).filter(function (roleId) {
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
})));
|
||||
|
||||
if (!normalizedRoleIds.length) {
|
||||
return { ok: false, message: 'Select at least one role.' };
|
||||
}
|
||||
|
||||
if (normalizedRoleIds.some(function (roleId) {
|
||||
return !validRoleIds.has(roleId);
|
||||
})) {
|
||||
return { ok: false, message: 'One or more selected roles are invalid.' };
|
||||
}
|
||||
|
||||
return { ok: true, roleIds: normalizedRoleIds };
|
||||
}
|
||||
|
||||
app.get('/admin/users', requirePermission('users.read'), async function (req, res, next) {
|
||||
try {
|
||||
const users = await rbacData.fetchUsersWithRoles(pool);
|
||||
const mappedUsers = users.map(function (user) {
|
||||
return Object.assign({}, user, {
|
||||
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
|
||||
createdAtLabel: formatDashboardDate(user.created_at),
|
||||
modifiedAtLabel: formatDashboardDate(user.modified_at),
|
||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
|
||||
});
|
||||
});
|
||||
res.send(pages.renderUsersPage({ users: mappedUsers }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/users/new', requirePermission('users.create'), function (req, res) {
|
||||
fetchRoleOptions().then(function (roles) {
|
||||
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary'));
|
||||
}).catch(function (error) {
|
||||
res.status(500).send(error.message || 'Unable to load roles.');
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/admin/users/:id/edit', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
|
||||
const user = await rbacData.fetchUserWithRoles(pool, userId);
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
|
||||
}
|
||||
|
||||
const roles = await fetchRoleOptions();
|
||||
res.send(pages.renderUsersEditPage(Object.assign({}, user, {
|
||||
isCurrentUser: false,
|
||||
createdAtLabel: formatDashboardDate(user.created_at),
|
||||
modifiedAtLabel: formatDashboardDate(user.modified_at),
|
||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
|
||||
}), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds)));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/users', requirePermission('users.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const username = String(req.body.username || '').trim();
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
const formValues = {
|
||||
username: username,
|
||||
name: name
|
||||
};
|
||||
|
||||
async function renderValidationError(message) {
|
||||
const roles = await fetchRoleOptions();
|
||||
return res.status(400).send(pages.renderUsersAddPage(message, req.currentUser, mapRolesForForm(roles, selectedRoleIds), formValues, 'warning'));
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return renderValidationError('Name is required.');
|
||||
}
|
||||
if (!username) {
|
||||
return renderValidationError('Username is required.');
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return renderValidationError('Password must be at least 8 characters.');
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return renderValidationError('Passwords do not match.');
|
||||
}
|
||||
if (!roleCheck.ok) {
|
||||
return renderValidationError(roleCheck.message);
|
||||
}
|
||||
|
||||
const [existingRows] = await connection.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
|
||||
if (existingRows.length) {
|
||||
return renderValidationError('That username already exists.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(password);
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.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, actorId, actorId]
|
||||
);
|
||||
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
|
||||
await connection.commit();
|
||||
res.redirect('/admin/users?message=' + encodeURIComponent('User created.'));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/users/:id/roles', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
if (!roleCheck.ok) {
|
||||
return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
|
||||
}
|
||||
|
||||
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
|
||||
res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/users/:id/username', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
const name = String(req.body.name || '').trim();
|
||||
const username = String(req.body.username || '').trim();
|
||||
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (!name) {
|
||||
return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Name is required.'));
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own username or password.'));
|
||||
}
|
||||
if (!username) {
|
||||
return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.'));
|
||||
}
|
||||
|
||||
const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]);
|
||||
if (existingRows.length) {
|
||||
return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('That username already exists.'));
|
||||
}
|
||||
|
||||
const [result] = await pool.query('UPDATE users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
res.redirect('/admin/users?message=' + encodeURIComponent('Username updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/users/:id/password', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to change your own password.'));
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return res.redirect('/admin/users?message=' + encodeURIComponent('Password must be at least 8 characters.'));
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return res.redirect('/admin/users?message=' + encodeURIComponent('Passwords do not match.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(password);
|
||||
await pool.query(
|
||||
'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
|
||||
);
|
||||
await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [userId]);
|
||||
res.redirect('/admin/users?message=' + encodeURIComponent('Password updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/users/:id/delete', requirePermission('users.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/admin/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.'));
|
||||
}
|
||||
|
||||
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
|
||||
if (!countRows.length || Number(countRows[0].user_count) <= 1) {
|
||||
return res.redirect('/admin/users?message=' + encodeURIComponent('At least one user must remain.'));
|
||||
}
|
||||
|
||||
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [userId]);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
res.redirect('/admin/users?message=' + encodeURIComponent('User deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
module.exports = function registerAdminAccountRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const formatDashboardDate = deps.formatDashboardDate;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
@@ -8,30 +9,34 @@ module.exports = function registerAdminAccountRoutes(app, deps) {
|
||||
const createUserSession = deps.createUserSession;
|
||||
const setSessionCookie = deps.setSessionCookie;
|
||||
|
||||
app.get('/admin/account', function (req, res) {
|
||||
app.get('/account', function (req, res) {
|
||||
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', req.query.return_url ? String(req.query.return_url) : ''));
|
||||
});
|
||||
|
||||
app.post('/admin/account/name', async function (req, res, next) {
|
||||
app.post('/account/name', async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (!name) {
|
||||
return res.status(400).send('Name is required.');
|
||||
}
|
||||
|
||||
if (await common.fetchDuplicateName(pool, 'users', name, req.currentUser.id)) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('That name already exists.'));
|
||||
}
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('UPDATE users SET name = ?, modified_by = ? WHERE id = ?', [name, actorId, req.currentUser.id]);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
res.redirect('/admin/account?message=' + encodeURIComponent('Name updated.'));
|
||||
res.redirect('/account?message=' + encodeURIComponent('Name updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/account/password', async function (req, res, next) {
|
||||
app.post('/account/password', async function (req, res, next) {
|
||||
try {
|
||||
const currentPassword = String(req.body.current_password || '');
|
||||
const newPassword = String(req.body.new_password || '');
|
||||
@@ -61,9 +66,9 @@ module.exports = function registerAdminAccountRoutes(app, deps) {
|
||||
|
||||
const token = await createUserSession(pool, user.id);
|
||||
setSessionCookie(res, token);
|
||||
res.redirect('/admin/account?message=' + encodeURIComponent('Password updated.'));
|
||||
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -6,7 +6,7 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
|
||||
const withClientNameReservation = deps.withClientNameReservation;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
app.post('/admin/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
|
||||
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const slug = String(req.params.slug || '').trim();
|
||||
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
||||
@@ -152,4 +152,4 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -56,7 +56,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}, {});
|
||||
}
|
||||
|
||||
app.get('/admin/slides', requirePermission('slides.read'), async function (req, res, next) {
|
||||
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
@@ -65,31 +65,66 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/slides/new', requirePermission('slides.create'), async function (req, res, next) {
|
||||
app.get('/slides/new', requirePermission('slides.create'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchTemplatesData(pool);
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
||||
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
|
||||
return Object.assign({}, source, {
|
||||
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
||||
});
|
||||
}) : [];
|
||||
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
|
||||
: [];
|
||||
return Object.assign({}, feed, { items: items.map(function (item) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
}) });
|
||||
}));
|
||||
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources });
|
||||
res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/slides/:id/edit', requirePermission('slides.update'), async function (req, res, next) {
|
||||
app.get('/slides/:id/edit', requirePermission('slides.update'), async function (req, res, next) {
|
||||
try {
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
if (!slide) {
|
||||
return res.status(404).send('Slide not found');
|
||||
}
|
||||
const data = await common.fetchTemplatesData(pool);
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
||||
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
|
||||
return Object.assign({}, source, {
|
||||
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
||||
});
|
||||
}) : [];
|
||||
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
|
||||
: [];
|
||||
return Object.assign({}, feed, { items: items.map(function (item) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
}) });
|
||||
}));
|
||||
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources });
|
||||
res.send(pages.renderSlideFormPage(data, 'edit', slide, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
|
||||
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
const payload = await common.buildSlidePayload(pool, req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'slides', payload.title, null, 'title')) {
|
||||
return res.status(400).send('A slide with that title already exists.');
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO slides (title, body, template_id, content_json, media_path, media_type, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
@@ -101,9 +136,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
localUploadDir: deps.uploadDir,
|
||||
nextUploadRefs: collectUploadReferencesFromPayload(payload)
|
||||
});
|
||||
redirectAfterSave(req, res, '/admin/slides/' + result.insertId + '/edit', {
|
||||
closeUrl: '/admin/slides',
|
||||
newUrl: '/admin/slides/new',
|
||||
redirectAfterSave(req, res, '/slides/' + result.insertId + '/edit', {
|
||||
closeUrl: '/slides',
|
||||
newUrl: '/slides/new',
|
||||
message: 'Slide created.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -111,7 +146,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
|
||||
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
if (!slide) {
|
||||
@@ -121,6 +156,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
|
||||
const existingUploadRefs = collectUploadReferencesFromSlide(slide);
|
||||
const payload = await common.buildSlidePayload(pool, req, slide);
|
||||
if (await common.fetchDuplicateName(pool, 'slides', payload.title, slide.id, 'title')) {
|
||||
return res.status(400).send('A slide with that title already exists.');
|
||||
}
|
||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query(
|
||||
@@ -138,9 +176,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
screenSlideCounts: screenSlideCounts
|
||||
});
|
||||
await broadcastDashboardState();
|
||||
redirectAfterSave(req, res, '/admin/slides/' + slide.id + '/edit', {
|
||||
closeUrl: '/admin/slides',
|
||||
newUrl: '/admin/slides/new',
|
||||
redirectAfterSave(req, res, '/slides/' + slide.id + '/edit', {
|
||||
closeUrl: '/slides',
|
||||
newUrl: '/slides/new',
|
||||
message: 'Slide updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -148,7 +186,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/slides/:id/delete', requirePermission('slides.delete'), async function (req, res, next) {
|
||||
app.post('/slides/:id/delete', requirePermission('slides.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
if (!slide) {
|
||||
@@ -156,7 +194,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
const blockMessage = await getSlideDeleteBlockMessage(pool, slide);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/admin/slides?message=' + encodeURIComponent(blockMessage));
|
||||
return res.redirect('/slides?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
const affectedScreens = await fetchScreensBySlideId(pool, slide.id);
|
||||
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
|
||||
@@ -172,13 +210,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
screenSlideCounts: screenSlideCounts
|
||||
});
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/admin/slides?message=' + encodeURIComponent('Slide deleted.'));
|
||||
res.redirect('/slides?message=' + encodeURIComponent('Slide deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/templates', requirePermission('templates.read'), async function (req, res, next) {
|
||||
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchTemplatesData(pool);
|
||||
res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
@@ -187,7 +225,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/templates/new', requirePermission('templates.create'), async function (req, res, next) {
|
||||
app.get('/templates/new', requirePermission('templates.create'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchCanvasSizesData(pool);
|
||||
res.send(pages.renderTemplateFormPage(null, 'create', req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
|
||||
@@ -196,9 +234,12 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
|
||||
app.post('/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
const payload = await common.buildTemplatePayload(pool, req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name)) {
|
||||
return res.redirect('/templates/new?message=' + encodeURIComponent('A template with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO slide_templates (name, canvas_size_id, background_image_path, background_color, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
@@ -207,8 +248,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||
const region = payload.regions[i];
|
||||
await pool.query(
|
||||
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[result.insertId, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[result.insertId, region.region_key, region.region_type, region.label, region.font_family, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
);
|
||||
}
|
||||
await syncPlaylistUploadsOnChange({
|
||||
@@ -217,9 +258,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
localUploadDir: deps.uploadDir,
|
||||
nextUploadRefs: collectUploadReferencesFromPayload(payload)
|
||||
});
|
||||
redirectAfterSave(req, res, '/admin/templates/' + result.insertId + '/edit', {
|
||||
closeUrl: '/admin/templates',
|
||||
newUrl: '/admin/templates/new',
|
||||
redirectAfterSave(req, res, '/templates/' + result.insertId + '/edit', {
|
||||
closeUrl: '/templates',
|
||||
newUrl: '/templates/new',
|
||||
message: 'Template created.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -227,7 +268,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/templates/:id/edit', requirePermission('templates.update'), async function (req, res, next) {
|
||||
app.get('/templates/:id/edit', requirePermission('templates.update'), async function (req, res, next) {
|
||||
try {
|
||||
const template = await common.fetchTemplateById(pool, Number(req.params.id));
|
||||
if (!template) {
|
||||
@@ -240,7 +281,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/templates/:id', requirePermission('templates.update'), upload.any(), async function (req, res, next) {
|
||||
app.post('/templates/:id', requirePermission('templates.update'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
const template = await common.fetchTemplateById(pool, Number(req.params.id));
|
||||
if (!template) {
|
||||
@@ -248,6 +289,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
const existingUploadRefs = collectUploadReferencesFromTemplate(template);
|
||||
const payload = await common.buildTemplatePayload(pool, req, template);
|
||||
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name, template.id)) {
|
||||
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent('A template with that name already exists.'));
|
||||
}
|
||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
||||
const actorId = getAuditUserId(req);
|
||||
@@ -259,8 +303,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||
const region = payload.regions[i];
|
||||
await pool.query(
|
||||
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[template.id, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
'INSERT INTO slide_template_regions (template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[template.id, region.region_key, region.region_type, region.label, region.font_family, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
||||
);
|
||||
}
|
||||
await syncPlaylistUploadsOnChange({
|
||||
@@ -271,9 +315,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
nextUploadRefs: nextUploadRefs
|
||||
});
|
||||
await notifyPlayerScreens(affectedScreens, 'refresh');
|
||||
redirectAfterSave(req, res, '/admin/templates/' + template.id + '/edit', {
|
||||
closeUrl: '/admin/templates',
|
||||
newUrl: '/admin/templates/new',
|
||||
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
|
||||
closeUrl: '/templates',
|
||||
newUrl: '/templates/new',
|
||||
message: 'Template updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -281,7 +325,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/templates/:id/delete', requirePermission('templates.delete'), async function (req, res, next) {
|
||||
app.post('/templates/:id/delete', requirePermission('templates.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const template = await common.fetchTemplateById(pool, Number(req.params.id));
|
||||
if (!template) {
|
||||
@@ -289,7 +333,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
const blockMessage = await getTemplateDeleteBlockMessage(pool, template);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/admin/templates?message=' + encodeURIComponent(blockMessage));
|
||||
return res.redirect('/templates?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
const uploadRefs = collectUploadReferencesFromTemplate(template);
|
||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
||||
@@ -303,13 +347,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
previousUploadRefs: uploadRefs
|
||||
});
|
||||
await notifyPlayerScreens(affectedScreens, 'refresh');
|
||||
res.redirect('/admin/templates?message=' + encodeURIComponent('Template deleted.'));
|
||||
res.redirect('/templates?message=' + encodeURIComponent('Template deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
||||
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
@@ -318,21 +362,24 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) {
|
||||
app.get('/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) {
|
||||
res.send(pages.renderCanvasSizeFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.post('/admin/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
|
||||
app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
|
||||
try {
|
||||
const payload = common.buildCanvasSizePayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'canvas_sizes', payload.name)) {
|
||||
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
||||
}
|
||||
if (await canvasSizeExists(payload.width, payload.height)) {
|
||||
return res.redirect('/admin/canvas-sizes/new?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query('INSERT INTO canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/admin/canvas-sizes', {
|
||||
closeUrl: '/admin/canvas-sizes',
|
||||
newUrl: '/admin/canvas-sizes/new',
|
||||
const [result] = await pool.query('INSERT INTO canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/canvas-sizes/' + result.insertId + '/edit', {
|
||||
closeUrl: '/canvas-sizes',
|
||||
newUrl: '/canvas-sizes/new',
|
||||
message: 'Canvas size created.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -340,7 +387,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/canvas-sizes/:id/edit', requirePermission('canvas-sizes.update'), async function (req, res, next) {
|
||||
app.get('/canvas-sizes/:id/edit', requirePermission('canvas-sizes.update'), async function (req, res, next) {
|
||||
try {
|
||||
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
|
||||
if (!canvasSize) {
|
||||
@@ -352,20 +399,23 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/canvas-sizes/:id', requirePermission('canvas-sizes.update'), async function (req, res, next) {
|
||||
app.post('/canvas-sizes/:id', requirePermission('canvas-sizes.update'), async function (req, res, next) {
|
||||
try {
|
||||
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
|
||||
if (!canvasSize) {
|
||||
return res.status(404).send('Canvas size not found');
|
||||
}
|
||||
const payload = common.buildCanvasSizePayload(req, canvasSize);
|
||||
if (await common.fetchDuplicateName(pool, 'canvas_sizes', payload.name, canvasSize.id)) {
|
||||
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
||||
}
|
||||
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
||||
return res.redirect('/admin/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
}
|
||||
await pool.query('UPDATE canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
|
||||
redirectAfterSave(req, res, '/admin/canvas-sizes', {
|
||||
closeUrl: '/admin/canvas-sizes',
|
||||
newUrl: '/admin/canvas-sizes/new',
|
||||
redirectAfterSave(req, res, '/canvas-sizes', {
|
||||
closeUrl: '/canvas-sizes',
|
||||
newUrl: '/canvas-sizes/new',
|
||||
message: 'Canvas size updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -373,7 +423,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/canvas-sizes/:id/delete', requirePermission('canvas-sizes.delete'), async function (req, res, next) {
|
||||
app.post('/canvas-sizes/:id/delete', requirePermission('canvas-sizes.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
|
||||
if (!canvasSize) {
|
||||
@@ -381,13 +431,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
const blockMessage = await getCanvasSizeDeleteBlockMessage(pool, canvasSize);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/admin/canvas-sizes?message=' + encodeURIComponent(blockMessage));
|
||||
return res.redirect('/canvas-sizes?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
await pool.query('UPDATE slide_templates SET canvas_size_id = NULL, modified_by = ? WHERE canvas_size_id = ?', [getAuditUserId(req), canvasSize.id]);
|
||||
await pool.query('DELETE FROM canvas_sizes WHERE id = ?', [canvasSize.id]);
|
||||
res.redirect('/admin/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.'));
|
||||
res.redirect('/canvas-sizes?message=' + encodeURIComponent('Canvas size deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,532 @@
|
||||
module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
const { hasAnyPermission } = require('../../../rbac');
|
||||
const formatDashboardDate = deps.formatDashboardDate || function (value) {
|
||||
return value ? String(value) : '';
|
||||
};
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
const requirePermission = deps.requirePermission;
|
||||
const fetchRssFeedItems = deps.fetchRssFeedItems || (common && common.fetchRssFeedItems) || null;
|
||||
const replaceRssFeedItems = deps.replaceRssFeedItems || (common && common.replaceRssFeedItems) || null;
|
||||
const fetchApiSourceResponse = common && common.fetchApiSourceResponse ? common.fetchApiSourceResponse : null;
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
async function refreshApiSourceInBackground(apiSourceId, apiUrl, actorId) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
let responseDetails = null;
|
||||
let pullError = '';
|
||||
|
||||
try {
|
||||
responseDetails = await loadApiSourceResponse(apiUrl);
|
||||
} catch (error) {
|
||||
pullError = String(error && error.message ? error.message : 'Unable to load API response.');
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSourceId]
|
||||
);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
let updatedItems = [];
|
||||
let pullError = '';
|
||||
|
||||
try {
|
||||
updatedItems = await loadRssFeedItems(feedUrl, itemLimit);
|
||||
} catch (error) {
|
||||
pullError = String(error && error.message ? error.message : 'Unable to load feed items.');
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE rss_feeds SET modified_by = ? WHERE id = ?',
|
||||
[actorId, rssFeedId]
|
||||
);
|
||||
if (replaceRssFeedItems) {
|
||||
await replaceRssFeedItems(connection, rssFeedId, updatedItems);
|
||||
}
|
||||
await connection.commit();
|
||||
|
||||
if (pullError) {
|
||||
console.error('[admin-data-sources] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError);
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
if (!pool || !common || !pages || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof requirePermission !== 'function' || !backgroundTaskQueue) {
|
||||
throw new Error('registerAdminDataSourceRoutes requires the data source route dependencies.');
|
||||
}
|
||||
|
||||
function formatRecurringKey(sourceType, id) {
|
||||
return sourceType + '-refresh:' + Number(id);
|
||||
}
|
||||
|
||||
function buildRecurringTitle(sourceType) {
|
||||
return sourceType === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh';
|
||||
}
|
||||
|
||||
function registerRecurringRefresh(sourceType, id, name, intervalValue, intervalUnit, run) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: formatRecurringKey(sourceType, id),
|
||||
title: buildRecurringTitle(sourceType),
|
||||
category: 'data-source',
|
||||
intervalMs: require('../../lib/background-task-queue').normalizeIntervalMs(intervalValue, intervalUnit),
|
||||
metadata: {
|
||||
sourceType: sourceType,
|
||||
sourceId: Number(id),
|
||||
sourceName: name
|
||||
},
|
||||
run: run
|
||||
});
|
||||
}
|
||||
|
||||
function removeRecurringRefresh(sourceType, id) {
|
||||
backgroundTaskQueue.removeRecurringTask(formatRecurringKey(sourceType, id));
|
||||
}
|
||||
|
||||
function getTaskStatusById(taskId) {
|
||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.getTaskById !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const task = backgroundTaskQueue.getTaskById(taskId);
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
status: task.status,
|
||||
finishedAt: task.finishedAt || '',
|
||||
errorMessage: task.errorMessage || ''
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRssFeedItems(feedUrl, itemLimit) {
|
||||
if (typeof fetchRssFeedItems === 'function') {
|
||||
return fetchRssFeedItems(feedUrl, itemLimit);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function loadApiSourceResponse(apiUrl) {
|
||||
if (typeof fetchApiSourceResponse === 'function') {
|
||||
return fetchApiSourceResponse(apiUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
responseJson: null,
|
||||
responseStatus: null,
|
||||
responseContentType: null
|
||||
};
|
||||
}
|
||||
|
||||
function sendRefreshTaskState(req, res, sourceType, sourceId) {
|
||||
const taskId = Number(req.query.refresh_task_id);
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||
return res.status(400).json({ error: 'Missing refresh task id.' });
|
||||
}
|
||||
|
||||
const task = getTaskStatusById(taskId);
|
||||
const expectedKey = sourceType + '-refresh:' + Number(sourceId);
|
||||
if (!task || task.key !== expectedKey) {
|
||||
return res.status(404).json({ error: 'Refresh task not found.' });
|
||||
}
|
||||
|
||||
res.json(task);
|
||||
}
|
||||
|
||||
app.get('/data-sources', function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['rss-feeds.read', 'api-sources.read'])) {
|
||||
if (hasAnyPermission(req.currentUser, ['rss-feeds.read'])) {
|
||||
return res.redirect('/data-sources/rss-feeds');
|
||||
}
|
||||
return res.redirect('/data-sources/api-sources');
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchApiSourcesData(pool);
|
||||
res.send(pages.renderApiSourcesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), function (req, res) {
|
||||
res.send(pages.renderApiSourceFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources', requirePermission('api-sources.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildApiSourcePayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'api_sources', payload.name)) {
|
||||
return res.redirect('/data-sources/api-sources/new?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO api_sources (name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.apiUrl, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return refreshApiSourceInBackground(result.insertId, payload.apiUrl, actorId);
|
||||
});
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'api-source-refresh:' + result.insertId,
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
const message = 'API source created. Refresh is running in the background.';
|
||||
res.redirect('/data-sources/api-sources/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
res.send(pages.renderApiSourceEditPage(Object.assign({}, apiSource, {
|
||||
apiUrl: apiSource.api_url,
|
||||
updateIntervalValue: apiSource.update_interval_value,
|
||||
updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
|
||||
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at),
|
||||
lastPulledAtLabel: apiSource.last_pulled_at ? String(apiSource.last_pulled_at) : '',
|
||||
lastPullError: apiSource.last_pull_error || '',
|
||||
lastResponseStatus: apiSource.last_response_status,
|
||||
lastResponseContentType: apiSource.last_response_content_type,
|
||||
lastResponseJson: apiSource.last_response_json || ''
|
||||
}), {
|
||||
lastResponseJson: apiSource.last_response_json || '',
|
||||
lastPullError: apiSource.last_pull_error || ''
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res) {
|
||||
sendRefreshTaskState(req, res, 'api-source', Number(req.params.id));
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources/:id', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const payload = common.buildApiSourcePayload(req, apiSource);
|
||||
if (await common.fetchDuplicateName(pool, 'api_sources', payload.name, apiSource.id)) {
|
||||
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE api_sources SET name = ?, api_url = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.apiUrl, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
|
||||
);
|
||||
await connection.commit();
|
||||
registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return refreshApiSourceInBackground(apiSource.id, payload.apiUrl, actorId);
|
||||
});
|
||||
const message = 'API source updated. Refresh is running in the background.';
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'api-source-refresh:' + apiSource.id,
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: apiSource.id,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: apiSource.id,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM api_sources WHERE id = ?', [apiSource.id]);
|
||||
await connection.commit();
|
||||
removeRecurringRefresh('api-source', apiSource.id);
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('API source deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchRssFeedsData(pool);
|
||||
res.send(pages.renderRssFeedsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) {
|
||||
res.send(pages.renderRssFeedFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds', requirePermission('rss-feeds.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildRssFeedPayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'rss_feeds', payload.name)) {
|
||||
return res.redirect('/data-sources/rss-feeds/new?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId);
|
||||
});
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'rss-feed-refresh:' + result.insertId,
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
const message = 'RSS feed created. Refresh is running in the background.';
|
||||
res.redirect('/data-sources/rss-feeds/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) {
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id)
|
||||
: [];
|
||||
|
||||
res.send(pages.renderRssFeedEditPage(Object.assign({}, rssFeed, {
|
||||
feedUrl: rssFeed.feed_url,
|
||||
updateIntervalValue: rssFeed.update_interval_value,
|
||||
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
|
||||
itemLimit: rssFeed.item_limit
|
||||
}), {
|
||||
pulledItems: pulledItems,
|
||||
pullError: ''
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res) {
|
||||
sendRefreshTaskState(req, res, 'rss-feed', Number(req.params.id));
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds/:id', requirePermission('rss-feeds.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const payload = common.buildRssFeedPayload(req, rssFeed);
|
||||
if (await common.fetchDuplicateName(pool, 'rss_feeds', payload.name, rssFeed.id)) {
|
||||
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id]
|
||||
);
|
||||
await connection.commit();
|
||||
registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
|
||||
});
|
||||
const message = 'RSS feed updated. Refresh is running in the background.';
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'rss-feed-refresh:' + rssFeed.id,
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: rssFeed.id,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: rssFeed.id,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM rss_feeds WHERE id = ?', [rssFeed.id]);
|
||||
await connection.commit();
|
||||
removeRecurringRefresh('rss-feed', rssFeed.id);
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('RSS feed deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -30,7 +30,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
app.post('/admin/commands', requirePermission('dashboard.allow'), async function (req, res, next) {
|
||||
app.post('/commands', requirePermission('dashboard.allow'), async function (req, res, next) {
|
||||
try {
|
||||
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
|
||||
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
||||
@@ -72,18 +72,21 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/playlists', requirePermission('playlists.create'), async function (req, res, next) {
|
||||
app.post('/playlists', requirePermission('playlists.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
|
||||
if (!name) {
|
||||
return res.status(400).send('Playlist name is required.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'playlists', name)) {
|
||||
return res.status(400).send('A playlist with that name already exists.');
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query('INSERT INTO playlists (name, fade_between_slides, created_by, modified_by) VALUES (?, ?, ?, ?)', [name, fadeBetweenSlides, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/admin/playlists', {
|
||||
closeUrl: '/admin/playlists',
|
||||
newUrl: '/admin/playlists/new',
|
||||
const [result] = await pool.query('INSERT INTO playlists (name, fade_between_slides, created_by, modified_by) VALUES (?, ?, ?, ?)', [name, fadeBetweenSlides, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
|
||||
closeUrl: '/playlists',
|
||||
newUrl: '/playlists/new',
|
||||
message: 'Playlist created.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -91,7 +94,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
app.post('/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
@@ -103,6 +106,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'playlists', name, playlist.id)) {
|
||||
return res.status(400).send('A playlist with that name already exists.');
|
||||
}
|
||||
|
||||
const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id);
|
||||
|
||||
@@ -239,9 +245,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
await connection.commit();
|
||||
await notifyPlayerScreens(affectedScreens, 'refresh');
|
||||
await broadcastDashboardState();
|
||||
redirectAfterSave(req, res, '/admin/playlists?edit=' + playlist.id, {
|
||||
closeUrl: '/admin/playlists',
|
||||
newUrl: '/admin/playlists/new',
|
||||
redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, {
|
||||
closeUrl: '/playlists',
|
||||
newUrl: '/playlists/new',
|
||||
message: 'Playlist updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -256,7 +262,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
|
||||
app.post('/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
@@ -264,16 +270,16 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
const blockMessage = await getPlaylistDeleteBlockMessage(pool, playlist);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/admin/playlists?message=' + encodeURIComponent(blockMessage));
|
||||
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
await pool.query('DELETE FROM playlists WHERE id = ?', [playlist.id]);
|
||||
res.redirect('/admin/playlists?message=' + encodeURIComponent('Playlist deleted.'));
|
||||
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
app.post('/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
@@ -302,13 +308,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, actorId, actorId]);
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
app.post('/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
@@ -326,13 +332,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide duration updated.'));
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide duration updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
|
||||
@@ -358,7 +364,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (swapIndex < 0 || swapIndex >= orderedSlides.length) {
|
||||
await connection.rollback();
|
||||
return res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
|
||||
return res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
|
||||
}
|
||||
|
||||
const currentSlide = orderedSlides[currentIndex];
|
||||
@@ -370,7 +376,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
await connection.commit();
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
next(error);
|
||||
@@ -379,7 +385,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
app.get('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
@@ -416,7 +422,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
@@ -490,13 +496,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
@@ -505,13 +511,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
await pool.query('DELETE FROM playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/admin/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/screens/new', requirePermission('screens.create'), async function (req, res, next) {
|
||||
app.get('/screens/new', requirePermission('screens.create'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchScreenEditData(pool);
|
||||
res.send(pages.renderScreenFormPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
@@ -520,20 +526,23 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/screens', requirePermission('screens.create'), async function (req, res, next) {
|
||||
app.post('/screens', requirePermission('screens.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (!name) {
|
||||
return res.status(400).send('Screen name is required.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'screens', name)) {
|
||||
return res.status(400).send('A screen with that name already exists.');
|
||||
}
|
||||
const slugInput = String(req.body.slug || '').trim();
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query('INSERT INTO screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/admin/screens', {
|
||||
closeUrl: '/admin/screens',
|
||||
newUrl: '/admin/screens/new',
|
||||
const [result] = await pool.query('INSERT INTO screens (name, slug, playlist_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, slug, playlistId, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/screens?edit=' + result.insertId, {
|
||||
closeUrl: '/screens',
|
||||
newUrl: '/screens/new',
|
||||
message: 'Screen created.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -541,7 +550,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/screens/:id', requirePermission('screens.update'), async function (req, res, next) {
|
||||
app.post('/screens/:id', requirePermission('screens.update'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (!name) {
|
||||
@@ -551,6 +560,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'screens', name, screen.id)) {
|
||||
return res.status(400).send('A screen with that name already exists.');
|
||||
}
|
||||
const slugInput = String(req.body.slug || '').trim();
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const previousPlaylistId = screen.playlist_id;
|
||||
@@ -566,9 +578,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
url: `${PLAYER_PUBLIC_BASE_URL}/screen/${encodeURIComponent(slug)}`
|
||||
});
|
||||
}
|
||||
redirectAfterSave(req, res, '/admin/screens?edit=' + screen.id, {
|
||||
closeUrl: '/admin/screens',
|
||||
newUrl: '/admin/screens/new',
|
||||
redirectAfterSave(req, res, '/screens?edit=' + screen.id, {
|
||||
closeUrl: '/screens',
|
||||
newUrl: '/screens/new',
|
||||
message: 'Screen updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -576,7 +588,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/screens/:id/delete', requirePermission('screens.delete'), async function (req, res, next) {
|
||||
app.post('/screens/:id/delete', requirePermission('screens.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const screen = await common.fetchScreenById(pool, Number(req.params.id));
|
||||
if (!screen) {
|
||||
@@ -584,12 +596,12 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
}
|
||||
const blockMessage = await getScreenDeleteBlockMessage(pool, screen, getScreenConnections);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/admin/screens?message=' + encodeURIComponent(blockMessage));
|
||||
return res.redirect('/screens?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
await pool.query('DELETE FROM screens WHERE id = ?', [screen.id]);
|
||||
res.redirect('/admin/screens?message=' + encodeURIComponent('Screen deleted.'));
|
||||
res.redirect('/screens?message=' + encodeURIComponent('Screen deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = function registerAdminPagesRoutes(app, deps) {
|
||||
require('../signage/dashboard/routes')(app, deps);
|
||||
require('../signage/clients/routes')(app, deps);
|
||||
require('../signage/screens/routes')(app, deps);
|
||||
require('../signage/playlists/routes')(app, deps);
|
||||
require('../signage/slides/routes')(app, deps);
|
||||
require('../signage/canvas-sizes/routes')(app, deps);
|
||||
require('../signage/templates/routes')(app, deps);
|
||||
};
|
||||
@@ -0,0 +1,432 @@
|
||||
module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const rbacData = deps.rbacData;
|
||||
const permissions = Array.isArray(deps.permissions) ? deps.permissions : [];
|
||||
const readArrayField = deps.readArrayField;
|
||||
const normalizePermissionKeys = deps.normalizePermissionKeys;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
function slugifyRoleKey(name) {
|
||||
const value = String(name || '').trim().toLowerCase();
|
||||
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
return slug || 'role';
|
||||
}
|
||||
|
||||
function normalizeSelectedIds(values) {
|
||||
return Array.from(new Set((Array.isArray(values) ? values : []).map(function (value) {
|
||||
return Number(value);
|
||||
}).filter(function (value) {
|
||||
return Number.isInteger(value) && value > 0;
|
||||
})));
|
||||
}
|
||||
|
||||
function getActionLabel(actionKey) {
|
||||
const normalizedActionKey = String(actionKey || '').trim().toLowerCase();
|
||||
if (normalizedActionKey === 'edit') {
|
||||
return 'Update';
|
||||
}
|
||||
if (normalizedActionKey === 'allow') {
|
||||
return 'Allow';
|
||||
}
|
||||
if (!normalizedActionKey) {
|
||||
return 'Read';
|
||||
}
|
||||
return normalizedActionKey.charAt(0).toUpperCase() + normalizedActionKey.slice(1);
|
||||
}
|
||||
|
||||
async function createUniqueRoleKey(baseName) {
|
||||
const baseKey = slugifyRoleKey(baseName);
|
||||
let candidate = baseKey;
|
||||
let suffix = 2;
|
||||
|
||||
while (true) {
|
||||
const [rows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [candidate]);
|
||||
if (!rows.length) {
|
||||
return candidate;
|
||||
}
|
||||
candidate = `${baseKey}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function mapPermissionsForView(permissionRows, selectedPermissionKeys) {
|
||||
const selectedKeys = new Set(normalizePermissionKeys(selectedPermissionKeys));
|
||||
const permissionDefinitions = new Map(permissions.map(function (permission) {
|
||||
return [String(permission.key || '').trim(), permission];
|
||||
}));
|
||||
return (Array.isArray(permissionRows) ? permissionRows : []).map(function (permission) {
|
||||
const definition = permissionDefinitions.get(String(permission.permission_key || '').trim()) || null;
|
||||
return Object.assign({}, permission, {
|
||||
resourceKey: definition ? definition.sectionKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
resourceName: definition ? definition.name : permission.section_name,
|
||||
categoryName: definition ? definition.sectionName : permission.section_name,
|
||||
sectionOrder: definition ? definition.sectionOrder : 999,
|
||||
actionKey: definition ? definition.actionKey : 'read',
|
||||
actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey),
|
||||
isSelected: selectedKeys.has(String(permission.permission_key || '').trim())
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mapUsersForView(userRows, selectedUserIds) {
|
||||
const selectedIds = new Set(normalizeSelectedIds(selectedUserIds));
|
||||
return (Array.isArray(userRows) ? userRows : []).map(function (user) {
|
||||
return Object.assign({}, user, {
|
||||
isSelected: selectedIds.has(Number(user.id))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildPermissionGroups(permissionRows) {
|
||||
const groups = [];
|
||||
const groupIndex = new Map();
|
||||
|
||||
(Array.isArray(permissionRows) ? permissionRows : []).forEach(function (permission) {
|
||||
const sectionKey = String(permission.resourceKey || permission.sectionKey || permission.sectionName || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
if (!groupIndex.has(sectionKey)) {
|
||||
const group = {
|
||||
id: sectionKey || 'permissions',
|
||||
title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(),
|
||||
categoryName: String(permission.categoryName || '').trim(),
|
||||
sectionOrder: Number(permission.sectionOrder) || 999,
|
||||
permissions: []
|
||||
};
|
||||
groupIndex.set(sectionKey, group);
|
||||
groups.push(group);
|
||||
}
|
||||
groupIndex.get(sectionKey).permissions.push(permission);
|
||||
});
|
||||
|
||||
groups.forEach(function (group) {
|
||||
group.permissions.sort(function (left, right) {
|
||||
const actionOrder = { create: 1, read: 2, update: 3, edit: 3, delete: 4, allow: 5 };
|
||||
const leftOrder = actionOrder[String(left.actionKey || '').trim()] || 99;
|
||||
const rightOrder = actionOrder[String(right.actionKey || '').trim()] || 99;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left.name || '').localeCompare(String(right.name || ''));
|
||||
});
|
||||
});
|
||||
|
||||
groups.sort(function (left, right) {
|
||||
const leftOrder = Number(left.sectionOrder) || 999;
|
||||
const rightOrder = Number(right.sectionOrder) || 999;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left.title || '').localeCompare(String(right.title || ''));
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
async function buildRoleCreateViewModel(formValues, selectedPermissionKeys) {
|
||||
const permissionRows = await rbacData.fetchPermissions(pool);
|
||||
return {
|
||||
formValues: {
|
||||
name: String(formValues && formValues.name || '').trim(),
|
||||
description: String(formValues && formValues.description || '').trim()
|
||||
},
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys))
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRoleViewModel(roleId) {
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const permissionRows = await rbacData.fetchPermissions(pool);
|
||||
const selectedPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, role.id);
|
||||
const selectedUserIds = await rbacData.fetchRoleUserIds(pool, role.id);
|
||||
const users = await rbacData.fetchUsersWithRoles(pool);
|
||||
return {
|
||||
role: Object.assign({}, role, {
|
||||
permissionKeys: selectedPermissionKeys,
|
||||
permissionCount: Number(role.permission_count) || 0,
|
||||
userCount: Number(role.user_count) || 0
|
||||
}),
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
|
||||
users: mapUsersForView(users, selectedUserIds)
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) {
|
||||
try {
|
||||
const roles = await rbacData.fetchRoles(pool);
|
||||
res.send(pages.renderRbacPage({ roles: roles }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/rbac/new', requirePermission('rbac.create'), function (req, res, next) {
|
||||
buildRoleCreateViewModel({
|
||||
name: String(req.query.name || '').trim(),
|
||||
description: String(req.query.description || '').trim()
|
||||
}, []).then(function (viewModel) {
|
||||
res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, 'primary'));
|
||||
}).catch(function (error) {
|
||||
next(error);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/rbac', requirePermission('rbac.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const description = String(req.body.description || '').trim();
|
||||
const selectedPermissionKeys = normalizePermissionKeys(Array.isArray(req.body['permission_keys[]'])
|
||||
? req.body['permission_keys[]']
|
||||
: req.body.permission_keys
|
||||
? [].concat(req.body.permission_keys)
|
||||
: []);
|
||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||
return String(permission.key || '').trim();
|
||||
}));
|
||||
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys);
|
||||
if (!name) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'roles', name)) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
}
|
||||
|
||||
if (selectedPermissionKeys.some(function (permissionKey) {
|
||||
return !validPermissionKeys.has(permissionKey);
|
||||
})) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
}
|
||||
|
||||
const roleKey = await createUniqueRoleKey(name);
|
||||
const actorId = getAuditUserId(req);
|
||||
const connection = await pool.getConnection();
|
||||
let insertedRoleId = null;
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
||||
[roleKey, name, description || null, actorId, actorId]
|
||||
);
|
||||
insertedRoleId = Number(result.insertId);
|
||||
if (selectedPermissionKeys.length) {
|
||||
await rbacData.syncRolePermissions(connection, insertedRoleId, selectedPermissionKeys);
|
||||
}
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/rbac/' + insertedRoleId + '/edit?message=' + encodeURIComponent('Role created.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/rbac/:id/edit', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const viewModel = await loadRoleViewModel(roleId);
|
||||
if (!viewModel) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
|
||||
const users = Array.isArray(viewModel.users)
|
||||
? viewModel.users.filter(function (user) {
|
||||
return Number(user && user.id) !== currentUserId;
|
||||
})
|
||||
: [];
|
||||
|
||||
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/rbac/:id', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const name = String(req.body.name || '').trim();
|
||||
const description = String(req.body.description || '').trim();
|
||||
const shouldSyncPermissions = Object.prototype.hasOwnProperty.call(req.body || {}, 'permissions_present');
|
||||
const shouldSyncUsers = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present');
|
||||
const selectedPermissionKeys = shouldSyncPermissions
|
||||
? readArrayField(req.body, ['permission_keys[]', 'permission_keys'])
|
||||
: [];
|
||||
const selectedUserIds = shouldSyncUsers
|
||||
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
|
||||
: [];
|
||||
const normalizedPermissionKeys = shouldSyncPermissions ? normalizePermissionKeys(selectedPermissionKeys) : [];
|
||||
const normalizedUserIds = shouldSyncUsers ? normalizeSelectedIds(selectedUserIds) : [];
|
||||
|
||||
if (!name) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.'));
|
||||
}
|
||||
|
||||
if (await common.fetchDuplicateName(pool, 'roles', name, roleId)) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('A role with that name already exists.'));
|
||||
}
|
||||
|
||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||
return String(permission.key || '').trim();
|
||||
}));
|
||||
if (shouldSyncPermissions && normalizedPermissionKeys.some(function (permissionKey) {
|
||||
return !validPermissionKeys.has(permissionKey);
|
||||
})) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||
}
|
||||
|
||||
let availableUsers = [];
|
||||
if (shouldSyncUsers) {
|
||||
availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||
}
|
||||
const validUserIds = new Set(availableUsers.map(function (user) {
|
||||
return Number(user.id);
|
||||
}));
|
||||
if (shouldSyncUsers && normalizedUserIds.some(function (userId) {
|
||||
return !validUserIds.has(userId);
|
||||
})) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE roles SET name = ?, description = ?, modified_by = ? WHERE id = ?',
|
||||
[name, description || null, getAuditUserId(req), roleId]
|
||||
);
|
||||
if (shouldSyncPermissions) {
|
||||
await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys);
|
||||
}
|
||||
if (shouldSyncUsers) {
|
||||
await rbacData.syncRoleUsers(connection, roleId, normalizedUserIds);
|
||||
}
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/rbac/:id/permissions', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const selectedPermissionKeys = Array.isArray(req.body['permission_keys[]'])
|
||||
? req.body['permission_keys[]']
|
||||
: req.body.permission_keys
|
||||
? [].concat(req.body.permission_keys)
|
||||
: [];
|
||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||
return String(permission.key || '').trim();
|
||||
}));
|
||||
const normalizedPermissionKeys = normalizePermissionKeys(selectedPermissionKeys);
|
||||
if (normalizedPermissionKeys.some(function (permissionKey) {
|
||||
return !validPermissionKeys.has(permissionKey);
|
||||
})) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||
}
|
||||
|
||||
await rbacData.syncRolePermissions(pool, roleId, normalizedPermissionKeys);
|
||||
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/rbac/:id/users', requirePermission('rbac.update'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const selectedUserIds = Array.isArray(req.body['user_ids[]'])
|
||||
? req.body['user_ids[]']
|
||||
: req.body.user_ids
|
||||
? [].concat(req.body.user_ids)
|
||||
: [];
|
||||
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
|
||||
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||
const validUserIds = new Set(availableUsers.map(function (user) {
|
||||
return Number(user.id);
|
||||
}));
|
||||
|
||||
if (normalizedUserIds.some(function (userId) {
|
||||
return !validUserIds.has(userId);
|
||||
})) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.'));
|
||||
}
|
||||
|
||||
await rbacData.syncRoleUsers(pool, roleId, normalizedUserIds);
|
||||
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/rbac/:id/delete', requirePermission('rbac.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
if (String(role.role_key || '') === 'administrators') {
|
||||
return res.redirect('/rbac?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.'));
|
||||
}
|
||||
if (Number(role.user_count) > 0) {
|
||||
return res.redirect('/rbac?message=' + encodeURIComponent('Remove all users from this role before deleting it.'));
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM roles WHERE id = ?', [roleId]);
|
||||
res.redirect('/rbac?message=' + encodeURIComponent('Role deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,303 @@
|
||||
module.exports = function registerAdminUsersRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const formatDashboardDate = deps.formatDashboardDate;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const hashPassword = deps.hashPassword;
|
||||
const readArrayField = deps.readArrayField;
|
||||
const rbacData = deps.rbacData;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
async function fetchRoleOptions() {
|
||||
return rbacData.fetchRoles(pool);
|
||||
}
|
||||
|
||||
function mapRolesForForm(roles, selectedRoleIds) {
|
||||
const selectedIds = new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) {
|
||||
return Number(roleId);
|
||||
}).filter(function (roleId) {
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
}));
|
||||
|
||||
return (Array.isArray(roles) ? roles : []).map(function (role) {
|
||||
return Object.assign({}, role, {
|
||||
isSelected: selectedIds.has(Number(role.id))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function validateRoleIds(roleIds) {
|
||||
const availableRoles = await fetchRoleOptions();
|
||||
const validRoleIds = new Set(availableRoles.map(function (role) {
|
||||
return Number(role.id);
|
||||
}));
|
||||
const normalizedRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) {
|
||||
return Number(roleId);
|
||||
}).filter(function (roleId) {
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
})));
|
||||
|
||||
if (!normalizedRoleIds.length) {
|
||||
return { ok: false, message: 'Select at least one role.' };
|
||||
}
|
||||
|
||||
if (normalizedRoleIds.some(function (roleId) {
|
||||
return !validRoleIds.has(roleId);
|
||||
})) {
|
||||
return { ok: false, message: 'One or more selected roles are invalid.' };
|
||||
}
|
||||
|
||||
return { ok: true, roleIds: normalizedRoleIds };
|
||||
}
|
||||
|
||||
app.get('/users', requirePermission('users.read'), async function (req, res, next) {
|
||||
try {
|
||||
const users = await rbacData.fetchUsersWithRoles(pool);
|
||||
const mappedUsers = users.map(function (user) {
|
||||
return Object.assign({}, user, {
|
||||
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
|
||||
createdAtLabel: formatDashboardDate(user.created_at),
|
||||
modifiedAtLabel: formatDashboardDate(user.modified_at),
|
||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
|
||||
});
|
||||
});
|
||||
res.send(pages.renderUsersPage({ users: mappedUsers }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/users/new', requirePermission('users.create'), function (req, res) {
|
||||
fetchRoleOptions().then(function (roles) {
|
||||
res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary'));
|
||||
}).catch(function (error) {
|
||||
res.status(500).send(error.message || 'Unable to load roles.');
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/users/:id/edit', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
|
||||
const user = await rbacData.fetchUserWithRoles(pool, userId);
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
|
||||
}
|
||||
|
||||
const roles = await fetchRoleOptions();
|
||||
res.send(pages.renderUsersEditPage(Object.assign({}, user, {
|
||||
isCurrentUser: false,
|
||||
createdAtLabel: formatDashboardDate(user.created_at),
|
||||
modifiedAtLabel: formatDashboardDate(user.modified_at),
|
||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
|
||||
}), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds)));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users', requirePermission('users.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const username = String(req.body.username || '').trim();
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
const formValues = {
|
||||
username: username,
|
||||
name: name
|
||||
};
|
||||
|
||||
async function renderValidationError(message) {
|
||||
const roles = await fetchRoleOptions();
|
||||
return res.status(400).send(pages.renderUsersAddPage(message, req.currentUser, mapRolesForForm(roles, selectedRoleIds), formValues, 'warning'));
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return renderValidationError('Name is required.');
|
||||
}
|
||||
if (!username) {
|
||||
return renderValidationError('Username is required.');
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return renderValidationError('Password must be at least 8 characters.');
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return renderValidationError('Passwords do not match.');
|
||||
}
|
||||
if (!roleCheck.ok) {
|
||||
return renderValidationError(roleCheck.message);
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'users', name)) {
|
||||
return renderValidationError('That name already exists.');
|
||||
}
|
||||
|
||||
const [existingRows] = await connection.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]);
|
||||
if (existingRows.length) {
|
||||
return renderValidationError('That username already exists.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(password);
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.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, actorId, actorId]
|
||||
);
|
||||
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
|
||||
await connection.commit();
|
||||
res.redirect('/users?message=' + encodeURIComponent('User created.'));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users/:id/roles', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
if (!roleCheck.ok) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message));
|
||||
}
|
||||
|
||||
await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds);
|
||||
res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users/:id/username', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
const name = String(req.body.name || '').trim();
|
||||
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (!name) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Name is required.'));
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own username or password.'));
|
||||
}
|
||||
|
||||
const [userRows] = await pool.query('SELECT id, username FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!userRows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const username = String(req.body.username || userRows[0].username || '').trim();
|
||||
if (!username) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.'));
|
||||
}
|
||||
|
||||
if (await common.fetchDuplicateName(pool, 'users', name, userId)) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That name already exists.'));
|
||||
}
|
||||
|
||||
const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]);
|
||||
if (existingRows.length) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That username already exists.'));
|
||||
}
|
||||
|
||||
const [result] = await pool.query('UPDATE users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
res.redirect('/users?message=' + encodeURIComponent('User updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users/:id/password', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to change your own password.'));
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('Password must be at least 8 characters.'));
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('Passwords do not match.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!rows.length) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const passwordRecord = hashPassword(password);
|
||||
await pool.query(
|
||||
'UPDATE users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
|
||||
);
|
||||
await pool.query('DELETE FROM auth_sessions WHERE user_id = ?', [userId]);
|
||||
res.redirect('/users?message=' + encodeURIComponent('Password updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/users/:id/delete', requirePermission('users.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('You cannot delete your own account from the users page.'));
|
||||
}
|
||||
|
||||
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM users');
|
||||
if (!countRows.length || Number(countRows[0].user_count) <= 1) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('At least one user must remain.'));
|
||||
}
|
||||
|
||||
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [userId]);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
res.redirect('/users?message=' + encodeURIComponent('User deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -10,12 +10,12 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
const sessionCookieName = deps.sessionCookieName;
|
||||
|
||||
app.get('/', function (req, res) {
|
||||
res.redirect(req.currentUser ? '/admin' : '/login');
|
||||
res.redirect(req.currentUser ? '/dashboard' : '/login');
|
||||
});
|
||||
|
||||
app.get('/login', function (req, res) {
|
||||
if (req.currentUser) {
|
||||
return res.redirect('/admin');
|
||||
return res.redirect('/dashboard');
|
||||
}
|
||||
res.send(pages.renderLoginPage(req.query.message ? String(req.query.message) : ''));
|
||||
});
|
||||
@@ -36,7 +36,7 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
|
||||
const token = await createUserSession(pool, user.id);
|
||||
setSessionCookie(res, token);
|
||||
res.redirect('/admin');
|
||||
res.redirect('/dashboard');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderLoginPage(message) {
|
||||
return renderView('login', {
|
||||
return renderView('auth/login', {
|
||||
title: 'Sign in',
|
||||
authShell: true,
|
||||
bodyClass: 'login-page-body',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function buildDefaultApiSource() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
apiUrl: '',
|
||||
updateIntervalValue: 60,
|
||||
updateIntervalUnit: 'minutes',
|
||||
lastPulledAt: null,
|
||||
lastPullError: '',
|
||||
lastResponseStatus: null,
|
||||
lastResponseContentType: '',
|
||||
lastResponseJson: ''
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function renderApiSourceFormPage(apiSource, mode, message, currentUser) {
|
||||
const isEdit = mode === 'edit';
|
||||
const viewApiSource = Object.assign(buildDefaultApiSource(), apiSource || {});
|
||||
|
||||
return renderView(isEdit ? 'data-sources/api-sources/edit' : 'data-sources/api-sources/add', {
|
||||
title: isEdit ? 'Edit API source' : 'Add API source',
|
||||
active: 'api-sources',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
apiSource: viewApiSource,
|
||||
lastResponseJson: viewApiSource.lastResponseJson || '',
|
||||
lastPullError: viewApiSource.lastPullError || ''
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
const renderApiSourceFormPage = require('./add');
|
||||
|
||||
module.exports = function renderApiSourceEditPage(apiSource, data, message, currentUser) {
|
||||
const viewData = Object.assign({
|
||||
lastResponseJson: '',
|
||||
lastPullError: ''
|
||||
}, data || {});
|
||||
|
||||
return renderApiSourceFormPage(Object.assign({}, apiSource, {
|
||||
lastResponseJson: viewData.lastResponseJson,
|
||||
lastPullError: viewData.lastPullError
|
||||
}), 'edit', message, currentUser);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
function formatIntervalLabel(interval, unit) {
|
||||
const value = Math.max(1, Number(interval) || 0);
|
||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes';
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return value === 1 ? 'Every second' : `Every ${value} seconds`;
|
||||
}
|
||||
return value === 1 ? 'Every minute' : `Every ${value} minutes`;
|
||||
}
|
||||
|
||||
function formatLastPullLabel(apiSource, formatDashboardDate) {
|
||||
if (!apiSource.last_pulled_at) {
|
||||
return 'Never';
|
||||
}
|
||||
|
||||
const timestamp = typeof formatDashboardDate === 'function'
|
||||
? formatDashboardDate(apiSource.last_pulled_at)
|
||||
: String(apiSource.last_pulled_at);
|
||||
return timestamp || 'Never';
|
||||
}
|
||||
|
||||
module.exports = function renderApiSourcesPage(data, message, currentUser, formatDashboardDate) {
|
||||
const apiSources = (data.apiSources || []).map(function (apiSource) {
|
||||
return Object.assign({}, apiSource, {
|
||||
intervalLabel: formatIntervalLabel(apiSource.update_interval_value, apiSource.update_interval_unit),
|
||||
lastPullLabel: formatLastPullLabel(apiSource, formatDashboardDate),
|
||||
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at)
|
||||
});
|
||||
});
|
||||
|
||||
return renderView('data-sources/api-sources/list', {
|
||||
title: 'API sources',
|
||||
active: 'api-sources',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
apiSources: apiSources
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
module.exports = require('../admin/data-sources');
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function buildDefaultRssFeed() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
feedUrl: '',
|
||||
updateIntervalValue: 60,
|
||||
updateIntervalUnit: 'minutes',
|
||||
itemLimit: 1
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function renderRssFeedFormPage(rssFeed, mode, message, currentUser) {
|
||||
const isEdit = mode === 'edit';
|
||||
const viewRssFeed = Object.assign(buildDefaultRssFeed(), rssFeed || {});
|
||||
|
||||
return renderView(isEdit ? 'data-sources/rss-feeds/edit' : 'data-sources/rss-feeds/add', {
|
||||
title: isEdit ? 'Edit RSS feed' : 'Add RSS feed',
|
||||
active: 'rss-feeds',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
rssFeed: viewRssFeed,
|
||||
pulledItems: viewRssFeed.pulledItems || [],
|
||||
pullError: viewRssFeed.pullError || ''
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
const renderRssFeedFormPage = require('./add');
|
||||
|
||||
module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentUser) {
|
||||
const viewData = Object.assign({
|
||||
pulledItems: [],
|
||||
pullError: ''
|
||||
}, data || {});
|
||||
|
||||
return renderRssFeedFormPage(Object.assign({}, rssFeed, {
|
||||
pulledItems: viewData.pulledItems,
|
||||
pullError: viewData.pullError
|
||||
}), 'edit', message, currentUser);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function formatIntervalLabel(interval, unit) {
|
||||
const value = Math.max(1, Number(interval) || 0);
|
||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes';
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return value === 1 ? 'Every second' : `Every ${value} seconds`;
|
||||
}
|
||||
return value === 1 ? 'Every minute' : `Every ${value} minutes`;
|
||||
}
|
||||
|
||||
function formatItemLabel(count) {
|
||||
const value = Math.max(1, Number(count) || 0);
|
||||
return value === 1 ? 'Latest item' : `Latest ${value} items`;
|
||||
}
|
||||
|
||||
module.exports = function renderRssFeedsPage(data, message, currentUser) {
|
||||
const rssFeeds = (data.rssFeeds || []).map(function (rssFeed) {
|
||||
return Object.assign({}, rssFeed, {
|
||||
intervalLabel: formatIntervalLabel(rssFeed.update_interval_value, rssFeed.update_interval_unit),
|
||||
itemLabel: formatItemLabel(rssFeed.item_limit)
|
||||
});
|
||||
});
|
||||
|
||||
return renderView('data-sources/rss-feeds/list', {
|
||||
title: 'RSS feeds',
|
||||
active: 'rss-feeds',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
rssFeeds: rssFeeds
|
||||
});
|
||||
};
|
||||
@@ -42,7 +42,7 @@ module.exports = function renderErrorPage(options, currentUser) {
|
||||
const errorOptions = options || {};
|
||||
const statusCode = Number(errorOptions.statusCode || 500);
|
||||
const copy = getErrorCopy(statusCode, String(errorOptions.message || '').trim());
|
||||
return renderView('error', {
|
||||
return renderView('error/error', {
|
||||
title: String(errorOptions.title || copy.title || 'Error').trim(),
|
||||
active: '',
|
||||
messageVariant: 'primary',
|
||||
@@ -51,7 +51,7 @@ module.exports = function renderErrorPage(options, currentUser) {
|
||||
errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(),
|
||||
errorMessage: String(errorOptions.message || copy.errorMessage || 'An unexpected error occurred.').trim(),
|
||||
detail: String(errorOptions.detail || '').trim(),
|
||||
backUrl: String(errorOptions.backUrl || '/admin').trim() || '/admin',
|
||||
backUrl: String(errorOptions.backUrl || '/dashboard').trim() || '/dashboard',
|
||||
backLabel: String(errorOptions.backLabel || copy.backLabel || 'Back to dashboard').trim() || 'Back to dashboard',
|
||||
searchUrl: String(errorOptions.searchUrl || '').trim(),
|
||||
bodyClass: 'error-page bg-dark text-white',
|
||||
|
||||
@@ -10,6 +10,12 @@ module.exports = {
|
||||
renderPlaylistFormPage: require('./playlists/add'),
|
||||
renderPlaylistEditPage: require('./playlists/edit'),
|
||||
renderPlaylistSlideConfigPage: require('./playlists/slide-config'),
|
||||
renderApiSourcesPage: require('./data-sources/api-sources/list'),
|
||||
renderApiSourceFormPage: require('./data-sources/api-sources/add'),
|
||||
renderApiSourceEditPage: require('./data-sources/api-sources/edit'),
|
||||
renderRssFeedsPage: require('./data-sources/rss-feeds/list'),
|
||||
renderRssFeedFormPage: require('./data-sources/rss-feeds/add'),
|
||||
renderRssFeedEditPage: require('./data-sources/rss-feeds/edit'),
|
||||
renderScreensPage: require('./screens/list'),
|
||||
renderScreenFormPage: require('./screens/add'),
|
||||
renderScreenEditPage: require('./screens/edit'),
|
||||
@@ -21,6 +27,7 @@ module.exports = {
|
||||
renderCanvasSizesPage: require('./canvas-sizes/list'),
|
||||
renderCanvasSizeFormPage: require('./canvas-sizes/add'),
|
||||
renderCanvasSizeEditPage: require('./canvas-sizes/edit'),
|
||||
renderBackgroundTasksPage: require('./settings/background-tasks-page'),
|
||||
renderErrorPage: require('./error'),
|
||||
renderRbacPage: require('./rbac/list'),
|
||||
renderRbacAddPage: require('./rbac/add'),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { hasAnyPermission } = require('../../../rbac');
|
||||
|
||||
const TASKS_PER_PAGE = 10;
|
||||
|
||||
function formatIntervalLabel(intervalMs) {
|
||||
const value = Math.max(1, Number(intervalMs) || 0);
|
||||
if (value % 60000 === 0) {
|
||||
const minutes = Math.max(1, value / 60000);
|
||||
return minutes === 1 ? 'Every minute' : `Every ${minutes} minutes`;
|
||||
}
|
||||
if (value % 1000 === 0) {
|
||||
const seconds = Math.max(1, value / 1000);
|
||||
return seconds === 1 ? 'Every second' : `Every ${seconds} seconds`;
|
||||
}
|
||||
return `${value} ms`;
|
||||
}
|
||||
|
||||
function parsePageNumber(value) {
|
||||
const pageNumber = Math.floor(Number(value) || 1);
|
||||
return Math.max(1, pageNumber);
|
||||
}
|
||||
|
||||
function buildPagination(totalItems, currentPage, pageParam) {
|
||||
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / TASKS_PER_PAGE));
|
||||
const safeCurrentPage = Math.min(Math.max(1, currentPage), totalPages);
|
||||
const startIndex = (safeCurrentPage - 1) * TASKS_PER_PAGE;
|
||||
const endIndex = Math.min(totalItems, startIndex + TASKS_PER_PAGE);
|
||||
const pages = [];
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
|
||||
pages.push({
|
||||
number: pageNumber,
|
||||
active: pageNumber === safeCurrentPage,
|
||||
url: `?${normalizedPageParam}=${pageNumber}`
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage: safeCurrentPage,
|
||||
totalPages: totalPages,
|
||||
totalItems: totalItems,
|
||||
hasMultiplePages: totalPages > 1,
|
||||
startItem: totalItems === 0 ? 0 : startIndex + 1,
|
||||
endItem: endIndex,
|
||||
hasPrevious: safeCurrentPage > 1,
|
||||
hasNext: safeCurrentPage < totalPages,
|
||||
previousUrl: `?${normalizedPageParam}=${safeCurrentPage - 1}`,
|
||||
nextUrl: `?${normalizedPageParam}=${safeCurrentPage + 1}`,
|
||||
pages: pages,
|
||||
pageSize: TASKS_PER_PAGE,
|
||||
pageParam: normalizedPageParam
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function renderBackgroundTasksPage(data, message, currentUser) {
|
||||
const tasks = (data && data.tasks) || [];
|
||||
const recurringTasks = (data && data.recurringTasks) || [];
|
||||
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
|
||||
const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']);
|
||||
const pagination = buildPagination(tasks.length, parsePageNumber(data && data.page), 'page');
|
||||
const recurringPagination = buildPagination(recurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage');
|
||||
const visibleTasks = tasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE);
|
||||
const visibleRecurringTasks = recurringTasks.slice((recurringPagination.currentPage - 1) * TASKS_PER_PAGE, recurringPagination.currentPage * TASKS_PER_PAGE);
|
||||
|
||||
return renderView('settings/background-tasks/index', {
|
||||
title: 'Background tasks',
|
||||
active: 'background-tasks',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
scripts: ['js/settings/background-tasks.js'],
|
||||
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
|
||||
tasks: visibleTasks,
|
||||
recurringTasks: visibleRecurringTasks.map(function (task) {
|
||||
return Object.assign({}, task, {
|
||||
intervalLabel: formatIntervalLabel(task.intervalMs)
|
||||
});
|
||||
}),
|
||||
summary: summary,
|
||||
pagination: pagination,
|
||||
recurringPagination: recurringPagination,
|
||||
canManage: canManage,
|
||||
canClearFinished: tasks.some(function (task) {
|
||||
return task.status !== 'queued' && task.status !== 'running';
|
||||
})
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
const { hasAnyPermission } = require('../../../rbac');
|
||||
|
||||
function requireSettingsAccess() {
|
||||
return function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['background-tasks.read', 'background-tasks.allow'])) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function registerAdminSettingsRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
|
||||
if (!pages || !backgroundTaskQueue) {
|
||||
throw new Error('registerAdminSettingsRoutes requires pages and backgroundTaskQueue.');
|
||||
}
|
||||
|
||||
function buildQueueVersion(tasks, recurringTasks, summary) {
|
||||
return [
|
||||
'q:' + Number(summary && summary.counts && summary.counts.queued || 0),
|
||||
'r:' + Number(summary && summary.counts && summary.counts.running || 0),
|
||||
'f:' + Number(summary && summary.counts && summary.counts.failed || 0),
|
||||
'c:' + Number(summary && summary.counts && summary.counts.completed || 0),
|
||||
'a:' + Number(summary && summary.activeCount || 0),
|
||||
't:' + Number(summary && summary.total || 0),
|
||||
'tasks:' + (tasks || []).map(function (task) {
|
||||
return [task.id, task.status, task.startedAt || '', task.finishedAt || '', task.errorMessage || '', task.attempts || 0].join(':');
|
||||
}).join('|'),
|
||||
'recurring:' + (recurringTasks || []).map(function (task) {
|
||||
return [task.key, task.nextRunAt || '', task.lastRunAt || '', task.lastStatus || '', task.lastError || ''].join(':');
|
||||
}).join('|')
|
||||
].join('~');
|
||||
}
|
||||
|
||||
app.get('/settings/background-tasks', requireSettingsAccess(), function (req, res) {
|
||||
const tasks = backgroundTaskQueue.listTasks();
|
||||
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
||||
const summary = backgroundTaskQueue.getSummary();
|
||||
const version = buildQueueVersion(tasks, recurringTasks, summary);
|
||||
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.get('/settings/background-tasks/state', requireSettingsAccess(), function (req, res) {
|
||||
const tasks = backgroundTaskQueue.listTasks();
|
||||
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
||||
const summary = backgroundTaskQueue.getSummary();
|
||||
const version = buildQueueVersion(tasks, recurringTasks, summary);
|
||||
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
||||
res.json({
|
||||
version: version,
|
||||
summary: summary,
|
||||
taskCount: tasks.length,
|
||||
recurringCount: recurringTasks.length
|
||||
});
|
||||
});
|
||||
|
||||
function requireSettingsManageAccess() {
|
||||
return function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['background-tasks.allow'])) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
};
|
||||
}
|
||||
|
||||
app.post('/settings/background-tasks/clear-finished', requireSettingsManageAccess(), function (req, res) {
|
||||
const removedCount = backgroundTaskQueue.clearFinishedTasks();
|
||||
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(removedCount ? 'Cleared ' + removedCount + ' finished task' + (removedCount === 1 ? '' : 's') + '.' : 'No finished tasks to clear.'));
|
||||
});
|
||||
|
||||
app.post('/settings/background-tasks/:id/cancel', requireSettingsManageAccess(), function (req, res) {
|
||||
const canceled = backgroundTaskQueue.cancelTask(Number(req.params.id));
|
||||
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(canceled ? 'Task canceled.' : 'Unable to cancel that task.'));
|
||||
});
|
||||
|
||||
app.post('/settings/background-tasks/:id/retry', requireSettingsManageAccess(), function (req, res) {
|
||||
const retryTask = backgroundTaskQueue.retryTask(Number(req.params.id));
|
||||
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(retryTask ? 'Task requeued.' : 'Unable to retry that task.'));
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderRbacAddPage(message, currentUser, formValues, permissionGroups, messageVariant) {
|
||||
return renderView('rbac/add', {
|
||||
return renderView('settings/rbac/add', {
|
||||
title: 'Create role',
|
||||
active: 'rbac',
|
||||
message: message,
|
||||
@@ -1,7 +1,7 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users) {
|
||||
return renderView('rbac/edit', {
|
||||
return renderView('settings/rbac/edit', {
|
||||
title: 'Edit role',
|
||||
active: 'rbac',
|
||||
message: message,
|
||||
@@ -1,7 +1,7 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderRbacPage(data, message, currentUser) {
|
||||
return renderView('rbac/list', {
|
||||
return renderView('settings/rbac/list', {
|
||||
title: 'Roles and permissions',
|
||||
active: 'rbac',
|
||||
message: message,
|
||||
@@ -1,7 +1,7 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderUsersAddPage(message, currentUser, roles, formValues, messageVariant) {
|
||||
return renderView('users/add', {
|
||||
return renderView('settings/users/add', {
|
||||
title: 'Add user',
|
||||
active: 'users',
|
||||
message: message,
|
||||
@@ -1,7 +1,7 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderUsersEditPage(user, message, currentUser, roles) {
|
||||
return renderView('users/edit', {
|
||||
return renderView('settings/users/edit', {
|
||||
title: 'Edit user',
|
||||
active: 'users',
|
||||
message: message,
|
||||
@@ -1,7 +1,7 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderUsersPage(data, message, currentUser) {
|
||||
return renderView('users/list', {
|
||||
return renderView('settings/users/list', {
|
||||
title: 'Users',
|
||||
active: 'users',
|
||||
message: message,
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function buildDefaultCanvasSize() {
|
||||
return {
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderCanvasSizesPage(data, message, currentUser) {
|
||||
const canvasSizes = (data.canvasSizes || []).map((size) => ({
|
||||
@@ -0,0 +1,16 @@
|
||||
module.exports = function registerCanvasSizeRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
const common = deps.common;
|
||||
const pool = deps.pool;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchCanvasSizesData(pool);
|
||||
res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderConnectedClientsPage(data, message, currentUser) {
|
||||
return renderView('clients/list', {
|
||||
@@ -0,0 +1,16 @@
|
||||
module.exports = function registerClientsRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const pages = deps.pages;
|
||||
const buildDashboardState = deps.buildDashboardState;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
app.get('/clients', requirePermission('clients.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await buildDashboardState(pool);
|
||||
res.send(pages.renderConnectedClientsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderDashboardPage(data, message, currentUser) {
|
||||
return renderView('dashboard/index', {
|
||||
@@ -0,0 +1,15 @@
|
||||
module.exports = function registerDashboardRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const pages = deps.pages;
|
||||
const buildDashboardState = deps.buildDashboardState;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
app.get('/dashboard', requirePermission('dashboard.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await buildDashboardState(pool);
|
||||
res.send(pages.renderDashboardPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderPlaylistFormPage(message, currentUser) {
|
||||
return renderView('playlists/add', {
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderPlaylistsPage(data, message, currentUser) {
|
||||
const playlistSlides = data.playlistSlides || [];
|
||||
@@ -0,0 +1,47 @@
|
||||
module.exports = function registerPlaylistsRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
function requireQueryPermission(readPermissionKey, editPermissionKey) {
|
||||
return function (req, res, next) {
|
||||
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
|
||||
return requirePermission(permissionKey)(req, res, next);
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/playlists', requireQueryPermission('playlists.read', 'playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
if (req.query.edit) {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
}
|
||||
res.send(pages.renderPlaylistsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/playlists/new', requirePermission('playlists.create'), function (req, res) {
|
||||
res.send(pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.get('/playlists/:id/edit', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const data = await common.fetchAdminData(pool);
|
||||
res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const { renderFragment } = require('../../view');
|
||||
const { renderFragment } = require('../../../view');
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: 0, label: 'Sun' },
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderScreenFormPage(data, message, currentUser) {
|
||||
return renderView('screens/add', {
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderScreenEditPage(screen, data, message, currentUser) {
|
||||
return renderView('screens/edit', {
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderScreensPage(data, message, currentUser) {
|
||||
return renderView('screens/list', {
|
||||
@@ -0,0 +1,45 @@
|
||||
module.exports = function registerScreensRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const buildDashboardState = deps.buildDashboardState;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
function requireQueryPermission(readPermissionKey, editPermissionKey) {
|
||||
return function (req, res, next) {
|
||||
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
|
||||
return requirePermission(permissionKey)(req, res, next);
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await buildDashboardState(pool);
|
||||
if (req.query.edit) {
|
||||
const screen = await common.fetchScreenById(pool, Number(req.query.edit));
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
const editData = await common.fetchScreenEditData(pool);
|
||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
}
|
||||
res.send(pages.renderScreensPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/screens/:id/edit', requirePermission('screens.update'), async function (req, res, next) {
|
||||
try {
|
||||
const screen = await common.fetchScreenById(pool, Number(req.params.id));
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
const editData = await common.fetchScreenEditData(pool);
|
||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderSlideFormPage(data, mode, slide, message, currentUser) {
|
||||
const templateRegions = data.templateRegions || [];
|
||||
const rssFeeds = data.rssFeeds || [];
|
||||
const apiSources = data.apiSources || [];
|
||||
const templates = (data.templates || []).map((template) => ({
|
||||
...template,
|
||||
canvas_size_width: template.canvas_size_width,
|
||||
@@ -14,15 +16,18 @@ module.exports = function renderSlideFormPage(data, mode, slide, message, curren
|
||||
message: message,
|
||||
isEdit: mode === 'edit',
|
||||
saveLabel: 'Save',
|
||||
action: mode === 'edit' ? `/admin/slides/${slide.id}` : '/admin/slides',
|
||||
action: mode === 'edit' ? `/slides/${slide.id}` : '/slides',
|
||||
slide: slide || { title: '', template_id: null, content: {} },
|
||||
templates: templates,
|
||||
stylesheets: ['js/vendor/ckeditor5/ckeditor5.css'],
|
||||
slideEditorData: {
|
||||
templates: templates,
|
||||
rssFeeds: rssFeeds,
|
||||
apiSources: apiSources,
|
||||
existingTemplateId: slide && slide.template_id ? slide.template_id : null,
|
||||
existingContent: slide && slide.content ? slide.content : {}
|
||||
},
|
||||
currentUser: currentUser || null
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderSlidesPage(data, message, currentUser) {
|
||||
return renderView('slides/list', {
|
||||
@@ -0,0 +1,16 @@
|
||||
module.exports = function registerSlidesRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
const common = deps.common;
|
||||
const pool = deps.pool;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function buildDefaultTemplate() {
|
||||
return {
|
||||
@@ -9,16 +9,7 @@ function buildDefaultTemplate() {
|
||||
canvas_size_height: 1080,
|
||||
background_color: '#111111',
|
||||
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
|
||||
}]
|
||||
regions: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,9 +42,6 @@ function resolveTemplateCanvasSize(template, canvasSizes) {
|
||||
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',
|
||||
@@ -1,4 +1,4 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderTemplatesPage(data, message, currentUser) {
|
||||
const templates = (data.templates || []).map((template) => ({
|
||||
@@ -0,0 +1,16 @@
|
||||
module.exports = function registerTemplatesRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
const common = deps.common;
|
||||
const pool = deps.pool;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchTemplatesData(pool);
|
||||
res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
+18
-4
@@ -6,6 +6,7 @@ const { hasPermission, hasAnyPermission } = require('../rbac');
|
||||
|
||||
const VIEWS_ROOT = path.join(__dirname, 'views');
|
||||
const cache = new Map();
|
||||
const SIGNAGE_VIEW_PREFIXES = new Set(['dashboard', 'clients', 'screens', 'playlists', 'slides', 'canvas-sizes', 'templates']);
|
||||
|
||||
function inferMessageVariant(message, fallbackVariant) {
|
||||
const text = String(message || '').trim();
|
||||
@@ -13,7 +14,11 @@ function inferMessageVariant(message, fallbackVariant) {
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
return String(fallbackVariant || '').trim().toLowerCase() || 'primary';
|
||||
if (/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i.test(text)) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return String(fallbackVariant || '').trim().toLowerCase() || 'info';
|
||||
}
|
||||
|
||||
Handlebars.registerHelper('eq', function (left, right) {
|
||||
@@ -104,8 +109,17 @@ Handlebars.registerHelper('saveActionButtons', function (options) {
|
||||
].join(''));
|
||||
});
|
||||
|
||||
function resolveTemplatePath(relativePath) {
|
||||
const firstSegment = String(relativePath || '').split(/[\\/]/)[0];
|
||||
if (SIGNAGE_VIEW_PREFIXES.has(firstSegment)) {
|
||||
return path.join(VIEWS_ROOT, 'signage', relativePath);
|
||||
}
|
||||
|
||||
return path.join(VIEWS_ROOT, relativePath);
|
||||
}
|
||||
|
||||
function loadTemplate(relativePath) {
|
||||
const filePath = path.join(VIEWS_ROOT, relativePath);
|
||||
const filePath = resolveTemplatePath(relativePath);
|
||||
const stat = fs.statSync(filePath);
|
||||
const cached = cache.get(filePath);
|
||||
if (cached && cached.mtimeMs === stat.mtimeMs) {
|
||||
@@ -122,7 +136,7 @@ function renderView(viewName, context) {
|
||||
viewContext.messageVariant = inferMessageVariant(viewContext.message, 'primary');
|
||||
}
|
||||
const page = loadTemplate(`${viewName}.hbs`);
|
||||
const layout = loadTemplate(path.join('layout.hbs'));
|
||||
const layout = loadTemplate(path.join('shared', 'layout.hbs'));
|
||||
const body = page(viewContext);
|
||||
return layout(Object.assign({}, viewContext, { body: body }));
|
||||
}
|
||||
@@ -133,7 +147,7 @@ function renderFragment(viewName, context) {
|
||||
viewContext.messageVariant = inferMessageVariant(viewContext.message, 'primary');
|
||||
}
|
||||
const page = loadTemplate(`${viewName}.hbs`);
|
||||
const layout = loadTemplate(path.join('frame-layout.hbs'));
|
||||
const layout = loadTemplate(path.join('shared', 'frame-layout.hbs'));
|
||||
const body = page(viewContext);
|
||||
return layout(Object.assign({}, viewContext, { body: body }));
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Display name</h3>
|
||||
</div>
|
||||
<form id="account-name-form" method="post" action="/admin/account/name" data-async-save data-async-save-close-url="{{returnUrl}}">
|
||||
<form id="account-name-form" method="post" action="/account/name" data-async-save data-async-save-close-url="{{returnUrl}}">
|
||||
<input type="hidden" name="return_url" value="{{returnUrl}}" />
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
@@ -21,7 +21,7 @@
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="Display name actions">
|
||||
{{{saveActionButtons formId="account-name-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
|
||||
<a class="btn btn-warning" href="/admin" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
<a class="btn btn-warning" href="/dashboard" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Password</h3>
|
||||
</div>
|
||||
<form id="account-password-form" method="post" action="/admin/account/password" data-async-save data-async-save-close-url="{{returnUrl}}">
|
||||
<form id="account-password-form" method="post" action="/account/password" data-async-save data-async-save-close-url="{{returnUrl}}">
|
||||
<input type="hidden" name="return_url" value="{{returnUrl}}" />
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
@@ -52,10 +52,10 @@
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="Password actions">
|
||||
{{{saveActionButtons formId="account-password-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
|
||||
<a class="btn btn-warning" href="/admin" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
<a class="btn btn-warning" href="/dashboard" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<div class="card mx-auto" style="max-width: 26rem;">
|
||||
<div class="card-body p-4">
|
||||
<h1 class="h4 mb-3">Sign in</h1>
|
||||
{{#if message}}
|
||||
<div class="alert alert-warning">{{message}}</div>
|
||||
{{/if}}
|
||||
<form method="post" action="/login">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input id="username" name="username" type="text" class="form-control" autocomplete="username" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input id="password" name="password" type="password" class="form-control" autocomplete="current-password" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Add API source</h2>
|
||||
<p>Capture an API URL, refresh interval, and store the latest JSON response.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">API source details</h3>
|
||||
</div>
|
||||
<form id="api-source-form" method="post" action="/data-sources/api-sources" data-async-save data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="api-source-name" class="form-label">Name</label>
|
||||
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="api-source-url" class="form-label">API URL</label>
|
||||
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" placeholder="https://example.com/api.json" required />
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="api-source-interval" class="form-label">Update interval</label>
|
||||
<input id="api-source-interval" name="update_interval_value" type="number" min="1" class="form-control" value="{{apiSource.updateIntervalValue}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="api-source-interval-unit" class="form-label">Unit</label>
|
||||
<select id="api-source-interval-unit" name="update_interval_unit" class="form-select">
|
||||
<option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
||||
<option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="API source actions">
|
||||
{{{saveActionButtons formId="api-source-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
|
||||
<a class="btn btn-warning" href="/data-sources/api-sources" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,94 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Edit API source - {{apiSource.name}}</h2>
|
||||
<p>Adjust the endpoint or refresh cadence and review the latest JSON response.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">API source details</h3>
|
||||
</div>
|
||||
<form id="api-source-form" method="post" action="/data-sources/api-sources/{{apiSource.id}}" data-async-save data-async-save-refresh-target="#api-source-response-panel" data-async-save-refresh-state-url="/data-sources/api-sources/{{apiSource.id}}/state" data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="api-source-name" class="form-label">Name</label>
|
||||
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="api-source-url" class="form-label">API URL</label>
|
||||
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" placeholder="https://example.com/api.json" required />
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="api-source-interval" class="form-label">Update interval</label>
|
||||
<input id="api-source-interval" name="update_interval_value" type="number" min="1" class="form-control" value="{{apiSource.updateIntervalValue}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="api-source-interval-unit" class="form-label">Unit</label>
|
||||
<select id="api-source-interval-unit" name="update_interval_unit" class="form-select">
|
||||
<option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
||||
<option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="API source actions">
|
||||
{{{saveActionButtons formId="api-source-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
|
||||
<a class="btn btn-warning" href="/data-sources/api-sources" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
<button type="submit" class="btn btn-danger" form="delete-api-source-form">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-secondary mt-4" id="api-source-response-panel">
|
||||
<div class="card-header d-flex align-items-center gap-2">
|
||||
<h3 class="card-title mb-0">Latest response</h3>
|
||||
{{#if apiSource.lastResponseJson}}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary btn-sm ms-auto"
|
||||
data-json-toggle
|
||||
data-json-toggle-label-formatted="Format JSON"
|
||||
data-json-toggle-label-compact="Unformat JSON"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<i class="bi bi-filetype-json me-1"></i>
|
||||
<span data-json-toggle-label>Format JSON</span>
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{#if apiSource.lastPullError}}
|
||||
<div class="alert alert-warning">{{apiSource.lastPullError}}</div>
|
||||
{{/if}}
|
||||
<dl class="row">
|
||||
<dt class="col-12 col-md-3">Last pulled</dt>
|
||||
<dd class="col-12 col-md-9">
|
||||
{{#if apiSource.lastPulledAtValue}}
|
||||
<time data-local-datetime datetime="{{apiSource.lastPulledAtValue}}">{{#if apiSource.lastPulledAtLabel}}{{apiSource.lastPulledAtLabel}}{{else}}{{apiSource.lastPulledAtValue}}{{/if}}</time>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Status</dt>
|
||||
<dd class="col-12 col-md-9">{{#if apiSource.lastResponseStatus}}{{apiSource.lastResponseStatus}}{{else}}-{{/if}}</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Content type</dt>
|
||||
<dd class="col-12 col-md-9">{{#if apiSource.lastResponseContentType}}{{apiSource.lastResponseContentType}}{{else}}-{{/if}}</dd>
|
||||
</dl>
|
||||
{{#if apiSource.lastResponseJson}}
|
||||
<div data-json-toggle-panel>
|
||||
<script type="application/json" data-json-toggle-source>{{json apiSource.lastResponseJson}}</script>
|
||||
<pre class="mb-0 small bg-body-tertiary border rounded p-3" style="white-space: pre-wrap; word-break: break-word;" data-json-toggle-output>{{apiSource.lastResponseJson}}</pre>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="alert alert-secondary mb-0">No JSON response is stored yet.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="delete-api-source-form" method="post" action="/data-sources/api-sources/{{apiSource.id}}/delete" data-confirm-message="Delete this API source?"></form>
|
||||
@@ -0,0 +1,77 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>API sources</h2>
|
||||
<p>Store API endpoints, refresh cadence, and the latest JSON response pulled from each endpoint.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Saved sources</h3>
|
||||
{{#if (hasPermission currentUser 'api-sources.create')}}
|
||||
<div class="card-tools">
|
||||
<a class="btn btn-primary btn-sm" href="/data-sources/api-sources/new">Add API source</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>API URL</th>
|
||||
<th>Refresh interval</th>
|
||||
<th>Last pulled</th>
|
||||
<th>Last response</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#if apiSources.length}}
|
||||
{{#each apiSources}}
|
||||
<tr>
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="API URL" class="text-break">{{api_url}}</td>
|
||||
<td data-label="Refresh interval">{{intervalLabel}}</td>
|
||||
<td data-label="Last pulled">
|
||||
{{#if lastPulledAtValue}}
|
||||
<time data-local-datetime datetime="{{lastPulledAtValue}}">{{lastPullLabel}}</time>
|
||||
{{else}}
|
||||
{{lastPullLabel}}
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Last response">
|
||||
{{#if last_pull_error}}
|
||||
<span class="text-danger">Failed</span>
|
||||
{{else if last_response_status}}
|
||||
<span>{{last_response_status}}</span>
|
||||
{{#if last_response_content_type}}<span class="text-muted small ms-2">{{last_response_content_type}}</span>{{/if}}
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'api-sources.update' 'api-sources.delete')}}
|
||||
<div class="actions">
|
||||
{{#if (hasPermission ../currentUser 'api-sources.update')}}
|
||||
<a class="btn btn-sm btn-primary" href="/data-sources/api-sources/{{id}}/edit">Edit</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'api-sources.delete')}}
|
||||
<form class="inline-form" method="post" action="/data-sources/api-sources/{{id}}/delete" data-confirm-message="Delete this API source?">
|
||||
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="6" class="empty">No API sources yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Add RSS feed</h2>
|
||||
<p>Capture a feed URL, refresh interval, and the number of items to store.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">RSS feed details</h3>
|
||||
</div>
|
||||
<form id="rss-feed-form" method="post" action="/data-sources/rss-feeds" data-async-save data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-url="/data-sources/rss-feeds/new">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="rss-feed-name" class="form-label">Name</label>
|
||||
<input id="rss-feed-name" name="name" class="form-control" value="{{rssFeed.name}}" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="rss-feed-url" class="form-label">Feed URL</label>
|
||||
<input id="rss-feed-url" name="feed_url" type="url" class="form-control" value="{{rssFeed.feedUrl}}" placeholder="https://example.com/feed.xml" required />
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="rss-feed-interval" class="form-label">Update interval</label>
|
||||
<input id="rss-feed-interval" name="update_interval_value" type="number" min="1" class="form-control" value="{{rssFeed.updateIntervalValue}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="rss-feed-interval-unit" class="form-label">Unit</label>
|
||||
<select id="rss-feed-interval-unit" name="update_interval_unit" class="form-select">
|
||||
<option value="seconds" {{#if (eq rssFeed.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
||||
<option value="minutes" {{#if (eq rssFeed.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="rss-feed-item-limit" class="form-label">Items pulled</label>
|
||||
<input id="rss-feed-item-limit" name="item_limit" type="number" min="1" class="form-control" value="{{rssFeed.itemLimit}}" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="RSS feed actions">
|
||||
{{{saveActionButtons formId="rss-feed-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
|
||||
<a class="btn btn-warning" href="/data-sources/rss-feeds" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,185 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Edit RSS feed - {{rssFeed.name}}</h2>
|
||||
<p>Adjust the source URL, refresh interval, or item count.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">RSS feed details</h3>
|
||||
</div>
|
||||
<form id="rss-feed-form" method="post" action="/data-sources/rss-feeds/{{rssFeed.id}}" data-async-save data-async-save-refresh-target="#rss-feed-items-panel" data-async-save-refresh-state-url="/data-sources/rss-feeds/{{rssFeed.id}}/state" data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-url="/data-sources/rss-feeds/new">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="rss-feed-name" class="form-label">Name</label>
|
||||
<input id="rss-feed-name" name="name" class="form-control" value="{{rssFeed.name}}" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="rss-feed-url" class="form-label">Feed URL</label>
|
||||
<input id="rss-feed-url" name="feed_url" type="url" class="form-control" value="{{rssFeed.feedUrl}}" placeholder="https://example.com/feed.xml" required />
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="rss-feed-interval" class="form-label">Update interval</label>
|
||||
<input id="rss-feed-interval" name="update_interval_value" type="number" min="1" class="form-control" value="{{rssFeed.updateIntervalValue}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="rss-feed-interval-unit" class="form-label">Unit</label>
|
||||
<select id="rss-feed-interval-unit" name="update_interval_unit" class="form-select">
|
||||
<option value="seconds" {{#if (eq rssFeed.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
||||
<option value="minutes" {{#if (eq rssFeed.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="rss-feed-item-limit" class="form-label">Items pulled</label>
|
||||
<input id="rss-feed-item-limit" name="item_limit" type="number" min="1" class="form-control" value="{{rssFeed.itemLimit}}" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="RSS feed actions">
|
||||
{{{saveActionButtons formId="rss-feed-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
|
||||
<a class="btn btn-warning" href="/data-sources/rss-feeds" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
<button type="submit" class="btn btn-danger" form="delete-rss-feed-form">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-secondary mt-4" id="rss-feed-items-panel">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Pulled items</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{#if pullError}}
|
||||
<div class="alert alert-warning">{{pullError}}</div>
|
||||
{{/if}}
|
||||
{{#if pulledItems.length}}
|
||||
<div class="accordion rss-feed-items-accordion" id="rss-feed-items-accordion">
|
||||
{{#each pulledItems}}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="rss-feed-item-heading-{{id}}-{{position}}">
|
||||
<button class="accordion-button {{#unless @first}}collapsed{{/unless}}" type="button" data-bs-toggle="collapse" data-bs-target="#rss-feed-item-collapse-{{id}}-{{position}}" aria-expanded="{{#if @first}}true{{else}}false{{/if}}" aria-controls="rss-feed-item-collapse-{{id}}-{{position}}">
|
||||
<span class="me-2">{{title}}</span>
|
||||
<span class="text-muted small">{{#if pubDate}}• {{pubDate}}{{/if}}</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="rss-feed-item-collapse-{{id}}-{{position}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="rss-feed-item-heading-{{id}}-{{position}}" data-bs-parent="#rss-feed-items-accordion">
|
||||
<div class="accordion-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-12 col-md-3">Title</dt>
|
||||
<dd class="col-12 col-md-9">{{title}}</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Link</dt>
|
||||
<dd class="col-12 col-md-9">
|
||||
{{#if link}}
|
||||
<a href="{{link}}" target="_blank" rel="noopener noreferrer">{{link}}</a>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Published</dt>
|
||||
<dd class="col-12 col-md-9">{{#if pubDate}}{{pubDate}}{{else}}-{{/if}}</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Author</dt>
|
||||
<dd class="col-12 col-md-9">{{#if itemJson.author}}{{itemJson.author}}{{else}}-{{/if}}</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">GUID</dt>
|
||||
<dd class="col-12 col-md-9">{{#if itemJson.guid}}{{itemJson.guid}}{{else}}-{{/if}}</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Comments</dt>
|
||||
<dd class="col-12 col-md-9">
|
||||
{{#if itemJson.comments}}
|
||||
<a href="{{itemJson.comments}}" target="_blank" rel="noopener noreferrer">{{itemJson.comments}}</a>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Description</dt>
|
||||
<dd class="col-12 col-md-9">{{#if description}}{{description}}{{else}}<span class="empty">-</span>{{/if}}</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Categories</dt>
|
||||
<dd class="col-12 col-md-9">
|
||||
{{#if itemJson.categories.length}}
|
||||
<ul class="list-group list-group-flush border rounded">
|
||||
{{#each itemJson.categories}}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start">
|
||||
<span>{{value}}</span>
|
||||
{{#if domain}}<span class="text-muted small ms-3">{{domain}}</span>{{/if}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Enclosure</dt>
|
||||
<dd class="col-12 col-md-9">
|
||||
{{#if itemJson.enclosure}}
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-12 col-lg-4">URL</dt>
|
||||
<dd class="col-12 col-lg-8">
|
||||
{{#if itemJson.enclosure.url}}
|
||||
<a href="{{itemJson.enclosure.url}}" target="_blank" rel="noopener noreferrer">{{itemJson.enclosure.url}}</a>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
<dt class="col-12 col-lg-4">Length</dt>
|
||||
<dd class="col-12 col-lg-8">{{#if itemJson.enclosure.length}}{{itemJson.enclosure.length}}{{else}}-{{/if}}</dd>
|
||||
<dt class="col-12 col-lg-4">Type</dt>
|
||||
<dd class="col-12 col-lg-8">{{#if itemJson.enclosure.type}}{{itemJson.enclosure.type}}{{else}}-{{/if}}</dd>
|
||||
</dl>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Source</dt>
|
||||
<dd class="col-12 col-md-9 mb-0">
|
||||
{{#if itemJson.source}}
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-12 col-lg-4">Title</dt>
|
||||
<dd class="col-12 col-lg-8">{{#if itemJson.source.title}}{{itemJson.source.title}}{{else}}-{{/if}}</dd>
|
||||
<dt class="col-12 col-lg-4">URL</dt>
|
||||
<dd class="col-12 col-lg-8">
|
||||
{{#if itemJson.source.url}}
|
||||
<a href="{{itemJson.source.url}}" target="_blank" rel="noopener noreferrer">{{itemJson.source.url}}</a>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
</dl>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Raw item XML</dt>
|
||||
<dd class="col-12 col-md-9 mb-0">
|
||||
{{#if itemJson.rawXml}}
|
||||
<details>
|
||||
<summary class="small text-muted">Show raw XML</summary>
|
||||
<pre class="mb-0 mt-2 small bg-body-tertiary border rounded p-3 text-wrap">{{itemJson.rawXml}}</pre>
|
||||
</details>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="alert alert-secondary mb-0">No feed items could be loaded.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="delete-rss-feed-form" method="post" action="/data-sources/rss-feeds/{{rssFeed.id}}/delete" data-confirm-message="Delete this RSS feed?"></form>
|
||||
@@ -0,0 +1,60 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>RSS feeds</h2>
|
||||
<p>Store feed sources, refresh cadence, and how many entries to pull each time.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Saved feeds</h3>
|
||||
{{#if (hasPermission currentUser 'rss-feeds.create')}}
|
||||
<div class="card-tools">
|
||||
<a class="btn btn-primary btn-sm" href="/data-sources/rss-feeds/new">Add RSS feed</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Feed URL</th>
|
||||
<th>Refresh interval</th>
|
||||
<th>Items pulled</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#if rssFeeds.length}}
|
||||
{{#each rssFeeds}}
|
||||
<tr>
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="Feed URL" class="text-break">{{feed_url}}</td>
|
||||
<td data-label="Refresh interval">{{intervalLabel}}</td>
|
||||
<td data-label="Items pulled">{{itemLabel}}</td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'rss-feeds.update' 'rss-feeds.delete')}}
|
||||
<div class="actions">
|
||||
{{#if (hasPermission ../currentUser 'rss-feeds.update')}}
|
||||
<a class="btn btn-sm btn-primary" href="/data-sources/rss-feeds/{{id}}/edit">Edit</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'rss-feeds.delete')}}
|
||||
<form class="inline-form" method="post" action="/data-sources/rss-feeds/{{id}}/delete" data-confirm-message="Delete this RSS feed?">
|
||||
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="5" class="empty">No RSS feeds yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,15 +0,0 @@
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="text-center">
|
||||
<div class="display-1 fw-bold text-primary lh-1 mb-3">{{statusCode}}</div>
|
||||
<h1 class="h3 mb-3">{{errorTitle}}</h1>
|
||||
<p class="text-secondary mb-4">
|
||||
{{errorMessage}}
|
||||
</p>
|
||||
<a href="{{backUrl}}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1" aria-hidden="true"></i>
|
||||
{{backLabel}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<div class="card border-0 shadow-sm mx-auto" style="max-width: 48rem;">
|
||||
<div class="card-body p-4 p-md-5">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<div class="fs-1 text-danger">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-uppercase text-muted fw-semibold mb-1">{{statusCode}}</p>
|
||||
<h1 class="h3 mb-3">{{errorTitle}}</h1>
|
||||
<p class="mb-4">{{errorMessage}}</p>
|
||||
{{#if detail}}
|
||||
<pre class="small bg-body-tertiary border rounded p-3 mb-4 text-wrap">{{detail}}</pre>
|
||||
{{/if}}
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a class="btn btn-primary" href="{{backUrl}}">{{backLabel}}</a>
|
||||
{{#if searchUrl}}
|
||||
<a class="btn btn-outline-secondary" href="{{searchUrl}}">Search</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,28 +0,0 @@
|
||||
<div class="login-logo mb-3">
|
||||
<a href="/login" class="text-decoration-none fw-bold text-body">Pulse Signage</a>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary">
|
||||
<div class="card-header text-center">
|
||||
<h1 class="h4 mb-0">Sign in</h1>
|
||||
</div>
|
||||
<div class="card-body login-card-body">
|
||||
<p class="login-box-msg">Use your admin credentials to manage screens, playlists, and slides.</p>
|
||||
|
||||
{{#if message}}
|
||||
<div class="alert alert-danger py-2">{{message}}</div>
|
||||
{{/if}}
|
||||
|
||||
<form method="post" action="/login" class="d-grid gap-3">
|
||||
<div>
|
||||
<label for="login-username" class="form-label">Username</label>
|
||||
<input id="login-username" class="form-control form-control-lg" type="text" name="username" autocomplete="username" required autofocus />
|
||||
</div>
|
||||
<div>
|
||||
<label for="login-password" class="form-label">Password</label>
|
||||
<input id="login-password" class="form-control form-control-lg" type="password" name="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-lg">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,264 @@
|
||||
<div id="background-tasks-state" data-state-version="{{stateVersion}}" hidden></div>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Background tasks</h2>
|
||||
<p>Queued data refreshes run in the web process. Finished items stay here until you clear them.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card card-outline card-primary h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small text-uppercase">Queued</div>
|
||||
<div class="fs-3 fw-semibold">{{summary.counts.queued}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card card-outline card-info h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small text-uppercase">Running</div>
|
||||
<div class="fs-3 fw-semibold">{{summary.counts.running}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card card-outline card-danger h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small text-uppercase">Failed</div>
|
||||
<div class="fs-3 fw-semibold">{{summary.counts.failed}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card card-outline card-success h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small text-uppercase">Finished</div>
|
||||
<div class="fs-3 fw-semibold">{{summary.counts.completed}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary mb-4">
|
||||
<div class="card-header d-flex align-items-center gap-2">
|
||||
<h3 class="card-title mb-0">Task queue</h3>
|
||||
<div class="ms-auto d-flex align-items-center gap-2">
|
||||
{{#if canManage}}
|
||||
{{#if canClearFinished}}
|
||||
<form method="post" action="/settings/background-tasks/clear-finished">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm" data-confirm-message="Clear finished background tasks?">Clear finished</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<span class="text-muted small">Active workers: {{summary.activeCount}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task</th>
|
||||
<th>Category</th>
|
||||
<th>Status</th>
|
||||
<th>Queued</th>
|
||||
<th>Started</th>
|
||||
<th>Finished</th>
|
||||
<th>Source</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#if tasks.length}}
|
||||
{{#each tasks}}
|
||||
<tr>
|
||||
<td data-label="Task">
|
||||
<div class="fw-semibold">{{title}}</div>
|
||||
{{#if key}}
|
||||
<div class="text-muted small text-break">{{key}}</div>
|
||||
{{/if}}
|
||||
{{#if errorMessage}}
|
||||
<div class="text-danger small mt-1 text-break">{{errorMessage}}</div>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Category">{{category}}</td>
|
||||
<td data-label="Status">
|
||||
{{#if (eq status 'queued')}}
|
||||
<span class="badge text-bg-secondary">Queued</span>
|
||||
{{else if (eq status 'running')}}
|
||||
<span class="badge text-bg-primary">Running</span>
|
||||
{{else if (eq status 'completed')}}
|
||||
<span class="badge text-bg-success">Completed</span>
|
||||
{{else if (eq status 'failed')}}
|
||||
<span class="badge text-bg-danger">Failed</span>
|
||||
{{else if (eq status 'canceled')}}
|
||||
<span class="badge text-bg-warning">Canceled</span>
|
||||
{{else}}
|
||||
<span class="badge text-bg-light text-dark">{{status}}</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Queued">
|
||||
{{#if createdAt}}
|
||||
<time data-local-datetime datetime="{{createdAt}}">{{createdAt}}</time>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Started">
|
||||
{{#if startedAt}}
|
||||
<time data-local-datetime datetime="{{startedAt}}">{{startedAt}}</time>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Finished">
|
||||
{{#if finishedAt}}
|
||||
<time data-local-datetime datetime="{{finishedAt}}">{{finishedAt}}</time>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Source">
|
||||
{{#if metadata.sourceName}}
|
||||
<div>{{metadata.sourceName}}</div>
|
||||
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{{#if canManage}}
|
||||
{{#if (eq status 'queued')}}
|
||||
<form method="post" action="/settings/background-tasks/{{id}}/cancel">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm-message="Cancel this queued task?">Cancel</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
{{#if (eq status 'failed')}}
|
||||
<form method="post" action="/settings/background-tasks/{{id}}/retry">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">Retry</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="8" class="empty">No background tasks are queued right now.</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{#if pagination.hasMultiplePages}}
|
||||
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||
<div class="text-muted small">
|
||||
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} tasks
|
||||
</div>
|
||||
<nav aria-label="Background task pages">
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}">
|
||||
<a class="page-link" href="{{#if pagination.hasPrevious}}/settings/background-tasks{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
|
||||
</li>
|
||||
{{#each pagination.pages}}
|
||||
<li class="page-item {{#if active}}active{{/if}}">
|
||||
<a class="page-link" href="/settings/background-tasks{{url}}">{{number}}</a>
|
||||
</li>
|
||||
{{/each}}
|
||||
<li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}">
|
||||
<a class="page-link" href="{{#if pagination.hasNext}}/settings/background-tasks{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-secondary">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title mb-0">Scheduled refreshes</h3>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task</th>
|
||||
<th>Interval</th>
|
||||
<th>Next run</th>
|
||||
<th>Last run</th>
|
||||
<th>Last result</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#if recurringTasks.length}}
|
||||
{{#each recurringTasks}}
|
||||
<tr>
|
||||
<td data-label="Task">
|
||||
<div class="fw-semibold">{{title}}</div>
|
||||
<div class="text-muted small text-break">{{key}}</div>
|
||||
</td>
|
||||
<td data-label="Interval">{{intervalLabel}}</td>
|
||||
<td data-label="Next run">
|
||||
{{#if nextRunAt}}
|
||||
<time data-local-datetime datetime="{{nextRunAt}}">{{nextRunAt}}</time>
|
||||
{{else}}-{{/if}}
|
||||
</td>
|
||||
<td data-label="Last run">
|
||||
{{#if lastRunAt}}
|
||||
<time data-local-datetime datetime="{{lastRunAt}}">{{lastRunAt}}</time>
|
||||
{{else}}-{{/if}}
|
||||
</td>
|
||||
<td data-label="Last result">
|
||||
{{#if lastStatus}}
|
||||
<span class="badge {{#if (eq lastStatus 'failed')}}text-bg-danger{{else if (eq lastStatus 'completed')}}text-bg-success{{else}}text-bg-secondary{{/if}}">{{lastStatus}}</span>
|
||||
{{#if lastError}}<div class="text-danger small mt-1 text-break">{{lastError}}</div>{{/if}}
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Source">
|
||||
{{#if metadata.sourceName}}
|
||||
<div>{{metadata.sourceName}}</div>
|
||||
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="6" class="empty">No scheduled refreshes are registered yet.</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{#if recurringPagination.hasMultiplePages}}
|
||||
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||
<div class="text-muted small">
|
||||
Showing {{recurringPagination.startItem}}-{{recurringPagination.endItem}} of {{recurringPagination.totalItems}} scheduled refreshes
|
||||
</div>
|
||||
<nav aria-label="Scheduled refresh pages">
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item {{#unless recurringPagination.hasPrevious}}disabled{{/unless}}">
|
||||
<a class="page-link" href="{{#if recurringPagination.hasPrevious}}/settings/background-tasks{{recurringPagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
|
||||
</li>
|
||||
{{#each recurringPagination.pages}}
|
||||
<li class="page-item {{#if active}}active{{/if}}">
|
||||
<a class="page-link" href="/settings/background-tasks{{url}}">{{number}}</a>
|
||||
</li>
|
||||
{{/each}}
|
||||
<li class="page-item {{#unless recurringPagination.hasNext}}disabled{{/unless}}">
|
||||
<a class="page-link" href="{{#if recurringPagination.hasNext}}/settings/background-tasks{{recurringPagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/rbac" id="role-create-form" data-async-save data-async-save-close-url="/admin/rbac">
|
||||
<form method="post" action="/rbac" id="role-create-form" data-async-save data-async-save-close-url="/rbac">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-4 d-flex flex-column gap-4">
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
@@ -75,7 +75,7 @@
|
||||
<div class="d-flex justify-content-end mt-4">
|
||||
<div class="btn-group" role="group" aria-label="Role actions">
|
||||
{{{saveActionButtons formId="role-create-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
|
||||
<a class="btn btn-warning" href="/admin/rbac" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
<a class="btn btn-warning" href="/rbac" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/rbac/{{role.id}}" id="role-edit-form" data-async-save data-async-save-close-url="/admin/rbac">
|
||||
<form method="post" action="/rbac/{{role.id}}" id="role-edit-form" data-async-save data-async-save-close-url="/rbac">
|
||||
<input type="hidden" name="permissions_present" value="1" />
|
||||
<input type="hidden" name="users_present" value="1" />
|
||||
|
||||
@@ -130,4 +130,4 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/admin/rbac/{{role.id}}/delete" id="role-delete-form" class="d-none" data-confirm-message="Delete {{role.name}}?"></form>
|
||||
<form method="post" action="/rbac/{{role.id}}/delete" id="role-delete-form" class="d-none" data-confirm-message="Delete {{role.name}}?"></form>
|
||||
@@ -12,7 +12,7 @@
|
||||
<h3 class="card-title">Existing roles</h3>
|
||||
{{#if (hasPermission currentUser 'rbac.create')}}
|
||||
<div class="card-tools">
|
||||
<a class="btn btn-primary btn-sm" href="/admin/rbac/new">Add role</a>
|
||||
<a class="btn btn-primary btn-sm" href="/rbac/new">Add role</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -51,11 +51,11 @@
|
||||
{{#if (anyPermission ../currentUser 'rbac.update' 'rbac.delete')}}
|
||||
<div class="actions users-row-actions">
|
||||
{{#if (hasPermission ../currentUser 'rbac.update')}}
|
||||
<a class="btn btn-sm btn-primary" href="/admin/rbac/{{id}}/edit">Edit</a>
|
||||
<a class="btn btn-sm btn-primary" href="/rbac/{{id}}/edit">Edit</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'rbac.delete')}}
|
||||
{{#unless user_count}}
|
||||
<form method="post" action="/admin/rbac/{{id}}/delete" class="inline-form" data-confirm-message="Delete {{name}}?">
|
||||
<form method="post" action="/rbac/{{id}}/delete" class="inline-form" data-confirm-message="Delete {{name}}?">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
{{else}}
|
||||
@@ -75,4 +75,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user