' + sanitizeRichText(substituteScheduleVariables(value, Object.assign({}, entry || {}, {
+ return '
' + entries.map(function (entry, index) {
+ return '
' + sanitizeRichText(substituteTimetableVariables(value, Object.assign({}, entry || {}, {
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
group: group || {},
@@ -241,6 +248,6 @@ function renderRegion(region, regionContent) {
}).join('') + '
';
}
-registry.register('schedule', {
+registry.register('timetable', {
renderRegion: renderRegion
});
\ No newline at end of file
diff --git a/src/player/regions/video.js b/src/player/regions/video.js
index e9ffb18..84d7b13 100644
--- a/src/player/regions/video.js
+++ b/src/player/regions/video.js
@@ -111,6 +111,7 @@ function renderVideoRegion(region, regionContent) {
var regionKey = getVideoRegionCacheKey(region);
var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
+ var disableAudio = regionContent && regionContent.disable_audio === undefined ? true : Boolean(regionContent && regionContent.disable_audio);
if (!requestedSrc) {
return '';
@@ -121,7 +122,7 @@ function renderVideoRegion(region, regionContent) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
}
setVideoSourceAvailability(requestedSrc, true);
- return '
';
+ return '
';
}
var requestedState = getVideoSourceAvailability(requestedSrc);
@@ -131,7 +132,7 @@ function renderVideoRegion(region, regionContent) {
if (regionKey) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
}
- return '
';
+ return '
';
}
scheduleVideoSourceProbe(regionKey, requestedSrc, false);
@@ -140,7 +141,7 @@ function renderVideoRegion(region, regionContent) {
if (cachedSrc !== requestedSrc) {
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
}
- return '
';
+ return '
';
}
return '';
diff --git a/src/player/render.js b/src/player/render.js
index bf8302c..841778d 100644
--- a/src/player/render.js
+++ b/src/player/render.js
@@ -4,7 +4,7 @@ const Handlebars = require('handlebars');
const path = require('path');
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getAnnouncementIconsDataScript, getPlayerAnnouncementTemplatesDataScript, getPlayerAnnouncementTemplatesScript, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
const { createThumbnailPreviewBootstrapScript } = require('./thumbnail-preview');
-const { createPageAuthBundle, createPageFetchAuthScript } = require('../request-auth');
+const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth');
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
const PLAYER_MEDIA_DIR = path.join(__dirname, '..', '..', 'media');
diff --git a/src/player/routes.js b/src/player/routes.js
index 6996c27..17fd1dd 100644
--- a/src/player/routes.js
+++ b/src/player/routes.js
@@ -3,7 +3,7 @@
const fs = require('fs');
const express = require('express');
const path = require('path');
-const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
+const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
@@ -259,9 +259,9 @@ function registerPlayerRoutes(app, options) {
});
}
- if (typeof common.fetchSchedulesData === 'function') {
- const scheduleData = await common.fetchSchedulesData(pool);
- data.scheduleGroups = Array.isArray(scheduleData && scheduleData.scheduleGroups) ? scheduleData.scheduleGroups : [];
+ if (typeof common.fetchTimetablesData === 'function') {
+ const timetableData = await common.fetchTimetablesData(pool);
+ data.timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
diff --git a/src/player/runtime.js b/src/player/runtime.js
index 8e8df4e..713bfd2 100644
--- a/src/player/runtime.js
+++ b/src/player/runtime.js
@@ -2,8 +2,8 @@
const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws');
-const { isClientNameAvailable } = require('../data/client-name-check');
-const { verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
+const { isClientNameAvailable } = require('#src/data/client-name-check');
+const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
function normalizePlayerPublicBaseUrl(pageUrl) {
const value = String(pageUrl || '').trim();
diff --git a/src/player/thumbnail-preview.js b/src/player/thumbnail-preview.js
index bf726ee..b50d790 100644
--- a/src/player/thumbnail-preview.js
+++ b/src/player/thumbnail-preview.js
@@ -15,7 +15,7 @@ function buildThumbnailPreviewData(slide) {
slides: [slide],
rssFeeds: [],
apiSources: [],
- scheduleGroups: [],
+ timetableGroups: [],
revision: String(slide.modified_at || slide.id || Date.now())
};
}
diff --git a/src/rbac.js b/src/rbac.js
index e679497..d39bf90 100644
--- a/src/rbac.js
+++ b/src/rbac.js
@@ -33,18 +33,6 @@ const PERMISSION_SECTIONS = [
{ key: 'delete', name: 'Delete', description: 'Delete screens.' }
]
},
- {
- key: 'announcements',
- order: 35,
- name: 'Announcements',
- sectionName: 'Content',
- actions: [
- { key: 'read', name: 'Read', description: 'View announcements.' },
- { key: 'create', name: 'Create', description: 'Create new announcements.' },
- { key: 'update', name: 'Update', description: 'Update announcements.' },
- { key: 'delete', name: 'Delete', description: 'Delete announcements.' }
- ]
- },
{
key: 'playlists',
order: 40,
@@ -93,9 +81,21 @@ const PERMISSION_SECTIONS = [
{ key: 'delete', name: 'Delete', description: 'Delete canvas sizes.' }
]
},
+ {
+ key: 'announcements',
+ order: 80,
+ name: 'Announcements',
+ sectionName: 'Content',
+ actions: [
+ { key: 'read', name: 'Read', description: 'View announcements.' },
+ { key: 'create', name: 'Create', description: 'Create new announcements.' },
+ { key: 'update', name: 'Update', description: 'Update announcements.' },
+ { key: 'delete', name: 'Delete', description: 'Delete announcements.' }
+ ]
+ },
{
key: 'rss-feeds',
- order: 75,
+ order: 90,
name: 'RSS feeds',
sectionName: 'Data Sources',
actions: [
@@ -105,21 +105,9 @@ const PERMISSION_SECTIONS = [
{ key: 'delete', name: 'Delete', description: 'Delete RSS feeds.' }
]
},
- {
- key: 'schedules',
- order: 77,
- name: 'Schedules',
- sectionName: 'Data Sources',
- actions: [
- { key: 'read', name: 'Read', description: 'View configured schedule groups.' },
- { key: 'create', name: 'Create', description: 'Create new schedule groups.' },
- { key: 'update', name: 'Update', description: 'Update schedule groups and entries.' },
- { key: 'delete', name: 'Delete', description: 'Delete schedule groups.' }
- ]
- },
{
key: 'api-sources',
- order: 76,
+ order: 100,
name: 'API sources',
sectionName: 'Data Sources',
actions: [
@@ -130,39 +118,20 @@ const PERMISSION_SECTIONS = [
]
},
{
- key: 'fonts',
- order: 95,
- name: 'Fonts',
- sectionName: 'Settings',
+ key: 'timetables',
+ order: 110,
+ name: 'Timetables',
+ sectionName: 'Data Sources',
actions: [
- { key: 'read', name: 'Read', description: 'View managed fonts.' },
- { key: 'create', name: 'Create', description: 'Upload managed fonts.' },
- { key: 'delete', name: 'Delete', description: 'Remove managed fonts.' }
- ]
- },
- {
- key: 'background-tasks',
- order: 100,
- name: 'Background tasks',
- sectionName: 'Settings',
- actions: [
- { key: 'read', name: 'Read', description: 'View queued background tasks.' },
- { key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
- ]
- },
- {
- key: 'scheduled-tasks',
- order: 101,
- name: 'Scheduled tasks',
- sectionName: 'Settings',
- actions: [
- { key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
- { key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
+ { key: 'read', name: 'Read', description: 'View configured timetable groups.' },
+ { key: 'create', name: 'Create', description: 'Create new timetable groups.' },
+ { key: 'update', name: 'Update', description: 'Update timetable groups and entries.' },
+ { key: 'delete', name: 'Delete', description: 'Delete timetable groups.' }
]
},
{
key: 'users',
- order: 80,
+ order: 120,
name: 'Users',
sectionName: 'Settings',
actions: [
@@ -174,7 +143,7 @@ const PERMISSION_SECTIONS = [
},
{
key: 'rbac',
- order: 90,
+ order: 130,
name: 'Roles and permissions',
sectionName: 'Settings',
actions: [
@@ -183,6 +152,37 @@ const PERMISSION_SECTIONS = [
{ key: 'update', name: 'Update', description: 'Update role details and permissions.' },
{ key: 'delete', name: 'Delete', description: 'Delete roles.' }
]
+ },
+ {
+ key: 'fonts',
+ order: 140,
+ name: 'Fonts',
+ sectionName: 'Settings',
+ actions: [
+ { key: 'read', name: 'Read', description: 'View managed fonts.' },
+ { key: 'create', name: 'Create', description: 'Upload managed fonts.' },
+ { key: 'delete', name: 'Delete', description: 'Remove managed fonts.' }
+ ]
+ },
+ {
+ key: 'background-tasks',
+ order: 150,
+ name: 'Task Queue',
+ sectionName: 'Settings',
+ actions: [
+ { key: 'read', name: 'Read', description: 'View queued background tasks.' },
+ { key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
+ ]
+ },
+ {
+ key: 'scheduled-tasks',
+ order: 160,
+ name: 'Scheduled tasks',
+ sectionName: 'Settings',
+ actions: [
+ { key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
+ { key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
+ ]
}
];
@@ -211,6 +211,8 @@ function normalizePermissionKey(permissionKey) {
return String(permissionKey || '').trim();
}
+const PERMISSION_DENIED_MESSAGE = 'You do not have permissions to do this.';
+
function normalizePermissionKeys(permissionKeys) {
const normalized = [];
@@ -295,7 +297,7 @@ function requirePermission(permissionKey, options) {
return next();
}
- const error = new Error('You do not have permission to access this area.');
+ const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
@@ -309,5 +311,6 @@ module.exports = {
hasPermission,
hasAnyPermission,
requirePermission,
- normalizePermissionKeys
+ normalizePermissionKeys,
+ PERMISSION_DENIED_MESSAGE
};
\ No newline at end of file
diff --git a/src/web.js b/src/web.js
index 85248aa..03f3949 100644
--- a/src/web.js
+++ b/src/web.js
@@ -11,13 +11,14 @@ const pages = require('#src/web/pages');
const registerMiddleware = require('#src/web/middleware');
const { createBackgroundTaskQueue } = require('#src/web/lib/background-tasks/queue');
const { initializeBackgroundTasks } = require('#src/web/lib/background-tasks');
+const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
const { captureSlideThumbnail } = require('#src/web/lib/media');
const { createWebBootstrap } = require('#src/web/bootstrap');
const { registerRoutes } = require('#src/web/routes/register');
const { createNotifyPlayerScreens } = require('#src/web/lib/notify-player-screens');
const { initializeWebServer } = require('#src/web/lib/web-startup');
const { createWebConfig } = require('#src/web/lib/config');
-const { requirePermission: createRequirePermission } = require('#src/rbac');
+const { requirePermission: createRequirePermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const { rbacData } = require('#src/web/lib/auth');
const { createPlayerActionService } = require('#src/web/lib/player-actions');
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
@@ -32,6 +33,7 @@ const {
normalizeScheduleMode,
getAuditUserId,
getCanvasSignature,
+ fetchPlaylistCanvasId,
fetchPlaylistCanvasSignature,
fetchScreensByPlaylistId,
fetchScreensBySlideId,
@@ -52,6 +54,11 @@ async function start() {
pool: pool,
maxConcurrent: 1
});
+ const dataSourceTasks = createDataSourceTaskService({
+ pool: pool,
+ common: common,
+ backgroundTaskQueue: backgroundTaskQueue
+ });
const playerActionService = createPlayerActionService({
pool: pool,
common: common,
@@ -130,6 +137,7 @@ async function start() {
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
fetchScreensBySlideId: fetchScreensBySlideId,
fetchScreensByTemplateId: fetchScreensByTemplateId,
+ fetchPlaylistCanvasId: fetchPlaylistCanvasId,
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
getCanvasSignature: getCanvasSignature,
normalizeScheduleMode: normalizeScheduleMode,
@@ -138,10 +146,13 @@ async function start() {
redirectAfterSave: redirectAfterSave,
notifyPlayerScreens: notifyPlayerScreens,
broadcastDashboardState: broadcastDashboardState,
+ dataSourceTasks: dataSourceTasks,
playerActionService: playerActionService,
isClientNameAvailable: isClientNameAvailable,
withClientNameReservation: withClientNameReservation,
- requirePermission: createRequirePermission,
+ requirePermission: function (permissionKey, options) {
+ return createRequirePermission(permissionKey, Object.assign({ setAuthMessageCookie: setAuthMessageCookie }, options));
+ },
hasAnyPermission: hasAnyPermission,
backgroundTaskQueue: backgroundTaskQueue,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
@@ -161,7 +172,7 @@ async function start() {
if (wantsHtml && pages.renderErrorPage) {
const isPermissionError = statusCode === 403;
const message = isPermissionError
- ? String(error && error.message ? error.message : 'You do not have permission to access this area.')
+ ? String(error && error.message ? error.message : PERMISSION_DENIED_MESSAGE)
: String(error && error.message ? error.message : 'An unexpected error occurred.');
const title = isPermissionError
? 'Access denied'
diff --git a/src/web/bootstrap.js b/src/web/bootstrap.js
index 3a77c77..c3793b3 100644
--- a/src/web/bootstrap.js
+++ b/src/web/bootstrap.js
@@ -3,7 +3,7 @@
const { WebSocketServer, WebSocket } = require('ws');
const { createDashboardStateService } = require('./lib/dashboard-state');
const { createUploadSyncService } = require('./lib/media');
-const { createRequestAuthHeaders } = require('../request-auth');
+const { createRequestAuthHeaders } = require('#src/request-auth');
function createWebBootstrap(options) {
const pool = options && options.pool;
diff --git a/src/web/handlebars.js b/src/web/handlebars.js
index 9e0d42c..e95b676 100644
--- a/src/web/handlebars.js
+++ b/src/web/handlebars.js
@@ -82,18 +82,42 @@ function registerActionHelpers(Handlebars) {
Handlebars.registerHelper('saveActionButtons', function (options) {
const hash = options && options.hash ? options.hash : {};
const formId = String(hash.formId || '').trim();
- const saveLabel = String(hash.saveLabel || 'Save');
- const saveButtonClass = String(hash.saveButtonClass || 'btn btn-success');
- const saveActionName = String(hash.saveActionName || 'save_action');
- const saveActionValue = String(hash.saveActionValue || 'save');
- const closeLabel = String(hash.saveAndCloseLabel || 'Save and Close');
- const closeValue = String(hash.saveAndCloseValue || 'close');
- const newLabel = String(hash.saveAndNewLabel || 'Save and New');
- const newValue = String(hash.saveAndNewValue || 'new');
+
+ const save = {
+ label: String(hash.saveLabel || 'Save'),
+ className: String(hash.saveButtonClass || 'btn btn-success'),
+ actionName: String(hash.saveActionName || 'save_action'),
+ actionValue: String(hash.saveActionValue || 'save'),
+ closeLabel: String(hash.saveAndCloseLabel || 'Save and Close'),
+ closeValue: String(hash.saveAndCloseValue || 'close'),
+ newLabel: String(hash.saveAndNewLabel || 'Save and New'),
+ newValue: String(hash.saveAndNewValue || 'new')
+ };
+
const showSaveAndClose = hash.showSaveAndClose === undefined ? true : String(hash.showSaveAndClose).toLowerCase() !== 'false';
const showSaveAndNew = hash.showSaveAndNew === undefined ? true : String(hash.showSaveAndNew).toLowerCase() !== 'false';
- const ariaLabel = String(hash.ariaLabel || 'Save actions');
+ const showDelete = hash.showDelete === undefined ? true : String(hash.showDelete).toLowerCase() !== 'false';
const hasSecondaryActions = showSaveAndClose || showSaveAndNew;
+ const ariaLabel = String(hash.ariaLabel || 'Save actions');
+ const saveUrl = String(hash.saveUrl || '').trim();
+
+ const cancel = {
+ label: String(hash.cancelLabel || 'Cancel'),
+ className: String(hash.cancelClass || 'btn btn-warning'),
+ url: String(hash.cancelUrl || '').trim(),
+ confirmMessage: String(hash.cancelConfirmMessage || hash.cancelConfirm || 'You have unsaved changes. Leave this page?')
+ };
+
+ const deleteAction = {
+ label: String(hash.deleteLabel || 'Delete'),
+ className: String(hash.deleteClass || 'btn btn-danger'),
+ url: String(hash.deleteUrl || '').trim(),
+ confirmMessage: String(hash.deleteConfirmMessage || '').trim(),
+ disabled: hash.deleteDisabled === undefined ? false : String(hash.deleteDisabled).toLowerCase() !== 'false',
+ title: String(hash.deleteTitle || '')
+ };
+
+ const resolvedDeleteUrl = deleteAction.url || (!deleteAction.disabled && saveUrl ? saveUrl.replace(/\/+$/, '') + '/delete' : '');
if (!formId) {
return '';
@@ -102,16 +126,30 @@ function registerActionHelpers(Handlebars) {
const escape = Handlebars.escapeExpression;
const formAttr = ` form="${escape(formId)}"`;
- return new Handlebars.SafeString([
+ const parts = [
'
',
- ``,
- hasSecondaryActions ? `` : '',
+ ``,
+ hasSecondaryActions ? `` : '',
hasSecondaryActions ? '' : '',
- '
',
- ].join(''));
+ ];
+
+ if (cancel.url) {
+ parts.push(`
${escape(cancel.label)}`);
+ }
+
+ if (showDelete && (deleteAction.disabled || resolvedDeleteUrl)) {
+ const deleteActionAttr = resolvedDeleteUrl ? ` data-delete-action-url="${escape(resolvedDeleteUrl)}"` : '';
+ const confirmAttr = deleteAction.confirmMessage ? ` data-confirm-message="${escape(deleteAction.confirmMessage)}"` : '';
+ const titleAttr = deleteAction.disabled && deleteAction.title ? ` title="${escape(deleteAction.title)}"` : '';
+ parts.push(`
`);
+ }
+
+ parts.push('
');
+
+ return new Handlebars.SafeString(parts.join(''));
});
}
@@ -119,6 +157,11 @@ function registerActionHelpers(Handlebars) {
function registerPartials(Handlebars, viewsRoot) {
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(viewsRoot, 'shared', 'modal-shell.hbs'), 'utf8'));
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(viewsRoot, 'shared', 'table', 'table-pagination.hbs'), 'utf8'));
+ Handlebars.registerPartial('playlists/form', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'form.hbs'), 'utf8'));
+ Handlebars.registerPartial('signage/playlists/slide-row', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'slide-row.hbs'), 'utf8'));
+ Handlebars.registerPartial('signage/playlists/schedule-rule-card', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'schedule-rule-card.hbs'), 'utf8'));
+ Handlebars.registerPartial('data-sources/api-sources/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'api-sources', 'form.hbs'), 'utf8'));
+ Handlebars.registerPartial('data-sources/rss-feeds/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8'));
}
// One entry point keeps Handlebars bootstrap centralized.
diff --git a/src/web/lib/helpers.js b/src/web/lib/helpers.js
index 9075334..cd6e6ec 100644
--- a/src/web/lib/helpers.js
+++ b/src/web/lib/helpers.js
@@ -90,25 +90,31 @@ function getCanvasSignature(width, height) {
return normalizedWidth + 'x' + normalizedHeight;
}
-async function fetchPlaylistCanvasSignature(pool, playlistId) {
+async function fetchPlaylistCanvasId(pool, playlistId) {
+ const [playlistRows] = await pool.query('SELECT canvas_id FROM c_playlists WHERE id = ?', [playlistId]);
+ const storedCanvasId = Number(playlistRows && playlistRows[0] && playlistRows[0].canvas_id);
+ if (Number.isInteger(storedCanvasId) && storedCanvasId > 0) {
+ return storedCanvasId;
+ }
+
const [rows] = await pool.query(
- `SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height
+ `SELECT DISTINCT st.canvas_size_id AS canvas_id
FROM c_playlist_slides ps
JOIN c_slides sl ON sl.id = ps.slide_id
LEFT JOIN c_templates st ON st.id = sl.template_id
- LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE ps.playlist_id = ?
- AND cs.width IS NOT NULL
- AND cs.height IS NOT NULL`,
+ AND st.canvas_size_id IS NOT NULL`,
[playlistId]
);
- const signatures = Array.from(new Set(rows.map(function (row) {
- return getCanvasSignature(row.canvas_width, row.canvas_height);
- }).filter(Boolean)));
- if (!signatures.length) {
+ const canvasIds = Array.from(new Set(rows.map(function (row) {
+ return Number(row.canvas_id);
+ }).filter(function (value) {
+ return Number.isInteger(value) && value > 0;
+ })));
+ if (!canvasIds.length) {
return null;
}
- return signatures.length === 1 ? signatures[0] : 'mismatch';
+ return canvasIds.length === 1 ? canvasIds[0] : 'mismatch';
}
async function fetchScreensByPlaylistId(connection, playlistId) {
@@ -160,19 +166,25 @@ async function fetchOrderedPlaylistSlides(connection, playlistId) {
function redirectAfterSave(req, res, defaultUrl, options) {
const safeOptions = options || {};
- const action = String((req && req.body && req.body.action) || req.query.action || '').toLowerCase();
+ const action = String((req && req.body && (req.body.action || req.body.save_action)) || req.query.action || '').toLowerCase();
+ const message = safeOptions.message || '';
+
+ function withMessage(url) {
+ if (!message) {
+ return url;
+ }
+
+ const joiner = String(url || '').indexOf('?') === -1 ? '?' : '&';
+ return url + joiner + 'message=' + encodeURIComponent(message);
+ }
+
if (action === 'close') {
- return res.redirect(safeOptions.closeUrl || defaultUrl);
+ return res.redirect(withMessage(safeOptions.closeUrl || defaultUrl));
}
if (action === 'new') {
- return res.redirect(safeOptions.newUrl || defaultUrl);
+ return res.redirect(withMessage(safeOptions.newUrl || defaultUrl));
}
- const message = safeOptions.message || '';
- if (message) {
- const joiner = defaultUrl.indexOf('?') === -1 ? '?' : '&';
- return res.redirect(defaultUrl + joiner + 'message=' + encodeURIComponent(message));
- }
- return res.redirect(defaultUrl);
+ return res.redirect(withMessage(defaultUrl));
}
module.exports = {
@@ -185,7 +197,8 @@ module.exports = {
normalizeScheduleMode: normalizeScheduleMode,
getAuditUserId: getAuditUserId,
getCanvasSignature: getCanvasSignature,
- fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
+ fetchPlaylistCanvasId: fetchPlaylistCanvasId,
+ fetchPlaylistCanvasSignature: fetchPlaylistCanvasId,
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
fetchScreensBySlideId: fetchScreensBySlideId,
fetchScreensByTemplateId: fetchScreensByTemplateId,
diff --git a/src/web/lib/playlist-schedule.js b/src/web/lib/playlist-schedule.js
new file mode 100644
index 0000000..c3c8a0b
--- /dev/null
+++ b/src/web/lib/playlist-schedule.js
@@ -0,0 +1,215 @@
+// Shared schedule rule helpers for playlist playback and editor rendering.
+
+const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+
+function normalizeDateTimeValue(value) {
+ const raw = String(value || '').trim();
+ return raw ? raw : null;
+}
+
+function normalizeTimeValue(value) {
+ const raw = String(value || '').trim();
+ if (!raw) {
+ return null;
+ }
+ const match = raw.match(/^(\d{2}):(\d{2})(?::\d{2})?$/);
+ if (!match) {
+ return null;
+ }
+ return match[1] + ':' + match[2];
+}
+
+function normalizeDayValues(value) {
+ let days = [];
+
+ if (Array.isArray(value)) {
+ days = value;
+ } else if (value !== undefined && value !== null && String(value).trim()) {
+ try {
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
+ if (Array.isArray(parsed)) {
+ days = parsed;
+ }
+ } catch (_error) {
+ days = String(value)
+ .split(',')
+ .map(function (item) {
+ return item.trim();
+ })
+ .filter(Boolean);
+ }
+ }
+
+ return Array.from(new Set(days.map(function (day) {
+ return Number(day);
+ }).filter(function (day) {
+ return Number.isInteger(day) && day >= 0 && day <= 6;
+ }))).sort(function (left, right) {
+ return left - right;
+ });
+}
+
+function normalizeScheduleRule(rule) {
+ const input = rule && typeof rule === 'object' ? rule : {};
+ const startDatetime = normalizeDateTimeValue(
+ input.start_datetime || input.startDateTime || input.schedule_start_datetime || input.scheduleStartDatetime
+ );
+ const endDatetime = normalizeDateTimeValue(
+ input.end_datetime || input.endDateTime || input.schedule_end_datetime || input.scheduleEndDatetime
+ );
+ const startTime = normalizeTimeValue(
+ input.start_time || input.startTime || input.schedule_start_time || input.scheduleStartTime
+ );
+ const endTime = normalizeTimeValue(
+ input.end_time || input.endTime || input.schedule_end_time || input.scheduleEndTime
+ );
+ const days = normalizeDayValues(
+ input.days || input.day_list || input.schedule_days || input.schedule_days_json || input.scheduleDays
+ );
+
+ if (startDatetime || endDatetime) {
+ if (!startDatetime || !endDatetime) {
+ return null;
+ }
+ }
+
+ if (startTime || endTime) {
+ if (!startTime || !endTime) {
+ return null;
+ }
+ }
+
+ if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) {
+ return null;
+ }
+
+ const normalized = {};
+ if (startDatetime) {
+ normalized.start_datetime = startDatetime;
+ }
+ if (endDatetime) {
+ normalized.end_datetime = endDatetime;
+ }
+ if (startTime) {
+ normalized.start_time = startTime;
+ }
+ if (endTime) {
+ normalized.end_time = endTime;
+ }
+ if (days.length) {
+ normalized.days = days;
+ }
+
+ return normalized;
+}
+
+function parseScheduleRules(value) {
+ let rawRules = [];
+
+ if (Array.isArray(value)) {
+ rawRules = value;
+ } else if (value && typeof value === 'object' && Array.isArray(value.rules)) {
+ rawRules = value.rules;
+ } else {
+ const rawValue = String(value || '').trim();
+ if (!rawValue) {
+ return [];
+ }
+
+ try {
+ const parsed = JSON.parse(rawValue);
+ rawRules = Array.isArray(parsed) ? parsed : [];
+ } catch (_error) {
+ return null;
+ }
+ }
+
+ return rawRules
+ .map(function (rule) {
+ return normalizeScheduleRule(rule);
+ })
+ .filter(Boolean);
+}
+
+function serializeScheduleRules(value) {
+ const normalized = parseScheduleRules(value);
+ if (normalized === null) {
+ return null;
+ }
+ return JSON.stringify(normalized);
+}
+
+function formatDateTime(value) {
+ const raw = String(value || '').trim();
+ if (!raw) {
+ return '';
+ }
+ return raw.replace('T', ' ').slice(0, 16);
+}
+
+function formatTime(value) {
+ const raw = String(value || '').trim();
+ if (!raw) {
+ return '';
+ }
+ return raw.slice(0, 5);
+}
+
+function formatDays(value) {
+ const days = normalizeDayValues(value);
+ return days
+ .map(function (day) {
+ return DAY_NAMES[day] || '';
+ })
+ .filter(Boolean)
+ .join(', ');
+}
+
+function formatScheduleRuleSummary(rule) {
+ const normalized = normalizeScheduleRule(rule);
+ if (!normalized) {
+ return 'Always visible';
+ }
+
+ const leftParts = [];
+ if (normalized.start_datetime && normalized.end_datetime) {
+ leftParts.push('Dates ' + formatDateTime(normalized.start_datetime) + ' to ' + formatDateTime(normalized.end_datetime));
+ } else {
+ leftParts.push('Any date');
+ }
+
+ const rightParts = [];
+ const days = formatDays(normalized.days || []);
+ if (days) {
+ rightParts.push(days);
+ }
+ if (normalized.start_time && normalized.end_time) {
+ rightParts.push(formatTime(normalized.start_time) + '-' + formatTime(normalized.end_time));
+ }
+
+ if (rightParts.length) {
+ return leftParts[0] + ': ' + rightParts.join(' ');
+ }
+
+ return leftParts[0];
+}
+
+function formatScheduleRulesSummary(value) {
+ const rules = parseScheduleRules(value) || [];
+ if (!rules.length) {
+ return 'Always visible';
+ }
+
+ return rules.length === 1 ? '1 rule' : String(rules.length) + ' rules';
+}
+
+module.exports = {
+ normalizeDateTimeValue: normalizeDateTimeValue,
+ normalizeTimeValue: normalizeTimeValue,
+ normalizeDayValues: normalizeDayValues,
+ normalizeScheduleRule: normalizeScheduleRule,
+ parseScheduleRules: parseScheduleRules,
+ serializeScheduleRules: serializeScheduleRules,
+ formatScheduleRuleSummary: formatScheduleRuleSummary,
+ formatScheduleRulesSummary: formatScheduleRulesSummary
+};
\ No newline at end of file
diff --git a/src/web/pages/error.js b/src/web/pages/error.js
index 14ded86..b7f1434 100644
--- a/src/web/pages/error.js
+++ b/src/web/pages/error.js
@@ -1,6 +1,7 @@
// Error page renderer for the web UI.
-const { renderView } = require('../view');
+const { renderView } = require('#src/web/view');
+const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
function getErrorCopy(statusCode, message) {
const normalizedStatusCode = Number(statusCode) || 500;
@@ -9,7 +10,7 @@ function getErrorCopy(statusCode, message) {
return {
title: 'Access denied',
errorTitle: 'Access denied',
- errorMessage: message || 'You do not have permission to access this area.',
+ errorMessage: message || PERMISSION_DENIED_MESSAGE,
backLabel: 'Back to dashboard'
};
}
@@ -47,7 +48,7 @@ module.exports = function renderErrorPage(options, currentUser) {
return renderView('error/error', {
title: String(errorOptions.title || copy.title || 'Error').trim(),
active: '',
- messageVariant: 'primary',
+ messageVariant: statusCode === 403 ? 'warning' : 'primary',
currentUser: currentUser || null,
statusCode: statusCode,
errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(),
diff --git a/src/web/pages/index.js b/src/web/pages/index.js
index ef63008..759d5d3 100644
--- a/src/web/pages/index.js
+++ b/src/web/pages/index.js
@@ -19,26 +19,28 @@ module.exports = {
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')),
+ renderApiSourceAddPage: require(routePath('data-sources', 'api-sources', 'add')),
renderApiSourceEditPage: require(routePath('data-sources', 'api-sources', 'edit')),
- renderScheduleGroupsPage: require(routePath('data-sources', 'schedules', 'list')),
- renderScheduleGroupFormPage: require(routePath('data-sources', 'schedules', 'form')),
+ renderTimetableGroupsPage: require(routePath('data-sources', 'timetables', 'list')),
+ renderTimetableGroupAddPage: require(routePath('data-sources', 'timetables', 'add')),
+ renderTimetableGroupEditPage: require(routePath('data-sources', 'timetables', 'edit')),
renderRssFeedsPage: require(routePath('data-sources', 'rss-feeds', 'list')),
- renderRssFeedFormPage: require(routePath('data-sources', 'rss-feeds', 'add')),
+ renderRssFeedAddPage: 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')),
renderAnnouncementsPage: require(routePath('signage', 'announcements', 'list')),
- renderAnnouncementFormPage: require(routePath('signage', 'announcements', 'add')),
+ renderAnnouncementAddPage: require(routePath('signage', 'announcements', 'add')),
renderAnnouncementEditPage: require(routePath('signage', 'announcements', 'edit')),
renderSlidesPage: require(routePath('signage', 'slides', 'list')),
- renderSlideFormPage: require(routePath('signage', 'slides', 'form')),
+ renderSlideAddPage: require(routePath('signage', 'slides', 'add')),
+ renderSlideEditPage: require(routePath('signage', 'slides', 'edit')),
renderTemplatesPage: require(routePath('signage', 'templates', 'list')),
- renderTemplateFormPage: require(routePath('signage', 'templates', 'add')),
+ renderTemplateAddPage: 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')),
+ renderCanvasSizeAddPage: require(routePath('signage', 'canvas-sizes', 'add')),
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
renderFontsPage: require(routePath('settings', 'fonts', 'list')),
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage,
diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css
index 8068165..0f5f00d 100644
--- a/src/web/public/css/theme-custom.css
+++ b/src/web/public/css/theme-custom.css
@@ -146,6 +146,10 @@
min-height: 0;
}
+.min-w-0 {
+ min-width: 0;
+}
+
.sidebar-brand .brand-text.fw-light {
margin: 0;
}
@@ -170,7 +174,8 @@
.card-header .card-tools {
position: absolute;
right: 20px;
- top: 12px;
+ top: 50%;
+ transform: translateY(-50%);
}
.card-header .card-tools > .btn,
@@ -526,6 +531,115 @@
font-size: 0.875rem;
}
+.screen-command-card .card-header {
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+}
+
+.screen-command-panel {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
+ gap: 1rem;
+ align-items: start;
+}
+
+.screen-command-panel-left {
+ display: grid;
+ gap: 0.85rem;
+}
+
+.screen-command-panel-right {
+ display: grid;
+ align-content: start;
+ padding: 0.9rem 1rem;
+ border-left: 1px solid var(--bs-border-color);
+}
+
+.screen-command-selector-field {
+ display: grid;
+ gap: 0.4rem;
+}
+
+.screen-command-selector-label {
+ color: var(--bs-secondary-color);
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.screen-command-summary {
+ display: grid;
+ gap: 0.35rem;
+ align-content: start;
+ min-width: 0;
+}
+
+.screen-command-summary strong {
+ line-height: 1.2;
+}
+
+.screen-command-summary-meta {
+ color: var(--bs-secondary-color);
+ font-size: 0.875rem;
+ line-height: 1.35;
+}
+
+.screen-command-pill {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ padding: 0.35rem 0.7rem;
+ border-radius: 999px;
+ font-size: 0.8rem;
+ font-weight: 700;
+}
+
+.screen-command-pill.is-live {
+ background: rgba(13, 110, 253, 0.12);
+ color: var(--bs-primary);
+}
+
+.screen-command-pill.is-idle {
+ background: rgba(108, 117, 125, 0.12);
+ color: var(--bs-secondary-color);
+}
+
+.screen-command-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.screen-command-actions .btn {
+ min-width: 8.75rem;
+}
+
+.screen-command-actions .inline-form {
+ margin: 0;
+}
+
+@media (max-width: 767.98px) {
+ .screen-command-panel {
+ grid-template-columns: 1fr;
+ gap: 0.85rem;
+ }
+
+ .screen-command-panel-right {
+ padding: 0;
+ border-left: 0;
+ }
+
+ .screen-command-actions {
+ justify-content: stretch;
+ }
+
+ .screen-command-actions .btn {
+ width: 100%;
+ min-width: 0;
+ }
+}
+
.dashboard-screen-card-header {
display: flex;
align-items: flex-start;
@@ -660,7 +774,19 @@
border-bottom-right-radius: calc(var(--bs-border-radius) - 1px);
}
-.card-body.table-responsive > table.table > thead th {
+.playlist-items-table-responsive {
+ overflow: hidden;
+ border-bottom-left-radius: calc(var(--bs-border-radius) - 1px);
+ border-bottom-right-radius: calc(var(--bs-border-radius) - 1px);
+}
+
+.playlist-items-table-responsive > table.table {
+ width: 100%;
+ table-layout: auto;
+}
+
+.card-body.table-responsive > table.table > thead th,
+.playlist-items-table-responsive > table.table > thead th {
position: sticky;
top: 0;
z-index: 1;
@@ -1563,6 +1689,35 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
overflow: visible;
}
+.template-field-card.collapsed-card {
+ overflow: hidden;
+}
+
+.template-field-card.collapsed-card > .card-body {
+ display: block !important;
+ max-height: 0;
+ overflow: hidden;
+}
+
+.template-field-card.collapsed-card .card-header {
+ border-bottom-left-radius: var(--bs-border-radius);
+ border-bottom-right-radius: var(--bs-border-radius);
+}
+
+.template-field-body {
+ min-width: 0;
+ transition: opacity 0.5s ease;
+}
+
+.template-field-card.collapsing-card .template-field-body,
+.template-field-card.collapsed-card .template-field-body {
+ opacity: 0;
+}
+
+.template-field-card.expanding-card .template-field-body {
+ opacity: 1;
+}
+
.template-field-card .card-header,
.region-item .card-header {
background: var(--bs-tertiary-bg);
@@ -1851,14 +2006,27 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
background: rgba(var(--bs-primary-rgb), 0.08);
}
+.playlist-slide-picker-card.is-mismatch {
+ border-color: var(--bs-border-color-translucent);
+ background: rgba(var(--bs-secondary-rgb), 0.08);
+}
+
.playlist-slide-picker-card.is-assigned .playlist-slide-picker-media {
filter: saturate(0.85) grayscale(0.25);
}
+.playlist-slide-picker-card.is-mismatch .playlist-slide-picker-media {
+ filter: saturate(0.8) grayscale(0.45);
+}
+
.playlist-slide-picker-card.is-assigned .playlist-slide-picker-title {
color: var(--bs-body-secondary);
}
+.playlist-slide-picker-card.is-mismatch .playlist-slide-picker-title {
+ color: var(--bs-body-secondary);
+}
+
.playlist-slide-picker-card.is-hidden {
display: none !important;
}
@@ -1971,7 +2139,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
max-width: none;
overflow: hidden;
width: min(calc(100vw - 2rem), 56rem);
- max-height: calc(100vh - 0.25rem);
+ height: calc(100vh - 2rem);
+ max-height: calc(100vh - 2rem);
margin: 0;
}
@@ -1981,6 +2150,61 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.slide-schedule-content {
width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+.slide-schedule-content .slide-schedule-card {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.slide-schedule-content .slide-schedule-card form {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.slide-schedule-content .slide-schedule-card .card-body {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+}
+
+.slide-schedule-content #schedule-rule-list {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow-y: auto;
+ scrollbar-gutter: stable both-edges;
+}
+
+.slide-schedule-content .schedule-rule-card .card-header {
+ align-items: flex-start;
+}
+
+.slide-schedule-content .schedule-rule-card.collapsed-card {
+ overflow: hidden;
+}
+
+.slide-schedule-content .schedule-rule-card.collapsed-card > .card-body {
+ display: block !important;
+ padding: 0 !important;
+ max-height: 0;
+ overflow: hidden;
+}
+
+.slide-schedule-content .schedule-rule-card.collapsed-card .card-header {
+ border-bottom-left-radius: var(--bs-border-radius);
+ border-bottom-right-radius: var(--bs-border-radius);
+}
+
+.slide-schedule-content .schedule-rule-card .card-body {
+ padding-top: 0.75rem;
}
.actions,
@@ -2044,7 +2268,8 @@ td[data-label="Actions"] > div {
white-space: nowrap;
}
-td[data-label="Duration"] {
+td[data-label="Duration"],
+td[data-label="Config"] {
text-align: left;
}
@@ -2125,6 +2350,7 @@ table.table > :not(caption) > * > * {
line-height: 1;
cursor: grab;
text-decoration: none;
+ touch-action: none;
}
.playlist-drag-handle-icon {
@@ -2152,6 +2378,20 @@ table.table > :not(caption) > * > * {
cursor: grabbing;
}
+.playlist-row-ghost {
+ background: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.08) !important;
+ opacity: 0.45;
+}
+
+.playlist-row-dragging {
+ box-shadow: 0 0.85rem 2rem rgba(15, 23, 42, 0.18);
+ opacity: 0.98;
+}
+
+.playlist-row-drag-layer {
+ will-change: transform;
+}
+
table.table thead th.sortable {
cursor: pointer;
white-space: nowrap;
diff --git a/src/web/public/js/admin/admin-page.js b/src/web/public/js/admin/admin-page.js
index ff8f984..a44ddb5 100644
--- a/src/web/public/js/admin/admin-page.js
+++ b/src/web/public/js/admin/admin-page.js
@@ -19,7 +19,123 @@
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
+ return;
}
+
+ var actionPath = '';
+ try {
+ actionPath = new URL(form.action, window.location.href).pathname;
+ } catch (_error) {
+ actionPath = String(form.action || '');
+ }
+
+ if (!/\/delete(?:\/|$)/.test(actionPath)) {
+ return;
+ }
+
+ if (form.dataset && form.dataset.busy === 'true') {
+ event.preventDefault();
+ return;
+ }
+
+ event.preventDefault();
+ form.dataset.busy = 'true';
+
+ fetch(form.action, {
+ method: (form.method || 'POST').toUpperCase(),
+ headers: {
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'Accept': 'application/json, text/plain, */*'
+ },
+ credentials: 'same-origin'
+ }).then(function (response) {
+ if (!response.ok) {
+ return response.text().then(function (text) {
+ var error = new Error(text || 'Unable to delete item.');
+ error.status = response.status;
+ throw error;
+ });
+ }
+
+ if (isLoginRedirect(response)) {
+ window.location.replace(response.url || '/login');
+ return;
+ }
+
+ window.location.replace(response.url || window.location.href);
+ }).catch(function (error) {
+ if (typeof showToast === 'function') {
+ var status = Number(error && error.status);
+ var variant = status === 403 ? 'warning' : status >= 400 && status < 500 ? 'warning' : 'danger';
+ showToast(error.message || 'Unable to delete item.', variant);
+ return;
+ }
+ window.alert(error.message || 'Unable to delete item.');
+ }).finally(function () {
+ delete form.dataset.busy;
+ });
+ });
+ }
+
+ function initDeleteButtons() {
+ document.addEventListener('click', function (event) {
+ var deleteButton = event.target.closest('[data-delete-action-url]');
+ if (!deleteButton) {
+ return;
+ }
+
+ if (deleteButton.disabled || (deleteButton.dataset && deleteButton.dataset.busy === 'true')) {
+ event.preventDefault();
+ return;
+ }
+
+ var actionUrl = deleteButton.getAttribute('data-delete-action-url');
+ if (!actionUrl) {
+ return;
+ }
+
+ var message = deleteButton.getAttribute('data-confirm-message');
+ if (message && !window.confirm(message)) {
+ event.preventDefault();
+ return;
+ }
+
+ event.preventDefault();
+ deleteButton.dataset.busy = 'true';
+
+ fetch(actionUrl, {
+ method: 'POST',
+ headers: {
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'Accept': 'application/json, text/plain, */*'
+ },
+ credentials: 'same-origin'
+ }).then(function (response) {
+ if (!response.ok) {
+ return response.text().then(function (text) {
+ var error = new Error(text || 'Unable to delete item.');
+ error.status = response.status;
+ throw error;
+ });
+ }
+
+ if (isLoginRedirect(response)) {
+ window.location.replace(response.url || '/login');
+ return;
+ }
+
+ window.location.replace(response.url || window.location.href);
+ }).catch(function (error) {
+ if (typeof showToast === 'function') {
+ var status = Number(error && error.status);
+ var variant = status === 403 ? 'warning' : status >= 400 && status < 500 ? 'warning' : 'danger';
+ showToast(error.message || 'Unable to delete item.', variant);
+ return;
+ }
+ window.alert(error.message || 'Unable to delete item.');
+ }).finally(function () {
+ delete deleteButton.dataset.busy;
+ });
});
}
@@ -79,6 +195,191 @@
}, true);
}
+ function parseAsyncSaveResponseMessage(responseText) {
+ var savedMessage = '';
+ try {
+ var responseDocument = new DOMParser().parseFromString(responseText || '', 'text/html');
+ var toastBody = responseDocument.querySelector('.toast-body');
+ if (toastBody && toastBody.textContent) {
+ savedMessage = toastBody.textContent.trim();
+ }
+ } catch (_error) {
+ savedMessage = '';
+ }
+
+ return savedMessage;
+ }
+
+ function getAsyncSaveActionField(form) {
+ if (!form || !form.querySelector) {
+ return null;
+ }
+
+ return form.querySelector('input[type="hidden"][name="save_action"]') || form.querySelector('input[type="hidden"][name="action"]');
+ }
+
+ function isLoginRedirect(response) {
+ if (!response || !response.redirected || !response.url) {
+ return false;
+ }
+
+ try {
+ return new URL(response.url, window.location.href).pathname === '/login';
+ } catch (_error) {
+ return /\/login(?:[?#]|$)/.test(String(response.url || ''));
+ }
+ }
+
+ async function submitAsyncSaveForm(form, event, options) {
+ var settings = options || {};
+ if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
+ return false;
+ }
+
+ if (form.dataset && form.dataset.busy === 'true') {
+ if (event) {
+ event.preventDefault();
+ }
+ return false;
+ }
+
+ var message = form.getAttribute('data-confirm-message');
+ if (message && !window.confirm(message)) {
+ if (event) {
+ event.preventDefault();
+ }
+ return false;
+ }
+
+ if (event) {
+ event.preventDefault();
+ }
+
+ form.dataset.busy = 'true';
+
+ try {
+ if (typeof settings.beforeSubmit === 'function') {
+ var beforeResult = await settings.beforeSubmit(form, event);
+ if (beforeResult === false) {
+ return false;
+ }
+ }
+
+ var hiddenSaveAction = getAsyncSaveActionField(form);
+ var formData = new FormData(form);
+ var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
+ if (event && 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);
+ });
+ }
+
+ var response = await 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'
+ });
+
+ if (!response.ok) {
+ throw new Error(await response.text() || (settings.errorMessage || 'Unable to save changes.'));
+ }
+
+ if (isLoginRedirect(response)) {
+ window.location.replace(response.url || '/login');
+ return true;
+ }
+
+ if (submitterValue === 'close' || submitterValue === 'new') {
+ var redirectUrl = submitterValue === 'close'
+ ? String(response.url || form.dataset.asyncSaveCloseUrl || window.location.href)
+ : String(response.url || form.dataset.asyncSaveNewUrl || window.location.href);
+ window.location.replace(redirectUrl);
+ return true;
+ }
+
+ if (form.dataset && form.dataset.asyncSaveNewRedirect === 'response-url') {
+ window.location.replace(String(response.url || form.dataset.asyncSaveNewUrl || window.location.href));
+ return true;
+ }
+
+ var responseText = await response.text();
+ var responseDocument = null;
+ try {
+ responseDocument = new DOMParser().parseFromString(responseText || '', 'text/html');
+ } catch (_error) {
+ responseDocument = null;
+ }
+
+ if (typeof settings.afterSuccess === 'function') {
+ await settings.afterSuccess({
+ form: form,
+ response: response,
+ responseText: responseText,
+ responseDocument: responseDocument,
+ submitterValue: submitterValue
+ });
+ }
+
+ clearFormDirty(form);
+
+ var successMessage = typeof settings.getSuccessMessage === 'function'
+ ? settings.getSuccessMessage({
+ form: form,
+ response: response,
+ responseText: responseText,
+ responseDocument: responseDocument,
+ submitterValue: submitterValue
+ })
+ : parseAsyncSaveResponseMessage(responseText);
+
+ showToast(successMessage || String(settings.fallbackSuccessMessage || 'Saved.'), 'success');
+ return true;
+ } 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 : (settings.errorMessage || 'Unable to save changes.'), variant);
+ } else {
+ window.alert(error && error.message ? error.message : (settings.errorMessage || 'Unable to save changes.'));
+ }
+ return false;
+ } finally {
+ delete form.dataset.busy;
+ delete form.dataset.submitterValue;
+ if (typeof settings.afterFinally === 'function') {
+ settings.afterFinally(form);
+ }
+ var hiddenInput = getAsyncSaveActionField(form);
+ if (hiddenInput) {
+ hiddenInput.value = '';
+ }
+ }
+ }
+
+ window.webAsyncSaveForm = window.webAsyncSaveForm || {};
+ window.webAsyncSaveForm.submit = submitAsyncSaveForm;
+ window.webAsyncSaveForm.parseResponseMessage = parseAsyncSaveResponseMessage;
+
function initAsyncCommandForms() {
function getAnnouncementActionState(actionPath) {
var isPlay = /\/play$/.test(actionPath);
@@ -174,7 +475,7 @@
return;
}
- var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]');
+ var hiddenInput = getAsyncSaveActionField(form);
if (!hiddenInput) {
hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
@@ -208,6 +509,9 @@
if (typeof window.initTableSearches === 'function') {
window.initTableSearches(targetElement);
}
+ if (targetElement.id === 'playlist-items-body' && typeof window.rebindPlaylistTableDrag === 'function') {
+ window.rebindPlaylistTableDrag(targetElement);
+ }
}
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
@@ -311,7 +615,7 @@
return;
}
- var button = target.closest('button[name="save_action"]');
+ var button = target.closest('button[name="save_action"], button[name="action"]');
if (!button) {
return;
}
@@ -330,129 +634,24 @@
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;
- }
- if (form.hasAttribute('data-async-save-reload-on-success')) {
- clearFormDirty(form);
- window.location.replace(response.url || window.location.href);
- 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 = '';
- }
+ submitAsyncSaveForm(form, event, {
+ fallbackSuccessMessage: 'Saved.',
+ afterSuccess: function (context) {
+ 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(context.response.url || '', 'refresh_task_id');
if (refreshTargetSelector && refreshStateUrl && refreshTaskId) {
refreshSequence += 1;
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
- } else if (refreshTargetSelector && responseDocument) {
+ } else if (refreshTargetSelector && context.responseDocument) {
var currentTarget = document.querySelector(refreshTargetSelector);
- var nextTarget = responseDocument.querySelector(refreshTargetSelector);
+ var nextTarget = context.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);
@@ -651,6 +850,7 @@
}
initConfirmForms();
+ initDeleteButtons();
initDirtyTracking();
initCancelConfirm();
initAsyncCommandForms();
diff --git a/src/web/public/js/dashboard/dashboard-page.js b/src/web/public/js/dashboard/dashboard-page.js
index 6ce9916..39c4d24 100644
--- a/src/web/public/js/dashboard/dashboard-page.js
+++ b/src/web/public/js/dashboard/dashboard-page.js
@@ -6,20 +6,97 @@
var getClientDisplayName = webUiHelpers.getClientDisplayName;
var setButtonVariant = webUiHelpers.setButtonVariant;
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
+ var latestDashboardState = null;
+ function getClientMoveModalElements() {
+ return {
+ modal: document.getElementById('client-move-screen-modal'),
+ form: document.getElementById('client-move-screen-form'),
+ targetSelect: document.getElementById('client-move-screen-target'),
+ connectionInput: document.querySelector('[data-client-move-connection-id]'),
+ deviceInput: document.querySelector('[data-client-move-device-id]'),
+ clientNameInput: document.querySelector('[data-client-move-client-name]')
+ };
+ }
+
+ function getClientMoveScreens() {
+ if (latestDashboardState && Array.isArray(latestDashboardState.screens) && latestDashboardState.screens.length) {
+ return latestDashboardState.screens.slice().sort(compareScreensByConnectedClients);
+ }
+
+ var select = document.getElementById('client-move-screen-target');
+ if (!select || !select.options) {
+ return [];
+ }
+
+ return Array.prototype.slice.call(select.options).map(function (option) {
+ return {
+ slug: String(option.value || '').trim(),
+ name: String(option.textContent || option.value || '').trim()
+ };
+ }).filter(function (screen) {
+ return Boolean(screen && screen.slug);
+ });
+ }
+
+ function updateClientMoveModalFromRow(row) {
+ var elements = getClientMoveModalElements();
+ if (!elements.form || !elements.targetSelect || !row) {
+ return;
+ }
+
+ var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
+ var connectionId = String(row.getAttribute('data-client-key') || '').trim();
+ var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
+ var clientNameCell = row.querySelector('td[data-label="Client"] > div');
+ var clientName = String(clientNameCell && clientNameCell.textContent || '').trim();
+ var options = Array.prototype.slice.call(elements.targetSelect.options || []);
+
+ options.forEach(function (option) {
+ option.disabled = false;
+ if (String(option.value || '').trim() === currentScreenSlug) {
+ option.disabled = true;
+ }
+ });
+
+ elements.form.action = currentScreenSlug ? '/clients/' + encodeURIComponent(currentScreenSlug) + '/commands' : '#';
+ if (elements.connectionInput) {
+ elements.connectionInput.value = connectionId;
+ }
+ if (elements.deviceInput) {
+ elements.deviceInput.value = deviceId;
+ }
+ if (elements.clientNameInput) {
+ elements.clientNameInput.value = clientName;
+ }
+ elements.targetSelect.value = '';
+ if (elements.form.querySelector('button[type="submit"]')) {
+ elements.form.querySelector('button[type="submit"]').disabled = false;
+ }
+ }
function renderClientActionCell(client) {
var paused = Boolean(client.paused);
var pauseButtonClass = 'btn btn-sm btn-info';
var pauseButtonIcon = paused ? 'bi-play-fill' : 'bi-pause-fill';
- var pauseButtonLabel = paused ? 'Resume' : 'Pause';
+ var pauseButtonLabel = paused ? 'Resume client' : 'Pause client';
+ var pauseButtonText = paused ? 'Resume' : 'Pause';
var blackout = Boolean(client.blackout);
var blackoutButtonClass = 'btn btn-sm ' + (blackout ? 'btn-success' : 'btn-secondary');
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
- var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout';
+ var blackoutButtonLabel = blackout ? 'Restore client' : 'Blackout client';
+ var blackoutButtonText = blackout ? 'Restore' : 'Blackout';
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
- var blackoutCommandValue = blackout ? 'false' : 'true';
- return '
';
+ return [
+ '
',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '
'
+ ].join('');
}
function updateClientActionCell(cell, client) {
@@ -35,6 +112,8 @@
var paused = Boolean(client.paused);
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info');
+ pauseButton.setAttribute('aria-label', paused ? 'Resume client' : 'Pause client');
+ pauseButton.setAttribute('title', paused ? 'Resume client' : 'Pause client');
pauseButton.innerHTML = '
' + (paused ? 'Resume' : 'Pause');
var pauseForm = pauseButton.form;
@@ -52,7 +131,9 @@
var reloadButton = cell.querySelector('button[data-action="reload"]');
if (reloadButton) {
- reloadButton.innerHTML = '
Reload';
+ reloadButton.setAttribute('aria-label', 'Reload client');
+ reloadButton.setAttribute('title', 'Reload client');
+ reloadButton.innerHTML = '
';
setButtonVariant(reloadButton, ['btn-danger', 'btn-success', 'btn-outline-secondary', 'btn-outline-dark', 'btn-outline-primary', 'btn-secondary'], 'btn-danger');
var reloadForm = reloadButton.form;
if (reloadForm) {
@@ -74,6 +155,8 @@
var blackout = Boolean(client.blackout);
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
setButtonVariant(blackoutButton, ['btn-success', 'btn-secondary'], blackout ? 'btn-success' : 'btn-secondary');
+ blackoutButton.setAttribute('aria-label', blackout ? 'Restore client' : 'Blackout client');
+ blackoutButton.setAttribute('title', blackout ? 'Restore client' : 'Blackout client');
blackoutButton.innerHTML = '
' + (blackout ? 'Restore' : 'Blackout');
var blackoutForm = blackoutButton.form;
@@ -100,6 +183,7 @@
}
setButtonVariant(previousButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning');
previousButton.setAttribute('aria-label', 'Previous slide');
+ previousButton.setAttribute('title', 'Previous slide');
var previousForm = previousButton.form;
if (previousForm) {
var previousCommandInput = previousForm.querySelector('input[name="command"]');
@@ -120,6 +204,7 @@
}
setButtonVariant(nextButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning');
nextButton.setAttribute('aria-label', 'Next slide');
+ nextButton.setAttribute('title', 'Next slide');
var nextForm = nextButton.form;
if (nextForm) {
var nextCommandInput = nextForm.querySelector('input[name="command"]');
@@ -168,7 +253,7 @@
var actionCell = hasActionsColumn ? '
' + renderClientActionCell(client) + ' | ' : '';
return [
- '
',
+ '
',
'' + clientName + ' | ',
'' + screenName + ' | ',
'' + currentSlide + ' | ',
@@ -328,6 +413,141 @@
grid.innerHTML = screens.map(renderScreenTile).join('');
}
+ function updateScreenCommandControls(state) {
+ var select = document.getElementById('screen-command-select');
+ if (!select) {
+ return;
+ }
+
+ var forms = Array.prototype.slice.call(document.querySelectorAll('[data-screen-command-form]'));
+ var pill = document.querySelector('[data-screen-command-pill]');
+ var nameNode = document.querySelector('[data-screen-command-name]');
+ var metaNode = document.querySelector('[data-screen-command-meta]');
+ var screens = Array.isArray(state && state.screens) ? state.screens : [];
+ var screenBySlug = {};
+
+ screens.forEach(function (screen) {
+ if (screen && screen.slug) {
+ screenBySlug[String(screen.slug)] = screen;
+ }
+ });
+
+ if (!screens.length) {
+ select.value = '';
+ select.disabled = true;
+ forms.forEach(function (form) {
+ form.querySelectorAll('button, input').forEach(function (control) {
+ control.disabled = true;
+ });
+ });
+ if (pill) {
+ pill.classList.remove('is-live');
+ pill.classList.add('is-idle');
+ pill.textContent = 'No screens';
+ }
+ if (nameNode) {
+ nameNode.textContent = 'No target available';
+ }
+ if (metaNode) {
+ metaNode.textContent = 'Create a screen before using screen-level commands.';
+ }
+ return;
+ }
+
+ select.disabled = false;
+ if (!select.value || !screenBySlug[select.value]) {
+ select.value = screens[0].slug || '';
+ }
+
+ var selectedScreen = screenBySlug[select.value] || screens[0];
+ var selectedSlug = String(selectedScreen && selectedScreen.slug || '').trim();
+ var selectedClients = Array.isArray(state && state.clients) ? state.clients.filter(function (client) {
+ return String(client && client.screen_slug || '').trim() === selectedSlug;
+ }) : [];
+ var connectionCount = selectedClients.length;
+ var hasClients = connectionCount > 0;
+ var allPaused = hasClients && selectedClients.every(function (client) {
+ return Boolean(client && client.paused);
+ });
+ var allBlackout = hasClients && selectedClients.every(function (client) {
+ return Boolean(client && client.blackout);
+ });
+ var connectionLabel = hasClients ? connectionCount + ' connected client' + (connectionCount === 1 ? '' : 's') : 'No clients connected';
+
+ if (pill) {
+ pill.classList.toggle('is-live', hasClients);
+ pill.classList.toggle('is-idle', !hasClients);
+ pill.textContent = connectionLabel;
+ }
+ if (nameNode) {
+ nameNode.textContent = String(selectedScreen && selectedScreen.name || 'Selected screen');
+ }
+ if (metaNode) {
+ metaNode.textContent = 'Commands sent here target every client currently using this screen.';
+ }
+
+ forms.forEach(function (form) {
+ var command = String(form.getAttribute('data-screen-command-action') || '').trim().toLowerCase();
+ var commandInput = form.querySelector('input[name="command"]');
+ var button = form.querySelector('button[type="submit"]');
+ if (commandInput) {
+ if (command === 'pause') {
+ commandInput.value = allPaused ? 'pause' : 'pause';
+ var pauseStateInput = form.querySelector('input[name="paused"]');
+ if (pauseStateInput) {
+ pauseStateInput.value = allPaused ? 'false' : 'true';
+ }
+ if (button) {
+ button.innerHTML = '' + (allPaused ? 'Resume screen' : 'Pause screen');
+ }
+ setButtonVariant(button, ['btn-success', 'btn-info', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
+ form.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients on this screen?' : 'Pause all connected clients on this screen?');
+ } else if (command === 'blackout') {
+ commandInput.value = 'blackout';
+ var blackoutStateInput = form.querySelector('input[name="blackout"]');
+ if (blackoutStateInput) {
+ blackoutStateInput.value = allBlackout ? 'false' : 'true';
+ }
+ if (button) {
+ button.innerHTML = '' + (allBlackout ? 'Restore screen' : 'Blackout screen');
+ }
+ setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-danger', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary');
+ form.setAttribute('data-confirm-message', allBlackout ? 'Restore all connected clients on this screen?' : 'Blackout all connected clients on this screen?');
+ } else {
+ commandInput.value = command || commandInput.value || '';
+ }
+ }
+ form.action = selectedSlug ? '/clients/' + encodeURIComponent(selectedSlug) + '/commands' : '#';
+ if (command === 'reload') {
+ form.setAttribute('data-confirm-message', 'Reload selected screen?');
+ if (button) {
+ button.innerHTML = 'Reload screen';
+ }
+ }
+ Array.prototype.slice.call(form.querySelectorAll('button, input')).forEach(function (control) {
+ control.disabled = !selectedSlug;
+ });
+ });
+ }
+
+ function readScreenCommandStateFromDom() {
+ var select = document.getElementById('screen-command-select');
+ if (!select) {
+ return { screens: [] };
+ }
+
+ return {
+ screens: Array.prototype.slice.call(select.options || []).map(function (option) {
+ return {
+ slug: String(option.value || '').trim(),
+ name: String(option.getAttribute('data-screen-name') || option.textContent || option.value || '').trim(),
+ player_connection_count: Number(option.getAttribute('data-player-connection-count') || 0),
+ playlist_name: String(option.getAttribute('data-playlist-name') || '').trim()
+ };
+ })
+ };
+ }
+
function updateDashboardQuickActions(state) {
var pauseButton = document.getElementById('dashboard-pause-all-button');
var blackoutButton = document.getElementById('dashboard-blackout-all-button');
@@ -378,8 +598,10 @@
if (!state) {
return;
}
+ latestDashboardState = state;
updateStats(state);
updateScreenGrid(state);
+ updateScreenCommandControls(state);
updateClientTable(state);
updateDashboardQuickActions(state);
}
@@ -468,7 +690,98 @@
});
}
+ function initClientMoveHandler() {
+ var elements = getClientMoveModalElements();
+ if (!elements.modal || !elements.form || !elements.targetSelect) {
+ return;
+ }
+
+ document.addEventListener('click', function (event) {
+ var moveButton = event.target && event.target.closest ? event.target.closest('button[data-action="move-screen"]') : null;
+ if (!moveButton) {
+ return;
+ }
+
+ var row = moveButton.closest ? moveButton.closest('tr[data-client-key]') : null;
+ if (!row) {
+ return;
+ }
+
+ updateClientMoveModalFromRow(row);
+ });
+
+ elements.form.addEventListener('submit', function (event) {
+ if (elements.form.dataset && elements.form.dataset.busy === 'true') {
+ event.preventDefault();
+ return;
+ }
+
+ var targetScreenSlug = String(elements.targetSelect.value || '').trim();
+ if (!targetScreenSlug) {
+ event.preventDefault();
+ window.alert('Choose a target screen.');
+ return;
+ }
+
+ event.preventDefault();
+ elements.form.dataset.busy = 'true';
+
+ var formData = new FormData(elements.form);
+ var body = new URLSearchParams();
+ formData.forEach(function (value, key) {
+ body.append(key, value);
+ });
+
+ fetch(elements.form.action, {
+ method: (elements.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'
+ }).then(function (response) {
+ if (!response.ok) {
+ return response.text().then(function (text) {
+ var error = new Error(text || 'Unable to move client.');
+ try {
+ var payload = JSON.parse(text);
+ if (payload && (payload.error || payload.message)) {
+ error = new Error(String(payload.error || payload.message));
+ }
+ } catch (_error) {
+ // fall back to the raw text body
+ }
+ throw error;
+ });
+ }
+
+ if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
+ window.pulseModal.hide(elements.modal);
+ } else if (window.bootstrap && window.bootstrap.Modal) {
+ window.bootstrap.Modal.getOrCreateInstance(elements.modal).hide();
+ }
+ return response.json().catch(function () {
+ return { ok: true };
+ });
+ }).catch(function (error) {
+ window.alert(error && error.message ? error.message : 'Unable to move client.');
+ }).finally(function () {
+ delete elements.form.dataset.busy;
+ });
+ });
+ }
+
window.webHandleDashboardState = handleDashboardState;
initClientRenameHandler();
+ initClientMoveHandler();
+ var screenCommandSelect = document.getElementById('screen-command-select');
+ if (screenCommandSelect) {
+ updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
+ screenCommandSelect.addEventListener('change', function () {
+ updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
+ });
+ }
}());
diff --git a/src/web/public/js/data-sources/schedule-group-form.js b/src/web/public/js/data-sources/schedule-group-form.js
deleted file mode 100644
index 6dcb011..0000000
--- a/src/web/public/js/data-sources/schedule-group-form.js
+++ /dev/null
@@ -1,35 +0,0 @@
-// Minimal row editor for schedule groups.
-
-(function () {
- var form = document.getElementById('schedule-group-form');
- var body = document.querySelector('[data-schedule-entries-body]');
- var addButton = document.querySelector('[data-add-schedule-entry]');
- var template = document.getElementById('schedule-entry-row-template');
-
- if (!form || !body || !addButton || !template) {
- return;
- }
-
- function bindRemove(row) {
- var button = row.querySelector('[data-remove-schedule-entry]');
- if (!button) {
- return;
- }
- button.addEventListener('click', function () {
- row.remove();
- });
- }
-
- function addRow() {
- var fragment = template.content.cloneNode(true);
- var row = fragment.querySelector('[data-schedule-entry-row]');
- if (!row) {
- return;
- }
- bindRemove(row);
- body.appendChild(fragment);
- }
-
- body.querySelectorAll('[data-schedule-entry-row]').forEach(bindRemove);
- addButton.addEventListener('click', addRow);
-}());
\ No newline at end of file
diff --git a/src/web/public/js/data-sources/timetable-group-form.js b/src/web/public/js/data-sources/timetable-group-form.js
new file mode 100644
index 0000000..c282e2b
--- /dev/null
+++ b/src/web/public/js/data-sources/timetable-group-form.js
@@ -0,0 +1,121 @@
+// Minimal row editor for timetable groups.
+
+(function () {
+ var form = document.getElementById('timetable-group-form');
+ var body = document.querySelector('[data-timetable-entries-body]');
+ var addButton = document.querySelector('[data-add-timetable-entry]');
+ var template = document.getElementById('timetable-entry-row-template');
+
+ if (!form || !body || !addButton || !template) {
+ return;
+ }
+
+ function pad(value) {
+ return String(value).padStart(2, '0');
+ }
+
+ function formatDateTimeLocalValue(date) {
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
+ return '';
+ }
+
+ return [
+ String(date.getFullYear()).padStart(4, '0'),
+ '-',
+ pad(date.getMonth() + 1),
+ '-',
+ pad(date.getDate()),
+ 'T',
+ pad(date.getHours()),
+ ':',
+ pad(date.getMinutes())
+ ].join('');
+ }
+
+ function parseUtcDateTimeLocalValue(value) {
+ var raw = String(value || '').trim();
+ if (!raw) {
+ return null;
+ }
+
+ var normalized = /(?:[zZ]|[+-]\d\d(?::?\d\d)?)$/.test(raw) ? raw : raw + 'Z';
+ var date = new Date(normalized);
+ return Number.isNaN(date.getTime()) ? null : date;
+ }
+
+ function toUtcDateTimeLocalValue(value) {
+ var localDate = new Date(String(value || '').trim());
+ return Number.isNaN(localDate.getTime()) ? '' : localDate.toISOString();
+ }
+
+ function syncRowValuesToLocal(row) {
+ if (!row) {
+ return;
+ }
+
+ ['entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
+ var input = row.querySelector('[name="' + name + '"]');
+ if (!input || input.dataset.timetableTimezoneSynced === 'true') {
+ return;
+ }
+
+ var localValue = formatDateTimeLocalValue(parseUtcDateTimeLocalValue(input.value));
+ if (localValue) {
+ input.value = localValue;
+ }
+ input.dataset.timetableTimezoneSynced = 'true';
+ });
+ }
+
+ function syncFormDataToUtc(formData) {
+ var rows = body.querySelectorAll('[data-timetable-entry-row]');
+ ['entry_id[]', 'entry_title[]', 'entry_short_description[]', 'entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
+ formData.delete(name);
+ });
+
+ rows.forEach(function (row) {
+ var idInput = row.querySelector('[name="entry_id[]"]');
+ var titleInput = row.querySelector('[name="entry_title[]"]');
+ var descriptionInput = row.querySelector('[name="entry_short_description[]"]');
+ var startInput = row.querySelector('[name="entry_start_datetime[]"]');
+ var endInput = row.querySelector('[name="entry_end_datetime[]"]');
+
+ formData.append('entry_id[]', idInput ? idInput.value : '');
+ formData.append('entry_title[]', titleInput ? titleInput.value : '');
+ formData.append('entry_short_description[]', descriptionInput ? descriptionInput.value : '');
+ formData.append('entry_start_datetime[]', startInput ? toUtcDateTimeLocalValue(startInput.value) : '');
+ formData.append('entry_end_datetime[]', endInput ? toUtcDateTimeLocalValue(endInput.value) : '');
+ });
+ }
+
+ function bindRemove(row) {
+ var button = row.querySelector('[data-remove-timetable-entry]');
+ if (!button) {
+ return;
+ }
+ button.addEventListener('click', function () {
+ row.remove();
+ });
+ }
+
+ function addRow() {
+ var fragment = template.content.cloneNode(true);
+ var row = fragment.querySelector('[data-timetable-entry-row]');
+ if (!row) {
+ return;
+ }
+ bindRemove(row);
+ body.appendChild(fragment);
+ }
+
+ body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) {
+ bindRemove(row);
+ syncRowValuesToLocal(row);
+ });
+
+ form.addEventListener('formdata', function (event) {
+ syncFormDataToUtc(event.formData);
+ });
+
+ addButton.addEventListener('click', addRow);
+}());
\ No newline at end of file
diff --git a/src/web/public/js/playlists/playlist-schedule.js b/src/web/public/js/playlists/playlist-schedule.js
index 736378a..f72c53a 100644
--- a/src/web/public/js/playlists/playlist-schedule.js
+++ b/src/web/public/js/playlists/playlist-schedule.js
@@ -1,9 +1,6 @@
(function () {
// Shared constants used across the playlist schedule editor.
var DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
- var VIDEO_DURATION_LABEL = 'Use video duration';
- var VIDEO_DURATION_ACTIVE_LABEL = 'Use Video Duration';
- var VIDEO_DURATION_LOADING_LABEL = 'Loading...';
var PICKER_THUMB_EMPTY_LABEL = 'No thumbnail';
var PICKER_ASSIGNED_BADGE_LABEL = 'Already in playlist';
var PICKER_CARD_CLASS = 'playlist-slide-picker-card';
@@ -12,12 +9,8 @@
var PICKER_CHECK_CLASS = 'playlist-slide-picker-check bi bi-check2-circle';
var PICKER_BADGE_CLASS = 'playlist-slide-picker-badge badge text-bg-primary';
var SCHEDULE_ROW_FIELD_NAMES = {
- mode: '[name="schedule_mode[]"]',
- startDatetime: '[name="schedule_start_datetime[]"]',
- endDatetime: '[name="schedule_end_datetime[]"]',
- startTime: '[name="schedule_start_time[]"]',
- endTime: '[name="schedule_end_time[]"]',
- daysJson: '[name="schedule_days_json[]"]'
+ rulesContainer: '[data-schedule-rules-container]',
+ summaryNode: '.playlist-schedule-summary'
};
// Schedule helpers.
@@ -41,34 +34,230 @@
.join(', ');
}
+ function normalizeScheduleRuleValue(rule) {
+ var input = rule && typeof rule === 'object' ? rule : {};
+ var startDatetime = String(input.start_datetime || input.startDateTime || input.startDateTimeValue || '').trim();
+ var endDatetime = String(input.end_datetime || input.endDateTime || input.endDateTimeValue || '').trim();
+ var startTime = String(input.start_time || input.startTime || input.startTimeValue || '').trim();
+ var endTime = String(input.end_time || input.endTime || input.endTimeValue || '').trim();
+ var days = [];
+
+ if (Array.isArray(input.days)) {
+ days = input.days;
+ } else if (Array.isArray(input.dayOptions)) {
+ days = input.dayOptions.filter(function (dayOption) {
+ return Boolean(dayOption && dayOption.checked);
+ }).map(function (dayOption) {
+ return dayOption.value;
+ });
+ } else if (input.daysCsv !== undefined && input.daysCsv !== null && String(input.daysCsv).trim()) {
+ days = String(input.daysCsv).split(',');
+ } else if (input.days !== undefined && input.days !== null && String(input.days).trim()) {
+ try {
+ var parsedDays = JSON.parse(String(input.days));
+ days = Array.isArray(parsedDays) ? parsedDays : [];
+ } catch (_error) {
+ days = String(input.days).split(',');
+ }
+ }
+
+ days = Array.from(new Set(days.map(function (day) {
+ return Number(day);
+ }).filter(function (day) {
+ return Number.isInteger(day) && day >= 0 && day <= 6;
+ }))).sort(function (left, right) {
+ return left - right;
+ });
+
+ if (startDatetime || endDatetime) {
+ if (!startDatetime || !endDatetime) {
+ return null;
+ }
+ }
+
+ if (startTime || endTime) {
+ if (!startTime || !endTime) {
+ return null;
+ }
+ }
+
+ if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) {
+ return null;
+ }
+
+ var normalized = {};
+ if (startDatetime) {
+ normalized.start_datetime = startDatetime;
+ }
+ if (endDatetime) {
+ normalized.end_datetime = endDatetime;
+ }
+ if (startTime) {
+ normalized.start_time = startTime;
+ }
+ if (endTime) {
+ normalized.end_time = endTime;
+ }
+ if (days.length) {
+ normalized.days = days;
+ }
+
+ return normalized;
+ }
+
+ function parseScheduleRulesValue(value) {
+ var rawRules = [];
+
+ if (Array.isArray(value)) {
+ rawRules = value;
+ } else {
+ var raw = String(value || '').trim();
+ if (!raw) {
+ return [];
+ }
+ try {
+ var parsed = JSON.parse(raw);
+ rawRules = Array.isArray(parsed) ? parsed : [];
+ } catch (_error) {
+ return [];
+ }
+ }
+
+ return rawRules.map(function (rule) {
+ return normalizeScheduleRuleValue(rule);
+ }).filter(Boolean);
+ }
+
+ function formatScheduleRuleSummary(rule) {
+ var normalized = normalizeScheduleRuleValue(rule);
+ var left = [];
+ var right = [];
+
+ if (!normalized) {
+ return 'Always visible';
+ }
+
+ if (normalized.start_datetime && normalized.end_datetime) {
+ left.push('Dates ' + normalized.start_datetime.replace('T', ' ').slice(0, 16) + ' to ' + normalized.end_datetime.replace('T', ' ').slice(0, 16));
+ } else {
+ left.push('Any date');
+ }
+
+ var days = formatDays(normalized.days || []);
+ if (days) {
+ right.push(days);
+ }
+ if (normalized.start_time && normalized.end_time) {
+ right.push(normalized.start_time.slice(0, 5) + '-' + normalized.end_time.slice(0, 5));
+ }
+
+ if (right.length) {
+ return left[0] + ': ' + right.join(' ');
+ }
+
+ return left[0];
+ }
+
+ function formatScheduleRulesSummary(value) {
+ var rules = parseScheduleRulesValue(value);
+
+ if (!rules.length) {
+ return 'Always visible';
+ }
+
+ return rules.length === 1 ? '1 rule' : String(rules.length) + ' rules';
+ }
+
function getScheduleRowFields(row) {
return {
- mode: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.mode) : null,
- startDatetime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.startDatetime) : null,
- endDatetime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.endDatetime) : null,
- startTime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.startTime) : null,
- endTime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.endTime) : null,
- daysJson: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.daysJson) : null
+ rulesContainer: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.rulesContainer) : null,
+ summaryNode: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.summaryNode) : null
};
}
+ function buildScheduleRuleHiddenInputs(rule, rowKey, position, formId) {
+ var normalizedRule = normalizeScheduleRuleValue(rule) || {};
+ var daysCsv = Array.isArray(normalizedRule.days) ? normalizedRule.days.join(',') : '';
+ var formAttr = formId ? ' form="' + formId + '"' : '';
+
+ return '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '';
+ }
+
+ function buildScheduleRulesMarkup(values, rowKey, formId) {
+ var rules = Array.isArray(values) ? values : [];
+ return rules.map(function (rule, index) {
+ return buildScheduleRuleHiddenInputs(rule, rowKey, index, formId);
+ }).join('');
+ }
+
// Video duration helpers.
+ function getVideoDurationButtonLabel(isPressed) {
+ return isPressed ? 'Disable use video duration' : 'Use video duration';
+ }
+
+ function buildVideoDurationButtonContent(isPressed) {
+ return '' +
+ '' +
+ '' + getVideoDurationButtonLabel(isPressed) + '';
+ }
+
function buildVideoDurationButtonMarkup(isPressed, enabled) {
if (!enabled) {
return '';
}
- return '';
+ return '';
}
- function syncVideoDurationButtonState(button, isPressed, label) {
+ function getDisableAudioButtonLabel(isPressed) {
+ return isPressed ? 'Enable audio' : 'Mute slide';
+ }
+
+ function buildDisableAudioButtonContent(isPressed) {
+ return '' +
+ '' +
+ '' + getDisableAudioButtonLabel(isPressed) + '';
+ }
+
+ function buildDisableAudioButtonMarkup(isPressed, enabled) {
+ if (!enabled) {
+ return '';
+ }
+
+ return '';
+ }
+
+ function syncDisableAudioButtonState(button, isPressed) {
+ if (!button) {
+ return;
+ }
+
+ button.classList.toggle('btn-outline-secondary', Boolean(isPressed));
+ button.classList.toggle('btn-secondary', !Boolean(isPressed));
+ button.classList.toggle('active', !Boolean(isPressed));
+ button.setAttribute('aria-pressed', isPressed ? 'true' : 'false');
+ button.setAttribute('aria-label', getDisableAudioButtonLabel(isPressed));
+ button.setAttribute('title', getDisableAudioButtonLabel(isPressed));
+ button.innerHTML = buildDisableAudioButtonContent(isPressed);
+ }
+
+ function syncVideoDurationButtonState(button, isPressed) {
if (!button) {
return;
}
button.classList.toggle('active', Boolean(isPressed));
button.setAttribute('aria-pressed', isPressed ? 'true' : 'false');
- button.textContent = label || (isPressed ? VIDEO_DURATION_ACTIVE_LABEL : VIDEO_DURATION_LABEL);
+ button.setAttribute('aria-label', getVideoDurationButtonLabel(isPressed));
+ button.setAttribute('title', getVideoDurationButtonLabel(isPressed));
+ button.innerHTML = buildVideoDurationButtonContent(isPressed);
}
// Slide picker helpers.
@@ -86,11 +275,9 @@
}
card.setAttribute('data-is-assigned', isAssigned ? 'true' : 'false');
- card.disabled = isAssigned || isCanvasMismatch;
card.classList.toggle('is-assigned', isAssigned);
card.classList.toggle('is-mismatch', isCanvasMismatch);
card.classList.toggle('is-hidden', false);
- card.setAttribute('aria-disabled', card.disabled ? 'true' : 'false');
}
function createElementWithClass(tagName, className, textContent) {
@@ -106,6 +293,19 @@
return element;
}
+ function escapeHtmlAttribute(value) {
+ return String(value === undefined || value === null ? '' : value)
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+ .replace(//g, '>');
+ }
+
+ function buildScheduleSummaryChipMarkup(summary) {
+ return '' + escapeHtmlAttribute(String(summary || 'Always visible')) + '';
+ }
+
function buildSlidePickerThumbPlaceholder() {
var placeholder = createElementWithClass('div', 'playlist-slide-picker-thumb-placeholder');
var icon = createElementWithClass('i', 'bi bi-image playlist-slide-picker-thumb-placeholder-icon');
@@ -160,6 +360,10 @@
return Boolean(target && target.closest && target.closest('.playlist-use-video-duration'));
}
+ function isAudioControl(target) {
+ return Boolean(target && target.closest && target.closest('.playlist-disable-audio'));
+ }
+
function getDurationPointerState(target, type) {
var row = getPlaylistSlideRow(target);
var pointerType = type === 'button' ? 'button' : 'input';
@@ -192,285 +396,671 @@
'' + values.title + '
' +
'' +
'' +
+ '' +
'';
}
- function buildPlaylistScheduleCellMarkup(values) {
+ function buildPlaylistScheduleCellMarkup(values, rowKey) {
return '' +
- '' +
- ' ' + values.summary + ' ' +
- '' +
- '' +
- '' +
- '' +
- '' +
- '' +
+ ' | ' +
+ ' ' + buildScheduleSummaryChipMarkup(values.summary || 'Always visible') + ' ' +
+ '' +
+ buildScheduleRulesMarkup(values.scheduleRules, rowKey, 'playlist-edit-form') +
+ ' ' +
' | ';
}
function buildPlaylistDurationCellMarkup(values, durationActionMarkup) {
return '' +
- '' +
+ ' ' +
'' +
'' +
+ '' +
(values.useVideoDuration ? '' : '') +
durationActionMarkup +
' | ';
}
function buildPlaylistActionsCellMarkup(playlistId, rowKey) {
+ var scheduleUrl = '/playlists/new/slides/0/config';
+ var scheduleButtonMarkup = ' ';
+
return '' +
' ' +
- '' +
+ scheduleButtonMarkup +
'' +
' | ';
}
- // Schedule modal wiring.
- function initPlaylistScheduleModal() {
+ function collectScheduleParams(row, rowKey) {
+ var params = new URLSearchParams();
+ var fields = row ? Array.prototype.slice.call(row.querySelectorAll('[name^="schedule_rule_"]')) : [];
+
+ params.set('row_key', String(rowKey || ''));
+ fields.forEach(function (field) {
+ if (!field || !field.name) {
+ return;
+ }
+ params.append(field.name, String(field.value || ''));
+ });
+ return params;
+ }
+
+ function openScheduleModal(url) {
var dialog = document.getElementById('slide-schedule-dialog');
var content = document.getElementById('slide-schedule-content');
- var triggers = document.querySelectorAll('[data-schedule-config]');
if (!dialog || !content) {
return;
}
- function initInjectedContent() {
+ content.innerHTML = ' ';
+ if (typeof dialog.showModal === 'function') {
+ dialog.showModal();
+ } else {
+ dialog.setAttribute('open', 'open');
+ }
+
+ fetch(url, { credentials: 'same-origin' }).then(function (response) {
+ if (!response.ok) {
+ return response.text().then(function (text) {
+ throw new Error(text || 'Unable to open schedule editor.');
+ });
+ }
+ return response.text();
+ }).then(function (html) {
+ content.innerHTML = html;
if (typeof window.initPlaylistScheduleForm === 'function') {
window.initPlaylistScheduleForm(content);
}
- }
+ }).catch(function (error) {
+ content.innerHTML = ' ' + escapeHtmlAttribute(String(error && error.message ? error.message : 'Unable to open schedule editor.')) + ' ';
+ });
+ }
- function collectScheduleParams(row, rowKey) {
- var params = new URLSearchParams();
- var scheduleFields = Object.keys(SCHEDULE_ROW_FIELD_NAMES).map(function (key) {
- return SCHEDULE_ROW_FIELD_NAMES[key].replace(/^\[name="|"\]$/g, '');
- });
+ function closeScheduleModal() {
+ var dialog = document.getElementById('slide-schedule-dialog');
+ var content = document.getElementById('slide-schedule-content');
- params.set('row_key', String(rowKey || ''));
- scheduleFields.forEach(function (fieldName) {
- var field = row && row.querySelector ? row.querySelector('[name="' + fieldName + '"]') : null;
- var value = field && typeof field.value !== 'undefined' ? String(field.value || '') : '';
- if (value) {
- params.set(fieldName.replace(/\[\]$/, ''), value);
- }
- });
-
- return params;
- }
-
- function openScheduleModal(url) {
- content.innerHTML = ' ';
- if (typeof dialog.showModal === 'function') {
- dialog.showModal();
- } else {
- dialog.setAttribute('open', 'open');
- }
-
- fetch(url, {
- credentials: 'same-origin'
- }).then(function (response) {
- if (!response.ok) {
- return response.text().then(function (text) {
- throw new Error(text || 'Unable to open schedule editor.');
- });
- }
- return response.text();
- }).then(function (html) {
- content.innerHTML = html;
- initInjectedContent();
- }).catch(function (error) {
- content.innerHTML = ' ' + String(error && error.message ? error.message : 'Unable to open schedule editor.') + ' ';
- });
- }
-
- function closeScheduleModal() {
+ if (content) {
content.innerHTML = '';
- if (typeof dialog.close === 'function') {
- dialog.close();
- } else {
- dialog.removeAttribute('open');
- }
}
-
- window.openScheduleModal = openScheduleModal;
- window.closeScheduleModal = closeScheduleModal;
-
- if (triggers.length) {
- Array.prototype.forEach.call(triggers, function (trigger) {
- trigger.addEventListener('click', function () {
- var url = trigger.getAttribute('data-schedule-config');
- var rowKey = trigger.getAttribute('data-schedule-config-row') || '';
- var row = trigger.closest('tr[data-playlist-slide-row]');
- if (row) {
- var params = collectScheduleParams(row, rowKey || row.getAttribute('data-row-key') || '');
- url += (url.indexOf('?') === -1 ? '?' : '&') + params.toString();
- } else if (rowKey && url.indexOf('row_key=') === -1) {
- url += (url.indexOf('?') === -1 ? '?' : '&') + 'row_key=' + encodeURIComponent(rowKey);
- }
- openScheduleModal(url);
- });
- });
- }
-
- }
-
- // Schedule form behavior.
- function scheduleSummary(values) {
- var mode = String(values.schedule_mode || 'always');
- if (mode === 'dates') {
- if (values.schedule_start_datetime && values.schedule_end_datetime) {
- return 'Dates: ' + values.schedule_start_datetime.replace('T', ' ') + ' to ' + values.schedule_end_datetime.replace('T', ' ');
- }
- return 'Dates: not set';
- }
- if (mode === 'times') {
- var days = formatDays(values.schedule_days_json);
- if (days && values.schedule_start_time && values.schedule_end_time) {
- return 'Times: ' + days + ' ' + values.schedule_start_time.slice(0, 5) + '-' + values.schedule_end_time.slice(0, 5);
- }
- return 'Times: not set';
- }
- return 'Always visible';
- }
-
- function initPlaylistScheduleForm(root) {
- var scope = root || document;
- var form = scope.querySelector('form[action*="/config"]');
- var select = scope.querySelector('#schedule-mode-select');
- if (!form || !select) {
+ if (!dialog) {
return;
}
-
- var DEFAULT_START_TIME = '00:00';
- var DEFAULT_END_TIME = '23:59';
-
- var datesPanel = scope.querySelector('#schedule-dates-panel');
- var timesPanel = scope.querySelector('#schedule-times-panel');
- var startDateInput = form.querySelector('[name="schedule_start_datetime"]');
- var endDateInput = form.querySelector('[name="schedule_end_datetime"]');
- var startTimeInput = form.querySelector('[name="schedule_start_time"]');
- var endTimeInput = form.querySelector('[name="schedule_end_time"]');
- var rowKeyInput = form.querySelector('[name="row_key"]');
- var dayCheckboxes = form.querySelectorAll('[name="schedule_days"]');
-
- function getDayButton(checkbox) {
- return checkbox && checkbox.id ? form.querySelector('label[for="' + checkbox.id + '"]') : null;
+ if (typeof dialog.close === 'function') {
+ dialog.close();
+ } else {
+ dialog.removeAttribute('open');
}
+ }
- function syncDayButtonState(checkbox) {
- var button = getDayButton(checkbox);
+ function initPlaylistScheduleModal() {
+ window.openScheduleModal = openScheduleModal;
+ window.closeScheduleModal = closeScheduleModal;
+ }
- if (!button) {
+ // Keep schedule rule markup in sync with each rendered playlist row.
+ function hydrateScheduleRowContainers(root) {
+ var scope = root || document;
+ var rows = Array.prototype.slice.call(scope.querySelectorAll('[data-playlist-slide-row]'));
+
+ rows.forEach(function (row) {
+ var fields = getScheduleRowFields(row);
+ var data = fields.rulesContainer ? fields.rulesContainer.getAttribute('data-schedule-rules') : '';
+ var rules = [];
+
+ if (!fields.rulesContainer) {
return;
}
- button.classList.toggle('active', Boolean(checkbox.checked));
- button.setAttribute('aria-pressed', checkbox.checked ? 'true' : 'false');
+ if (!data && fields.rulesContainer.children.length) {
+ return;
+ }
+
+ if (data) {
+ try {
+ rules = JSON.parse(data);
+ } catch (_error) {
+ rules = [];
+ }
+ }
+
+ if (!fields.rulesContainer.children.length) {
+ fields.rulesContainer.innerHTML = buildScheduleRulesMarkup(rules, row.getAttribute('data-row-key') || '', 'playlist-edit-form');
+ }
+ });
+ }
+
+ function setRowSchedule(row, values) {
+ var rowKey = String(values && values.row_key ? values.row_key : row && row.getAttribute('data-row-key') || '');
+ var fields = getScheduleRowFields(row);
+ var rules = Array.isArray(values && values.scheduleRules) ? values.scheduleRules : [];
+ var summary = String(values && values.summary ? values.summary : formatScheduleRulesSummary(rules) || 'Always visible');
+
+ if (!row) {
+ return;
}
- function syncAllDayButtonStates() {
- Array.prototype.forEach.call(dayCheckboxes, syncDayButtonState);
+ if (fields.summaryNode) {
+ fields.summaryNode.innerHTML = buildScheduleSummaryChipMarkup(summary || 'Always visible');
+ }
+ if (fields.rulesContainer) {
+ fields.rulesContainer.setAttribute('data-schedule-rules', JSON.stringify(rules).replace(/' +
+ '' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ' +
+ ' ';
+ }
+
+ // Modal form state, validation, and submit handling.
+ function initPlaylistScheduleForm(root) {
+ var scope = root || document;
+ var form = scope.querySelector('form[action*="/config"], form[data-schedule-draft="true"]');
+ var scheduleRuleList = scope.querySelector('#schedule-rule-list');
+ var effectiveSummary = scope.querySelector('#schedule-effective-summary');
+ var addRuleButton = scope.querySelector('#schedule-add-rule');
+ var scheduleRuleTemplate = scope.querySelector('#schedule-rule-card-template');
+ if (!form || !scheduleRuleList) {
+ return;
+ }
+
+ var isDraftMode = Boolean(form.getAttribute('data-schedule-draft') === 'true');
+ var rowKeyInput = form.querySelector('[name="row_key"]');
+ var draftStore = window.__playlistScheduleDraftStore = window.__playlistScheduleDraftStore || {};
+ var cancelButton = scope.querySelector('[data-schedule-cancel]');
+
+ // Draft state is keyed by the row being edited so cancel/save can restore it later.
+ function getScheduleDraftKey() {
+ return String(rowKeyInput && rowKeyInput.value ? rowKeyInput.value : '').trim();
+ }
+
+ function readRuleDraftFromCard(card) {
+ var fields = getRuleFields(card);
+ var days = [];
+
+ Array.prototype.forEach.call(fields.days || [], function (checkbox) {
+ if (checkbox.checked) {
+ days.push(Number(checkbox.value));
+ }
+ });
+
+ days = Array.from(new Set(days)).sort(function (left, right) {
+ return left - right;
+ });
+
+ return {
+ start_datetime: String(fields.startDatetime && fields.startDatetime.value || ''),
+ end_datetime: String(fields.endDatetime && fields.endDatetime.value || ''),
+ start_time: String(fields.startTime && fields.startTime.value || ''),
+ end_time: String(fields.endTime && fields.endTime.value || ''),
+ days: days
+ };
+ }
+
+ function getScheduleDraftRules() {
+ var draftKey = getScheduleDraftKey();
+ if (!draftKey || !Object.prototype.hasOwnProperty.call(draftStore, draftKey)) {
+ return null;
+ }
+
+ return Array.isArray(draftStore[draftKey]) ? draftStore[draftKey] : [];
+ }
+
+ function persistScheduleDraft() {
+ var draftKey = getScheduleDraftKey();
+ if (!draftKey) {
+ return;
+ }
+
+ draftStore[draftKey] = getRuleCards().map(function (card) {
+ return {
+ rule: readRuleDraftFromCard(card),
+ isCollapsed: Boolean(card && card.classList && card.classList.contains('collapsed-card'))
+ };
+ });
+ }
+
+ function clearScheduleDraft() {
+ var draftKey = getScheduleDraftKey();
+ if (draftKey && Object.prototype.hasOwnProperty.call(draftStore, draftKey)) {
+ delete draftStore[draftKey];
+ }
+ }
+
+ window.clearPlaylistScheduleDraft = clearScheduleDraft;
+
+ function getRuleCards() {
+ return Array.prototype.slice.call(scheduleRuleList.querySelectorAll('[data-schedule-rule-card]'));
+ }
+
+ function getRuleFields(card) {
+ return {
+ startDatetime: card ? card.querySelector('[data-schedule-rule-start-datetime]') : null,
+ endDatetime: card ? card.querySelector('[data-schedule-rule-end-datetime]') : null,
+ startTime: card ? card.querySelector('[data-schedule-rule-start-time]') : null,
+ endTime: card ? card.querySelector('[data-schedule-rule-end-time]') : null,
+ days: card ? card.querySelectorAll('[data-schedule-rule-day]') : []
+ };
+ }
+
+ function getRuleSummaryNode(card) {
+ return card ? card.querySelector('[data-schedule-rule-summary]') : null;
+ }
+
+ function getRuleNumberNode(card) {
+ return card ? card.querySelector('[data-schedule-rule-number]') : null;
+ }
+
+ function getRuleRowKeyNode(card) {
+ return card ? card.querySelector('[data-schedule-rule-row-key]') : null;
+ }
+
+ function getRulePositionNode(card) {
+ return card ? card.querySelector('[data-schedule-rule-position]') : null;
+ }
+
+ function getRemoveRuleButton(card) {
+ return card ? card.querySelector('[data-remove-schedule-rule]') : null;
+ }
+
+ function clearRuleValidity(card) {
+ var fields = getRuleFields(card);
+ [fields.startDatetime, fields.endDatetime, fields.startTime, fields.endTime].forEach(function (input) {
if (input) {
input.setCustomValidity('');
}
});
}
- function setScheduleError(input, message) {
+ function setRuleError(input, message) {
if (!input) {
return;
}
input.setCustomValidity(message);
}
- function validateScheduleForm() {
- var mode = select.value;
- var firstDayCheckbox = dayCheckboxes.length ? dayCheckboxes[0] : null;
+ function validateRuleCard(card) {
+ var fields = getRuleFields(card);
+ var startDatetime = String(fields.startDatetime && fields.startDatetime.value || '').trim();
+ var endDatetime = String(fields.endDatetime && fields.endDatetime.value || '').trim();
+ var startTime = String(fields.startTime && fields.startTime.value || '').trim();
+ var endTime = String(fields.endTime && fields.endTime.value || '').trim();
+ var hasDateInput = Boolean(startDatetime || endDatetime);
+ var hasTimeInput = Boolean(startTime || endTime);
- clearScheduleValidity();
+ clearRuleValidity(card);
- if (mode === 'dates') {
- if (!startDateInput.value) {
- setScheduleError(startDateInput, 'Start datetime is required for this schedule mode.');
- }
- if (!endDateInput.value) {
- setScheduleError(endDateInput, 'End datetime is required for this schedule mode.');
- }
- if (startDateInput.value && endDateInput.value && new Date(endDateInput.value) < new Date(startDateInput.value)) {
- setScheduleError(endDateInput, 'End datetime must be after start datetime.');
- }
+ if (hasDateInput && (!startDatetime || !endDatetime)) {
+ setRuleError(fields.startDatetime || fields.endDatetime, 'Start and end datetimes must both be set for this rule.');
+ return false;
+ }
+ if (hasTimeInput && (!startTime || !endTime)) {
+ setRuleError(fields.startTime || fields.endTime, 'Start and end times must both be set for this rule.');
+ return false;
+ }
+ if (startDatetime && endDatetime && new Date(endDatetime) < new Date(startDatetime)) {
+ setRuleError(fields.endDatetime, 'End datetime must be after start datetime.');
+ return false;
+ }
+ if (startTime && endTime && endTime < startTime) {
+ setRuleError(fields.endTime, 'End time must be after start time.');
+ return false;
}
- if (mode === 'times') {
- if (!startTimeInput.value) {
- setScheduleError(startTimeInput, 'Start time is required for this schedule mode.');
- }
- if (!endTimeInput.value) {
- setScheduleError(endTimeInput, 'End time is required for this schedule mode.');
- }
- if (startTimeInput.value && endTimeInput.value && endTimeInput.value < startTimeInput.value) {
- setScheduleError(endTimeInput, 'End time must be after start time.');
- }
- if (!Array.prototype.some.call(dayCheckboxes, function (checkbox) {
- return checkbox.checked;
- })) {
- setScheduleError(firstDayCheckbox, 'Select at least one day.');
- }
- }
-
- return form.checkValidity();
+ return true;
}
- [startDateInput, endDateInput, startTimeInput, endTimeInput].forEach(function (input) {
- if (input) {
- input.addEventListener('input', clearScheduleValidity);
- }
- });
- Array.prototype.forEach.call(dayCheckboxes, function (checkbox) {
- checkbox.addEventListener('change', function () {
- syncDayButtonState(checkbox);
- clearScheduleValidity();
+ function readRuleFromCard(card) {
+ var fields = getRuleFields(card);
+ var days = [];
+
+ Array.prototype.forEach.call(fields.days || [], function (checkbox) {
+ if (checkbox.checked) {
+ days.push(Number(checkbox.value));
+ }
});
- var button = getDayButton(checkbox);
- if (button) {
- button.addEventListener('click', function (event) {
- event.preventDefault();
- checkbox.checked = !checkbox.checked;
- checkbox.dispatchEvent(new Event('change', { bubbles: true }));
- checkbox.focus();
- });
- }
- });
- select.addEventListener('change', clearScheduleValidity);
+ days = Array.from(new Set(days)).sort(function (left, right) {
+ return left - right;
+ });
- function updateVisibility() {
- var mode = select.value;
- if (datesPanel) {
- datesPanel.classList.toggle('is-hidden', mode !== 'dates');
- }
- if (timesPanel) {
- timesPanel.classList.toggle('is-hidden', mode !== 'times');
- }
- if (mode === 'times') {
- if (!startTimeInput.value) {
- startTimeInput.value = DEFAULT_START_TIME;
- }
- if (!endTimeInput.value) {
- endTimeInput.value = DEFAULT_END_TIME;
- }
- }
+ return normalizeScheduleRuleValue({
+ start_datetime: fields.startDatetime && fields.startDatetime.value || '',
+ end_datetime: fields.endDatetime && fields.endDatetime.value || '',
+ start_time: fields.startTime && fields.startTime.value || '',
+ end_time: fields.endTime && fields.endTime.value || '',
+ days: days
+ });
}
- select.addEventListener('change', updateVisibility);
- updateVisibility();
- syncAllDayButtonStates();
+ function updateRuleCardSummary(card) {
+ var summaryNode = getRuleSummaryNode(card);
+ var rule = readRuleFromCard(card);
+
+ if (summaryNode) {
+ summaryNode.textContent = formatScheduleRuleSummary(rule);
+ }
+ return rule;
+ }
+
+ function refreshRuleNumbers() {
+ getRuleCards().forEach(function (card, index) {
+ var numberNode = getRuleNumberNode(card);
+ var rowKeyNode = getRuleRowKeyNode(card);
+ var positionNode = getRulePositionNode(card);
+ var removeButton = getRemoveRuleButton(card);
+ var ruleIndex = index + 1;
+ if (numberNode) {
+ numberNode.textContent = String(ruleIndex);
+ }
+ if (rowKeyNode) {
+ rowKeyNode.value = String(getScheduleDraftKey() || '');
+ }
+ if (positionNode) {
+ positionNode.value = String(index);
+ }
+ if (removeButton) {
+ removeButton.disabled = false;
+ }
+ card.setAttribute('data-schedule-rule-key', 'schedule-rule-' + ruleIndex);
+ });
+ }
+
+ function renderEffectiveSummary(rules) {
+ if (!effectiveSummary) {
+ return;
+ }
+
+ effectiveSummary.innerHTML = '';
+ if (!rules.length) {
+ effectiveSummary.textContent = 'Always visible';
+ return;
+ }
+
+ var list = document.createElement('ul');
+ list.className = 'mb-0';
+ rules.forEach(function (rule, index) {
+ var item = document.createElement('li');
+ item.textContent = 'Rule ' + (index + 1) + ': ' + formatScheduleRuleSummary(rule);
+ list.appendChild(item);
+ });
+ effectiveSummary.appendChild(list);
+ }
+
+ // Rebuild the hidden form fields and keep the live summary chip current.
+ function syncScheduleRulesField() {
+ var rules = [];
+ getRuleCards().forEach(function (card) {
+ var rule = readRuleFromCard(card);
+ if (rule) {
+ rules.push(rule);
+ }
+ });
+ renderEffectiveSummary(rules);
+ persistScheduleDraft();
+
+ if (window.parent && typeof window.parent.applyPlaylistScheduleConfig === 'function') {
+ window.parent.applyPlaylistScheduleConfig({
+ row_key: String(rowKeyInput && rowKeyInput.value ? rowKeyInput.value : ''),
+ scheduleRules: rules,
+ summary: formatScheduleRulesSummary(rules)
+ });
+ }
+
+ return rules;
+ }
+
+ function getRuleCardTemplate() {
+ if (!scheduleRuleTemplate || !scheduleRuleTemplate.content) {
+ return null;
+ }
+
+ return scheduleRuleTemplate.content.firstElementChild;
+ }
+
+ function updateRuleCardDayOptions(card, ruleKey, normalizedRule) {
+ var dayInputs = card ? Array.prototype.slice.call(card.querySelectorAll('[data-schedule-rule-day]')) : [];
+ var dayLabels = card ? Array.prototype.slice.call(card.querySelectorAll('.schedule-day-button')) : [];
+
+ dayInputs.forEach(function (input, dayIndex) {
+ var label = dayLabels[dayIndex];
+ var isChecked = Array.isArray(normalizedRule.days) && normalizedRule.days.indexOf(dayIndex) !== -1;
+ var inputId = ruleKey + '-day-' + dayIndex;
+
+ input.id = inputId;
+ input.value = String(dayIndex);
+ input.checked = isChecked;
+ input.autocomplete = 'off';
+ input.setAttribute('data-schedule-rule-day', '');
+
+ if (label) {
+ label.setAttribute('for', inputId);
+ }
+ });
+ }
+
+ function createRuleCardElement(rule, ruleIndex, options) {
+ var ruleKey = 'schedule-rule-' + ruleIndex + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
+ var normalizedRule = normalizeScheduleRuleValue(rule) || {};
+ var cardTemplate = getRuleCardTemplate();
+ var isCollapsed = Boolean(options && options.isCollapsed);
+ var card;
+
+ if (!cardTemplate) {
+ return null;
+ }
+
+ card = cardTemplate.cloneNode(true);
+ card.setAttribute('data-schedule-rule-card', '');
+ card.setAttribute('data-schedule-rule-key', ruleKey);
+ card.querySelector('[data-schedule-rule-number]').textContent = String(ruleIndex);
+ card.querySelector('[data-schedule-rule-summary]').textContent = formatScheduleRuleSummary(normalizedRule);
+ card.querySelector('[data-schedule-rule-row-key]').value = String(getScheduleDraftKey() || '');
+ card.querySelector('[data-schedule-rule-position]').value = String(Math.max(0, ruleIndex - 1));
+ card.querySelector('[data-schedule-rule-start-datetime]').value = String(normalizedRule.start_datetime || '');
+ card.querySelector('[data-schedule-rule-end-datetime]').value = String(normalizedRule.end_datetime || '');
+ card.querySelector('[data-schedule-rule-start-time]').value = String(normalizedRule.start_time || '');
+ card.querySelector('[data-schedule-rule-end-time]').value = String(normalizedRule.end_time || '');
+ card.querySelector('[data-remove-schedule-rule]').setAttribute('data-confirm-message', 'Remove this rule?');
+ card.querySelector('[data-lte-toggle="card-collapse"]').setAttribute('aria-label', 'Collapse rule ' + String(ruleIndex));
+ card.querySelector('[data-lte-toggle="card-collapse"]').setAttribute('title', 'Collapse rule ' + String(ruleIndex));
+ card.querySelector('[data-lte-toggle="card-collapse"] .visually-hidden').textContent = 'Collapse rule ' + String(ruleIndex);
+ updateRuleCardDayOptions(card, ruleKey, normalizedRule);
+
+ card.querySelector('[data-lte-toggle="card-collapse"]').addEventListener('click', function (event) {
+ event.preventDefault();
+ card.classList.toggle('collapsed-card');
+ persistScheduleDraft();
+ });
+
+ if (isCollapsed) {
+ card.classList.add('collapsed-card');
+ } else {
+ card.classList.remove('collapsed-card');
+ }
+
+ return card;
+ }
+
+ function addRule(rule, options) {
+ var ruleCount = getRuleCards().length + 1;
+ var card = createRuleCardElement(rule || {}, ruleCount, options);
+ scheduleRuleList.appendChild(card);
+ refreshRuleNumbers();
+ syncScheduleRulesField();
+ return card;
+ }
+
+ function handleCardChange(card) {
+ clearRuleValidity(card);
+ updateRuleCardSummary(card);
+ syncScheduleRulesField();
+ }
+
+ function handleRuleRemoval(card) {
+ if (!card) {
+ return;
+ }
+ card.remove();
+ refreshRuleNumbers();
+ syncScheduleRulesField();
+ }
+
+ // Block saves when any rule is incomplete or out of order.
+ function validateScheduleForm() {
+ var isValid = true;
+
+ getRuleCards().forEach(function (card) {
+ if (!validateRuleCard(card)) {
+ isValid = false;
+ }
+ });
+
+ if (isValid) {
+ syncScheduleRulesField();
+ }
+
+ return isValid && form.checkValidity();
+ }
+
+ // Persist the modal form back to the server for non-draft rows.
+ function saveScheduleForm() {
+ return fetch(form.action, {
+ method: 'POST',
+ credentials: 'same-origin',
+ body: new URLSearchParams(new FormData(form))
+ }).then(function (response) {
+ if (!response.ok) {
+ return response.text().then(function (text) {
+ throw new Error(text || 'Unable to save schedule.');
+ });
+ }
+
+ return response;
+ });
+ }
+
+ scheduleRuleList.addEventListener('input', function (event) {
+ var card = event.target && event.target.closest ? event.target.closest('[data-schedule-rule-card]') : null;
+ if (!card) {
+ return;
+ }
+ handleCardChange(card);
+ });
+
+ scheduleRuleList.addEventListener('change', function (event) {
+ var card = event.target && event.target.closest ? event.target.closest('[data-schedule-rule-card]') : null;
+ if (!card) {
+ return;
+ }
+ handleCardChange(card);
+ });
+
+ scheduleRuleList.addEventListener('click', function (event) {
+ var removeButton = event.target && event.target.closest ? event.target.closest('[data-remove-schedule-rule]') : null;
+ if (!removeButton) {
+ return;
+ }
+ event.preventDefault();
+ var confirmMessage = removeButton.getAttribute('data-confirm-message');
+ if (confirmMessage && !window.confirm(confirmMessage)) {
+ return;
+ }
+ handleRuleRemoval(removeButton.closest('[data-schedule-rule-card]'));
+ });
+
+ if (addRuleButton) {
+ addRuleButton.addEventListener('click', function () {
+ addRule({}, { isCollapsed: false });
+ });
+ }
+
+ if (cancelButton) {
+ cancelButton.addEventListener('click', function () {
+ if (typeof window.closeScheduleModal === 'function') {
+ window.closeScheduleModal({ discardDraft: true });
+ }
+ });
+ }
+
+ var draftRules = getScheduleDraftRules();
+ if (draftRules !== null) {
+ scheduleRuleList.innerHTML = '';
+ draftRules.forEach(function (rule) {
+ addRule(rule && rule.rule ? rule.rule : {}, { isCollapsed: Boolean(rule && rule.isCollapsed) });
+ });
+ syncScheduleRulesField();
+ } else if (!getRuleCards().length) {
+ syncScheduleRulesField();
+ } else {
+ refreshRuleNumbers();
+ getRuleCards().forEach(function (card) {
+ updateRuleCardSummary(card);
+ });
+ syncScheduleRulesField();
+ }
form.addEventListener('submit', function (event) {
event.preventDefault();
@@ -480,59 +1070,49 @@
return;
}
- var mode = select.value;
-
- var selectedDays = [];
- Array.prototype.forEach.call(dayCheckboxes, function (checkbox) {
- if (checkbox.checked) {
- selectedDays.push(Number(checkbox.value));
- }
- });
+ var rules = syncScheduleRulesField();
var values = {
row_key: String(rowKeyInput && rowKeyInput.value ? rowKeyInput.value : ''),
- schedule_mode: mode,
- schedule_start_datetime: startDateInput.value || '',
- schedule_end_datetime: endDateInput.value || '',
- schedule_start_time: startTimeInput.value || '',
- schedule_end_time: endTimeInput.value || '',
- schedule_days_json: JSON.stringify(selectedDays.sort()),
+ scheduleRules: rules,
summary: ''
};
- values.summary = scheduleSummary(values);
+ values.summary = formatScheduleRulesSummary(values.scheduleRules);
- if (window.parent && typeof window.parent.applyPlaylistScheduleConfig === 'function') {
- window.parent.applyPlaylistScheduleConfig(values);
- if (typeof window.parent.closeScheduleModal === 'function') {
- window.parent.closeScheduleModal();
+ if (isDraftMode) {
+ clearScheduleDraft();
+
+ if (window.parent && typeof window.parent.applyPlaylistScheduleConfig === 'function') {
+ window.parent.applyPlaylistScheduleConfig(values);
+ }
+ if (typeof window.closeScheduleModal === 'function') {
+ window.closeScheduleModal({ discardDraft: true, preserveChanges: true });
}
return;
}
- var payload = new URLSearchParams(new FormData(form));
- fetch(form.action, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
- },
- body: payload.toString(),
- credentials: 'same-origin'
- }).then(function (response) {
- if (!response.ok) {
- return response.text().then(function (text) {
- throw new Error(text || 'Unable to save schedule.');
- });
+ saveScheduleForm().then(function () {
+ clearScheduleDraft();
+
+ if (window.parent && typeof window.parent.applyPlaylistScheduleConfig === 'function') {
+ window.parent.applyPlaylistScheduleConfig(values);
+ if (typeof window.parent.closeScheduleModal === 'function') {
+ window.parent.closeScheduleModal({ discardDraft: true, preserveChanges: true });
+ }
+ return;
}
- if (window.parent && typeof window.parent.closeScheduleModal === 'function') {
- window.parent.closeScheduleModal();
+
+ if (typeof window.closeScheduleModal === 'function') {
+ window.closeScheduleModal({ discardDraft: true, preserveChanges: true });
}
}).catch(function (error) {
- alert(error.message || 'Unable to save schedule.');
+ window.alert(String(error && error.message ? error.message : 'Unable to save schedule.'));
});
});
}
// Playlist editor behavior.
+ // Playlist table editing, picker modal, and row sync logic.
function initPlaylistEditStaging() {
var tbody = document.getElementById('playlist-items-body');
var form = document.getElementById('playlist-edit-form');
@@ -545,10 +1125,11 @@
var addSlideCount = document.getElementById('playlist-slide-picker-selected-count');
var addSlideData = document.getElementById('playlist-add-slide-data');
var addSlideOpenButton = document.getElementById('playlist-open-slide-modal');
+ var canvasSizeSelect = document.getElementById('playlist-canvas-size');
+ var canvasSizeLockInput = document.getElementById('playlist-canvas-size-lock');
var slidePickerCards = [];
var slidePickerSelection = new Set();
var slidePickerSlides = [];
- var videoDurationCache = Object.create(null);
var lastDurationPointerDown = null;
var lastDurationPointerUp = null;
@@ -581,6 +1162,65 @@
card.classList.toggle('is-hidden', !visible);
}
+ function getCanvasSizeEditable() {
+ return Boolean(canvasSizeSelect && String(canvasSizeSelect.getAttribute('data-playlist-canvas-editable') || '') === 'true');
+ }
+
+ function getLockedCanvasId() {
+ var canvasId = Number(canvasSizeSelect && canvasSizeSelect.value ? canvasSizeSelect.value : 0) || null;
+
+ if (canvasId) {
+ return canvasId;
+ }
+
+ var canvasIds = {};
+ getRows().forEach(function (row) {
+ var rowCanvasId = Number(row.getAttribute('data-canvas-id') || 0) || null;
+ if (rowCanvasId) {
+ canvasIds[rowCanvasId] = true;
+ }
+ });
+
+ var canvasKeys = Object.keys(canvasIds);
+ if (canvasKeys.length === 1) {
+ return Number(canvasKeys[0]) || null;
+ }
+
+ return null;
+ }
+
+ function syncPlaylistCanvasLock() {
+ if (!canvasSizeSelect) {
+ return;
+ }
+
+ var shouldLock = !getCanvasSizeEditable() || getRows().length > 0;
+ var lockedCanvasId = shouldLock ? getLockedCanvasId() : null;
+
+ if (shouldLock && !canvasSizeSelect.value && lockedCanvasId) {
+ canvasSizeSelect.value = String(lockedCanvasId);
+ }
+
+ canvasSizeSelect.disabled = shouldLock;
+
+ if (canvasSizeLockInput) {
+ canvasSizeLockInput.disabled = !shouldLock;
+ canvasSizeLockInput.value = shouldLock ? String(canvasSizeSelect.value || lockedCanvasId || '') : '';
+ }
+
+ if (tbody && tbody.setAttribute) {
+ tbody.setAttribute('data-playlist-canvas-id', String(shouldLock ? (canvasSizeSelect.value || lockedCanvasId || '') : getPlaylistCanvasId() || ''));
+ }
+ }
+
+ function syncAddSlideButtonState() {
+ if (!addSlideOpenButton) {
+ return;
+ }
+
+ addSlideOpenButton.disabled = !Boolean(getPlaylistCanvasId());
+ }
+
function clearModalSelection() {
slidePickerSelection.clear();
slidePickerCards.forEach(function (card) {
@@ -607,11 +1247,11 @@
button.className = PICKER_CARD_CLASS;
button.setAttribute('aria-pressed', 'false');
button.setAttribute('data-slide-id', String(slide.id || ''));
+ button.setAttribute('data-canvas-id', String(slide && slide.canvasSizeId ? slide.canvasSizeId : ''));
button.setAttribute('data-search-text', searchText);
button.setAttribute('data-is-assigned', slide && slide.isAssigned ? 'true' : 'false');
if (slide && slide.isAssigned) {
- button.disabled = true;
button.classList.add('is-assigned');
}
@@ -650,18 +1290,73 @@
syncSlidePickerState();
}
+ function getPlaylistCanvasId() {
+ if (canvasSizeSelect && canvasSizeSelect.value) {
+ return Number(canvasSizeSelect.value) || null;
+ }
+
+ if (canvasSizeSelect && (!canvasSizeSelect.disabled || !canvasSizeSelect.value)) {
+ return canvasSizeLockInput && canvasSizeLockInput.value ? Number(canvasSizeLockInput.value) || null : null;
+ }
+
+ if (canvasSizeLockInput && canvasSizeLockInput.value) {
+ return Number(canvasSizeLockInput.value) || null;
+ }
+
+ return Number(tbody && tbody.getAttribute ? tbody.getAttribute('data-playlist-canvas-id') : 0) || null;
+ }
+
+ function syncPlaylistCanvasId() {
+ if (tbody && tbody.setAttribute) {
+ tbody.setAttribute('data-playlist-canvas-id', String(getPlaylistCanvasId() || ''));
+ }
+ syncAddSlideOptions();
+ syncSlidePickerState();
+ syncPlaylistCanvasLock();
+ syncAddSlideButtonState();
+ }
+
function syncSlidePickerState() {
var query = String(addSlideSearch && addSlideSearch.value ? addSlideSearch.value : '').trim().toLowerCase();
var showAssignedSlides = Boolean(addSlideShowAssigned && addSlideShowAssigned.getAttribute('aria-pressed') === 'true');
var visibleCount = 0;
var assignedCount = 0;
+ var allowedCanvasId = getPlaylistCanvasId();
+ var activeCanvasIds = {};
+
+ if (!allowedCanvasId) {
+ getRows().forEach(function (row) {
+ var canvasId = Number(row.getAttribute('data-canvas-id') || 0) || null;
+ if (canvasId) {
+ activeCanvasIds[canvasId] = true;
+ }
+ });
+
+ if (!Object.keys(activeCanvasIds).length) {
+ slidePickerCards.forEach(function (card) {
+ if (slidePickerSelection.has(String(card.getAttribute('data-slide-id') || ''))) {
+ var selectedCanvasId = Number(card.getAttribute('data-canvas-id') || 0) || null;
+ if (selectedCanvasId) {
+ activeCanvasIds[selectedCanvasId] = true;
+ }
+ }
+ });
+ }
+ var canvasKeys = Object.keys(activeCanvasIds);
+ if (canvasKeys.length === 1) {
+ allowedCanvasId = Number(canvasKeys[0]) || null;
+ }
+ }
slidePickerCards.forEach(function (card) {
var searchText = String(card.getAttribute('data-search-text') || '');
var isAssigned = card.getAttribute('data-is-assigned') === 'true';
+ var canvasId = Number(card.getAttribute('data-canvas-id') || 0) || null;
+ var isCanvasMismatch = Boolean(allowedCanvasId && canvasId && canvasId !== allowedCanvasId);
var matchesSearch = !query || searchText.indexOf(query) !== -1;
var matchesAssignment = showAssignedSlides || !isAssigned;
- var matches = matchesSearch && matchesAssignment;
+ var matches = matchesSearch && matchesAssignment && !isCanvasMismatch;
+ updateSlidePickerCardState(card, isAssigned, isCanvasMismatch);
setCardVisible(card, matches);
if (isAssigned) {
assignedCount += 1;
@@ -691,7 +1386,7 @@
var slideId = String(card && card.getAttribute('data-slide-id') ? card.getAttribute('data-slide-id') : '');
var selected;
- if (!slideId || card.disabled) {
+ if (!slideId || String(card && card.getAttribute('data-is-assigned') || '') === 'true') {
return;
}
@@ -706,6 +1401,7 @@
}
function addSelectedSlides() {
+ var currentTbody = document.getElementById('playlist-items-body') || tbody;
var selectedSlides = slidePickerSlides.filter(function (slide) {
return slidePickerSelection.has(String(slide.id || '')) && !slide.isAssigned;
});
@@ -714,8 +1410,8 @@
return;
}
- if (tbody.querySelector('.playlist-empty-row')) {
- tbody.querySelector('.playlist-empty-row').remove();
+ if (currentTbody && currentTbody.querySelector('.playlist-empty-row')) {
+ currentTbody.querySelector('.playlist-empty-row').remove();
}
selectedSlides.forEach(function (slide) {
@@ -725,24 +1421,24 @@
thumbnail_path: String(slide.thumbnail_path || ''),
canvas_width: slide.canvas_width,
canvas_height: slide.canvas_height,
- canvas_signature: String(slide.canvasSignature || ''),
+ canvas_id: slide.canvasSizeId,
showVideoDurationButton: slide.showVideoDurationButton,
+ showMuteButton: slide.showMuteButton,
videoSourcePath: slide.videoSourcePath,
videoDurationSeconds: slide.videoDurationSeconds,
+ disableAudio: slide.disableAudio,
title: slide.title || 'Slide',
duration_seconds: 10,
- schedule_mode: 'always',
- schedule_start_datetime: '',
- schedule_end_datetime: '',
- schedule_start_time: '',
- schedule_end_time: '',
- schedule_days_json: '[]',
+ durationSeconds: 10,
+ scheduleRules: [],
summary: 'Always visible'
});
- tbody.appendChild(row);
+ if (currentTbody) {
+ currentTbody.appendChild(row);
+ }
});
- attachSlideThumbFallbacks(tbody);
+ attachSlideThumbFallbacks(currentTbody || tbody);
updateRowOrder(true);
@@ -755,7 +1451,8 @@
attachSlideThumbFallbacks(tbody);
function getRows() {
- return Array.prototype.slice.call(tbody.querySelectorAll('tr[data-playlist-slide-row]'));
+ var currentTbody = document.getElementById('playlist-items-body') || tbody;
+ return currentTbody ? Array.prototype.slice.call(currentTbody.querySelectorAll('tr[data-playlist-slide-row]:not([data-playlist-ghost-row])')) : [];
}
function findRowByKey(rowKey) {
@@ -769,8 +1466,9 @@
}
function updateEmptyState() {
+ var currentTbody = document.getElementById('playlist-items-body') || tbody;
var rows = getRows();
- var placeholder = tbody.querySelector('.playlist-empty-row');
+ var placeholder = currentTbody ? currentTbody.querySelector('.playlist-empty-row') : null;
if (rows.length) {
if (placeholder) {
placeholder.remove();
@@ -778,7 +1476,9 @@
return;
}
if (!placeholder) {
- tbody.innerHTML = ' |
| No slides assigned yet. |
';
+ if (currentTbody) {
+ currentTbody.innerHTML = '
| No slides assigned yet. |
';
+ }
}
}
@@ -788,72 +1488,75 @@
}
}
- function scheduleSummaryForRow(row) {
+ // Rebuild hidden schedule inputs after the async save swaps the playlist tbody.
+ function syncScheduleRowInputs(row) {
var fields = getScheduleRowFields(row);
- var mode = String(fields.mode ? fields.mode.value : 'always');
- var days = formatDays(fields.daysJson && fields.daysJson.value ? fields.daysJson.value : '[]');
+ var rowKey = String(row && row.getAttribute('data-row-key') || '').trim();
+ var serializedRules = fields.rulesContainer ? fields.rulesContainer.getAttribute('data-schedule-rules') : '';
+ var rules = [];
- if (mode === 'dates') {
- if (fields.startDatetime && fields.endDatetime && fields.startDatetime.value && fields.endDatetime.value) {
- return 'Dates: ' + fields.startDatetime.value.replace('T', ' ') + ' to ' + fields.endDatetime.value.replace('T', ' ');
- }
- return 'Dates: not set';
+ if (!fields.rulesContainer || !rowKey) {
+ return;
}
- if (mode === 'times') {
- if (days && fields.startTime && fields.endTime && fields.startTime.value && fields.endTime.value) {
- return 'Times: ' + days + ' ' + fields.startTime.value.slice(0, 5) + '-' + fields.endTime.value.slice(0, 5);
+
+ if (serializedRules) {
+ try {
+ rules = JSON.parse(serializedRules);
+ } catch (_error) {
+ rules = [];
}
- return 'Times: not set';
+ } else {
+ rules = [];
+ }
+
+ if (!Array.isArray(rules)) {
+ rules = [];
+ }
+
+ fields.rulesContainer.innerHTML = buildScheduleRulesMarkup(rules, rowKey, 'playlist-edit-form');
+
+ if (fields.summaryNode) {
+ fields.summaryNode.innerHTML = buildScheduleSummaryChipMarkup(formatScheduleRulesSummary(rules) || 'Always visible');
}
- return 'Always visible';
}
- function collectScheduleParams(row, rowKey) {
- var params = new URLSearchParams();
- var fields = getScheduleRowFields(row);
-
- params.set('row_key', String(rowKey || ''));
-
- if (fields.mode && fields.mode.value) {
- params.set('schedule_mode', String(fields.mode.value || ''));
- }
- if (fields.startDatetime && fields.startDatetime.value) {
- params.set('schedule_start_datetime', String(fields.startDatetime.value || ''));
- }
- if (fields.endDatetime && fields.endDatetime.value) {
- params.set('schedule_end_datetime', String(fields.endDatetime.value || ''));
- }
- if (fields.startTime && fields.startTime.value) {
- params.set('schedule_start_time', String(fields.startTime.value || ''));
- }
- if (fields.endTime && fields.endTime.value) {
- params.set('schedule_end_time', String(fields.endTime.value || ''));
- }
- if (fields.daysJson && fields.daysJson.value) {
- params.set('schedule_days_json', String(fields.daysJson.value || ''));
- }
-
- return params;
+ function syncScheduleDraftRows() {
+ getRows().forEach(function (row) {
+ syncScheduleRowInputs(row);
+ });
}
+ // Filter the slide picker against the current playlist rows and canvas lock.
function syncAddSlideOptions() {
var activeSlideIds = {};
- var activeCanvasSignatures = {};
+ var activeCanvasIds = {};
+ var allowedCanvasId = getPlaylistCanvasId();
+
getRows().forEach(function (row) {
var slideId = String(row.getAttribute('data-slide-id') || '');
- var canvasSignature = String(row.getAttribute('data-canvas-signature') || '');
+ var canvasId = Number(row.getAttribute('data-canvas-id') || 0) || null;
if (slideId) {
activeSlideIds[slideId] = true;
}
- if (canvasSignature) {
- activeCanvasSignatures[canvasSignature] = true;
+ if (!allowedCanvasId && canvasId) {
+ activeCanvasIds[canvasId] = true;
}
});
- var allowedCanvasSignature = '';
- var canvasKeys = Object.keys(activeCanvasSignatures);
- if (canvasKeys.length === 1) {
- allowedCanvasSignature = canvasKeys[0];
+ if (!allowedCanvasId) {
+ slidePickerCards.forEach(function (card) {
+ if (slidePickerSelection.has(String(card.getAttribute('data-slide-id') || ''))) {
+ var selectedCanvasId = Number(card.getAttribute('data-canvas-id') || 0) || null;
+ if (selectedCanvasId) {
+ activeCanvasIds[selectedCanvasId] = true;
+ }
+ }
+ });
+
+ var canvasKeys = Object.keys(activeCanvasIds);
+ if (canvasKeys.length === 1) {
+ allowedCanvasId = Number(canvasKeys[0]) || null;
+ }
}
slidePickerSlides.forEach(function (slide) {
@@ -861,9 +1564,9 @@
var card = slidePickerCards.find(function (item) {
return String(item.getAttribute('data-slide-id') || '') === slideId;
});
- var canvasSignature = String(slide.canvasSignature || '');
+ var canvasId = Number(slide.canvasSizeId || 0) || null;
var isAssigned = Boolean(activeSlideIds[slideId]);
- var isCanvasMismatch = Boolean(allowedCanvasSignature && canvasSignature && canvasSignature !== allowedCanvasSignature);
+ var isCanvasMismatch = Boolean(allowedCanvasId && canvasId && canvasId !== allowedCanvasId);
slide.isAssigned = isAssigned;
@@ -876,15 +1579,10 @@
}
});
- if (addSlideOpenButton) {
- addSlideOpenButton.disabled = slidePickerCards.filter(function (card) {
- return !card.disabled;
- }).length === 0;
- }
-
syncSlidePickerState();
}
+ // Recompute row numbers and derived picker state after drag/drop or adds.
function updateRowOrder(markDirty) {
var rows = getRows();
rows.forEach(function (row, index) {
@@ -894,185 +1592,159 @@
}
});
syncAddSlideOptions();
+ syncPlaylistCanvasLock();
+ syncAddSlideButtonState();
updateEmptyState();
if (markDirty) {
markPlaylistDirty();
}
}
- function lockDraggedRowWidths(row) {
+ form.addEventListener('submit', function () {
+ syncScheduleDraftRows();
+ }, true);
+
+ function bindPlaylistTableDrag(tbodyElement) {
+ if (!tbodyElement || typeof window.initPlaylistTableDrag !== 'function') {
+ return;
+ }
+
+ window.initPlaylistTableDrag({
+ tbody: tbodyElement,
+ onOrderChanged: updateRowOrder
+ });
+ }
+
+ function populatePlaylistSlideRow(row, values, rowKey) {
+ var canvasWidth = Number(values.canvas_width);
+ var canvasHeight = Number(values.canvas_height);
+ var thumbnailPath = String(values.thumbnail_path || '');
+ var scheduleRules = Array.isArray(values.scheduleRules) ? values.scheduleRules : [];
+ var useVideoDuration = Boolean(values.useVideoDuration);
+ var showVideoDurationButton = Boolean(values.showVideoDurationButton);
+ var disableAudio = values.disableAudio === undefined || values.disableAudio === null ? true : Boolean(values.disableAudio);
+ var showMuteButton = Boolean(values.showMuteButton);
+ var durationSource = values.durationSeconds !== undefined && values.durationSeconds !== null ? values.durationSeconds : values.duration_seconds;
+ var durationSeconds = String(durationSource !== undefined && durationSource !== null ? durationSource : 10);
+ var summary = String(values.summary || formatScheduleRulesSummary(scheduleRules) || 'Always visible');
+ var durationButton = row ? row.querySelector('[data-use-video-duration-button]') : null;
+ var muteButton = row ? row.querySelector('[data-disable-audio-button]') : null;
+ var durationInput = row ? row.querySelector('[data-playlist-duration-input]') : null;
+ var useVideoDurationInput = row ? row.querySelector('[data-playlist-use-video-duration-input]') : null;
+ var disableAudioInput = row ? row.querySelector('[data-playlist-disable-audio-input]') : null;
+ var durationMirror = row ? row.querySelector('[data-playlist-duration-mirror]') : null;
+ var thumb = row ? row.querySelector('[data-playlist-slide-thumb]') : null;
+ var titleNode = row ? row.querySelector('[data-playlist-slide-title]') : null;
+ var slideIdInput = row ? row.querySelector('[data-playlist-slide-id-input]') : null;
+ var rowKeyInput = row ? row.querySelector('[data-playlist-row-key-input]') : null;
+ var scheduleSummaryChip = row ? row.querySelector('[data-schedule-summary-chip]') : null;
+ var rulesContainer = row ? row.querySelector('[data-schedule-rules-container]') : null;
+
if (!row) {
return;
}
- var rowRect = row.getBoundingClientRect();
- row.style.width = rowRect.width + 'px';
- row.style.height = rowRect.height + 'px';
- row.style.boxSizing = 'border-box';
+ row.setAttribute('data-row-key', rowKey);
+ row.setAttribute('data-slide-id', String(values.slide_id || ''));
+ row.setAttribute('data-canvas-id', String(values.canvas_id || ''));
+ row.setAttribute('data-video-source-path', String(values.videoSourcePath || ''));
+ row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || ''));
+ row.setAttribute('data-use-video-duration-state', useVideoDuration ? 'true' : 'false');
+ row.setAttribute('data-disable-audio-state', disableAudio ? 'true' : 'false');
- Array.prototype.forEach.call(row.children, function (cell) {
- var cellRect = cell.getBoundingClientRect();
- cell.style.width = cellRect.width + 'px';
- cell.style.height = cellRect.height + 'px';
- cell.style.boxSizing = 'border-box';
- });
- }
-
- function unlockDraggedRowWidths(row) {
- if (!row) {
- return;
+ if (Number.isFinite(canvasWidth) && Number.isFinite(canvasHeight) && canvasWidth > 0 && canvasHeight > 0 && thumb) {
+ thumb.style.setProperty('--playlist-slide-thumb-aspect-ratio', canvasWidth + ' / ' + canvasHeight);
}
-
- row.style.width = '';
- row.style.height = '';
- row.style.boxSizing = '';
-
- Array.prototype.forEach.call(row.children, function (cell) {
- cell.style.width = '';
- cell.style.height = '';
- cell.style.boxSizing = '';
- });
- }
-
- function setRowSchedule(row, values) {
- var fields = getScheduleRowFields(row);
- var summary = row.querySelector('.playlist-schedule-summary');
-
- if (fields.mode) {
- fields.mode.value = values.schedule_mode || 'always';
+ if (thumb) {
+ thumb.innerHTML = thumbnailPath
+ ? '
 + ')
'
+ : '
';
}
- if (fields.startDatetime) {
- fields.startDatetime.value = values.schedule_start_datetime || '';
+ if (titleNode) {
+ titleNode.textContent = String(values.title || 'Slide');
}
- if (fields.endDatetime) {
- fields.endDatetime.value = values.schedule_end_datetime || '';
+ if (slideIdInput) {
+ slideIdInput.value = String(values.slide_id || '');
}
- if (fields.startTime) {
- fields.startTime.value = values.schedule_start_time || '';
+ if (rowKeyInput) {
+ rowKeyInput.value = String(rowKey || '');
}
- if (fields.endTime) {
- fields.endTime.value = values.schedule_end_time || '';
+ if (scheduleSummaryChip) {
+ scheduleSummaryChip.textContent = summary;
}
- if (fields.daysJson) {
- fields.daysJson.value = values.schedule_days_json || '[]';
+ if (rulesContainer) {
+ rulesContainer.setAttribute('data-schedule-rules', JSON.stringify(scheduleRules).replace(/ 0 && canvasHeight > 0) {
- thumbnailStyle = ' style="--playlist-slide-thumb-aspect-ratio: ' + canvasWidth + ' / ' + canvasHeight + ';"';
+ var disableAudio = values.disableAudio === undefined || values.disableAudio === null ? true : Boolean(values.disableAudio);
+ var rowTemplate = document.getElementById('playlist-slide-row-template');
+ var fragment;
+ var row;
+
+ if (rowTemplate && rowTemplate.content) {
+ fragment = rowTemplate.content.cloneNode(true);
+ row = fragment.querySelector('[data-playlist-slide-row]');
+ if (row) {
+ populatePlaylistSlideRow(row, values, rowKey);
+ setRowSchedule(row, Object.assign({}, values, { row_key: rowKey }));
+ return row;
+ }
}
- var thumbnailMarkup = values.thumbnail_path
- ? '

'
- : '
';
+
+ row = document.createElement('tr');
row.setAttribute('data-playlist-slide-row', '');
row.setAttribute('data-row-key', rowKey);
row.setAttribute('data-slide-id', String(values.slide_id));
- row.setAttribute('data-canvas-signature', String(values.canvas_signature || ''));
+ row.setAttribute('data-canvas-id', String(values.canvas_id || ''));
row.setAttribute('data-video-source-path', String(values.videoSourcePath || ''));
row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || ''));
row.setAttribute('data-use-video-duration-state', String(Boolean(values.useVideoDuration)));
- var durationActionMarkup = buildVideoDurationButtonMarkup(
- Boolean(values.useVideoDuration),
- Boolean(values.showVideoDurationButton)
- );
+ row.setAttribute('data-disable-audio-state', String(disableAudio));
row.innerHTML = '' +
buildPlaylistOrderCellMarkup() +
- buildPlaylistSlideCellMarkup(values, thumbnailStyle, thumbnailMarkup) +
- buildPlaylistScheduleCellMarkup(values) +
- buildPlaylistDurationCellMarkup(values, durationActionMarkup) +
- buildPlaylistActionsCellMarkup(playlistId, rowKey);
+ buildPlaylistSlideCellMarkup(values, '', '') +
+ buildPlaylistScheduleCellMarkup(values, rowKey) +
+ buildPlaylistDurationCellMarkup(Object.assign({}, values, { disableAudio: disableAudio }), buildVideoDurationButtonMarkup(Boolean(values.useVideoDuration), Boolean(values.showVideoDurationButton)) + buildDisableAudioButtonMarkup(disableAudio, Boolean(values.showMuteButton))) +
+ buildPlaylistActionsCellMarkup(rowKey);
+ setRowSchedule(row, Object.assign({}, values, { row_key: rowKey }));
return row;
}
- function loadVideoDuration(mediaPath) {
- var cacheKey = String(mediaPath || '').trim();
- var cachedPromise;
-
- if (!cacheKey) {
- return Promise.reject(new Error('No video source is available for this slide.'));
- }
-
- cachedPromise = videoDurationCache[cacheKey];
- if (cachedPromise) {
- return cachedPromise;
- }
-
- cachedPromise = new Promise(function (resolve, reject) {
- var video = document.createElement('video');
- var timeoutId = window.setTimeout(function () {
- cleanup();
- reject(new Error('Timed out loading the video metadata.'));
- }, 15000);
-
- function cleanup() {
- window.clearTimeout(timeoutId);
- video.removeAttribute('src');
- video.load();
- if (video.parentNode) {
- video.parentNode.removeChild(video);
- }
- }
-
- video.preload = 'metadata';
- video.muted = true;
- video.playsInline = true;
- video.setAttribute('playsinline', '');
- video.setAttribute('muted', '');
- video.style.position = 'absolute';
- video.style.left = '-9999px';
- video.style.top = '0';
- video.style.width = '1px';
- video.style.height = '1px';
- video.style.opacity = '0';
- video.style.pointerEvents = 'none';
-
- video.addEventListener('loadedmetadata', function () {
- var duration = Number(video.duration);
- cleanup();
- if (Number.isFinite(duration) && duration > 0) {
- resolve(Math.round(duration * 1000) / 1000);
- } else {
- reject(new Error('The video duration could not be read.'));
- }
- }, { once: true });
-
- video.addEventListener('error', function () {
- cleanup();
- reject(new Error('The video duration could not be read.'));
- }, { once: true });
-
- video.addEventListener('abort', function () {
- cleanup();
- reject(new Error('The video duration could not be read.'));
- }, { once: true });
-
- document.body.appendChild(video);
- video.src = cacheKey;
- video.load();
- }).catch(function (error) {
- delete videoDurationCache[cacheKey];
- throw error;
- });
-
- videoDurationCache[cacheKey] = cachedPromise;
- return cachedPromise;
- }
-
+ // Lock the editable duration input while preserving the prior value for a later restore.
function setVideoDurationToggleState(row, button, isPressed) {
var input = row ? row.querySelector('[name="duration_seconds[]"]') : null;
var flag = row ? row.querySelector('[name="use_video_duration[]"]') : null;
var mirror = row ? row.querySelector('[data-video-duration-mirror]') : null;
+ var videoDuration = String(row && row.getAttribute('data-video-duration-seconds') || '').trim();
if (row && input && isPressed && !row.getAttribute('data-video-duration-previous-value')) {
row.setAttribute('data-video-duration-previous-value', String(input.value || ''));
@@ -1087,6 +1759,8 @@
input.value = previousValue;
}
row.removeAttribute('data-video-duration-previous-value');
+ } else if (isPressed && videoDuration) {
+ input.value = videoDuration;
}
input.disabled = Boolean(isPressed);
input.classList.toggle('playlist-duration-input-locked', Boolean(isPressed));
@@ -1121,13 +1795,11 @@
}
}
- async function applyVideoDurationToRow(row, button) {
- var mediaPath = String(row && row.getAttribute('data-video-source-path') || '').trim();
+ function applyVideoDurationToRow(row, button) {
var input = row ? row.querySelector('[name="duration_seconds[]"]') : null;
- var isVideoRow = Boolean(mediaPath);
var storedDuration = Number(row && row.getAttribute('data-video-duration-seconds') || 0);
- if (!isVideoRow || !input) {
+ if (!input) {
return;
}
@@ -1140,52 +1812,45 @@
if (Number.isFinite(storedDuration) && storedDuration > 0) {
input.value = String(storedDuration);
- var existingMirror = row ? row.querySelector('[data-video-duration-mirror]') : null;
- if (existingMirror) {
- existingMirror.value = input.value;
- }
- input.dispatchEvent(new Event('input', { bubbles: true }));
- input.dispatchEvent(new Event('change', { bubbles: true }));
- return;
}
- if (button) {
- button.disabled = true;
- button.textContent = VIDEO_DURATION_LOADING_LABEL;
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+
+ function setDisableAudioToggleState(row, button, isPressed) {
+ var flag = row ? row.querySelector('[name="disable_audio[]"]') : null;
+
+ syncDisableAudioButtonState(button, isPressed);
+
+ if (flag) {
+ flag.value = isPressed ? '1' : '0';
}
- try {
- var durationSeconds = await loadVideoDuration(mediaPath);
- if (row) {
- row.setAttribute('data-video-duration-seconds', String(durationSeconds));
- }
- input.value = String(durationSeconds);
- var mirror = row ? row.querySelector('[data-video-duration-mirror]') : null;
- if (mirror) {
- mirror.value = input.value;
- }
- input.dispatchEvent(new Event('input', { bubbles: true }));
- input.dispatchEvent(new Event('change', { bubbles: true }));
- } catch (error) {
- setVideoDurationToggleState(row, button, false);
- alert(error && error.message ? error.message : 'Unable to read the video duration.');
- } finally {
- if (button) {
- button.disabled = false;
- syncVideoDurationButtonState(button, button.getAttribute('aria-pressed') === 'true');
- }
+ if (row) {
+ row.setAttribute('data-disable-audio-state', isPressed ? 'true' : 'false');
}
}
- tbody.addEventListener('click', function (event) {
+ function applyDisableAudioToRow(row, button) {
+ if (!row) {
+ return;
+ }
+
+ setDisableAudioToggleState(row, button, !(button && button.getAttribute('aria-pressed') === 'true'));
+ markPlaylistDirty();
+ }
+
+ // Central click handler for row actions so drag, remove, schedule, and duration toggle stay coordinated.
+ document.addEventListener('click', function (event) {
if (event.target && event.target.matches('[name="duration_seconds[]"]')) {
event.stopPropagation();
return;
}
var removeButton = event.target.closest('[data-playlist-remove-row]');
- var scheduleButton = event.target.closest('[data-schedule-config]');
var durationButton = event.target.closest('.playlist-use-video-duration');
+ var muteButton = event.target.closest('.playlist-disable-audio');
var row = getPlaylistSlideRow(event.target);
if (!row) {
@@ -1199,18 +1864,6 @@
return;
}
- if (scheduleButton) {
- event.preventDefault();
- if (typeof window.openScheduleModal === 'function') {
- var rowKey = getPlaylistSlideRowKey(row);
- var url = scheduleButton.getAttribute('data-schedule-config') || '';
- var params = collectScheduleParams(row, rowKey);
- url += (url.indexOf('?') === -1 ? '?' : '&') + params.toString();
- window.openScheduleModal(url);
- }
- return;
- }
-
if (durationButton) {
var rowKey = getPlaylistSlideRowKey(row);
var pressedOnInput = lastDurationPointerDown && lastDurationPointerDown.type === 'input' && lastDurationPointerDown.rowKey === rowKey;
@@ -1225,17 +1878,37 @@
event.preventDefault();
void applyVideoDurationToRow(row, durationButton);
+ lastDurationPointerDown = null;
+ lastDurationPointerUp = null;
+ return;
+ }
+
+ if (muteButton) {
+ event.preventDefault();
+ void applyDisableAudioToRow(row, muteButton);
+ return;
+ }
+
+ var scheduleButton = event.target.closest('[data-schedule-config]');
+ if (scheduleButton) {
+ event.preventDefault();
+ if (typeof window.openScheduleModal === 'function') {
+ var scheduleParams = collectScheduleParams(row, scheduleButton.getAttribute('data-schedule-config-row') || row.getAttribute('data-row-key') || '');
+ window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?' + scheduleParams.toString());
+ }
}
lastDurationPointerDown = null;
lastDurationPointerUp = null;
});
- tbody.addEventListener('mousedown', function (event) {
+ // Track the pointer source so clicks on the duration toggle do not fight text selection.
+ document.addEventListener('mousedown', function (event) {
var row = getPlaylistSlideRow(event.target);
var dragHandle = event.target && event.target.closest ? event.target.closest('[data-playlist-drag-handle]') : null;
var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null;
var durationToggle = isDurationControl(event.target) ? event.target.closest('.playlist-use-video-duration') : null;
+ var muteToggle = isAudioControl(event.target) ? event.target.closest('.playlist-disable-audio') : null;
if (row && !dragHandle) {
event.stopPropagation();
@@ -1252,12 +1925,19 @@
return;
}
+ if (muteToggle) {
+ event.stopPropagation();
+ return;
+ }
+
lastDurationPointerDown = null;
});
- tbody.addEventListener('mouseup', function (event) {
+ // Mirror the pointer-up side of the duration toggle click detection.
+ document.addEventListener('mouseup', function (event) {
var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null;
var durationToggle = isDurationControl(event.target) ? event.target.closest('.playlist-use-video-duration') : null;
+ var muteToggle = isAudioControl(event.target) ? event.target.closest('.playlist-disable-audio') : null;
var row = getPlaylistSlideRow(event.target);
if (durationInput) {
@@ -1270,18 +1950,27 @@
return;
}
+ if (muteToggle) {
+ lastDurationPointerUp = null;
+ return;
+ }
+
lastDurationPointerUp = null;
});
- tbody.addEventListener('input', function (event) {
+ // Keep the row dirty state and mirrored duration input in sync while the user edits.
+ document.addEventListener('input', function (event) {
if (event.target && event.target.matches('[name="duration_seconds[]"]')) {
var editedRow = getPlaylistSlideRow(event.target);
var editedButton = editedRow ? editedRow.querySelector('.playlist-use-video-duration') : null;
+ var editedMuteButton = editedRow ? editedRow.querySelector('.playlist-disable-audio') : null;
var editedMirror = editedRow ? editedRow.querySelector('[data-video-duration-mirror]') : null;
var useVideoDuration = Boolean(editedButton && editedButton.getAttribute('aria-pressed') === 'true');
+ var disableAudio = Boolean(editedMuteButton && editedMuteButton.getAttribute('aria-pressed') === 'true');
if (editedRow) {
editedRow.setAttribute('data-use-video-duration-state', useVideoDuration ? 'true' : 'false');
+ editedRow.setAttribute('data-disable-audio-state', disableAudio ? 'true' : 'false');
}
if (useVideoDuration && editedMirror) {
@@ -1295,31 +1984,9 @@
}
});
- if (window.Sortable) {
- Sortable.create(tbody, {
- animation: 180,
- handle: '[data-playlist-drag-handle]',
- draggable: 'tr[data-playlist-slide-row]',
- ghostClass: 'sortable-ghost',
- chosenClass: 'sortable-chosen',
- dragClass: 'sortable-drag',
- forceFallback: true,
- fallbackOnBody: true,
- fallbackTolerance: 3,
- swapThreshold: 0.65,
- invertedSwapThreshold: 0.65,
- onChoose: function (event) {
- lockDraggedRowWidths(event && event.item);
- },
- onUnchoose: function (event) {
- unlockDraggedRowWidths(event && event.item);
- },
- onEnd: function () {
- unlockDraggedRowWidths(tbody.querySelector('.sortable-drag'));
- updateRowOrder(true);
- }
- });
- }
+ bindPlaylistTableDrag(tbody);
+ hydrateScheduleRowContainers(tbody);
+ initPlaylistScheduleModal();
if (addSlideModal && addSlideGrid) {
renderSlidePicker();
@@ -1351,6 +2018,12 @@
});
}
+ if (canvasSizeSelect) {
+ canvasSizeSelect.addEventListener('change', function () {
+ syncPlaylistCanvasId();
+ });
+ }
+
addSlideModal.addEventListener('shown.bs.modal', function () {
syncSlidePickerState();
if (addSlideSearch) {
@@ -1363,20 +2036,17 @@
});
}
- window.applyPlaylistScheduleConfig = function (values) {
- var row = findRowByKey(String(values.row_key || ''));
- if (!row) {
- return;
- }
- setRowSchedule(row, values);
+ window.rebindPlaylistTableDrag = function (tbodyElement) {
+ bindPlaylistTableDrag(tbodyElement);
};
updateRowOrder();
+ syncPlaylistCanvasLock();
+ syncAddSlideButtonState();
}
window.initPlaylistScheduleForm = initPlaylistScheduleForm;
- initPlaylistScheduleModal();
initPlaylistScheduleForm();
initPlaylistEditStaging();
}());
diff --git a/src/web/public/js/playlists/playlist-table-drag.js b/src/web/public/js/playlists/playlist-table-drag.js
new file mode 100644
index 0000000..8bdd06c
--- /dev/null
+++ b/src/web/public/js/playlists/playlist-table-drag.js
@@ -0,0 +1,410 @@
+(function () {
+ // Playlist table drag sorting is isolated here so the playlist editor script
+ // can stay focused on slide picking, schedule editing, and row updates.
+
+ function getDragEventPoint(event) {
+ var touch = event.touches && event.touches[0] ? event.touches[0] : event.changedTouches && event.changedTouches[0] ? event.changedTouches[0] : null;
+
+ return {
+ x: touch ? touch.clientX : event.clientX,
+ y: touch ? touch.clientY : event.clientY
+ };
+ }
+
+ function measureDraggedRowWidths(row) {
+ var metrics = {
+ rowRect: row.getBoundingClientRect(),
+ cellRects: []
+ };
+
+ Array.prototype.forEach.call(row.children, function (cell) {
+ metrics.cellRects.push(cell.getBoundingClientRect());
+ });
+
+ return metrics;
+ }
+
+ function lockDraggedRowWidths(row, metrics) {
+ if (!row) {
+ return;
+ }
+
+ var rowRect = metrics && metrics.rowRect ? metrics.rowRect : row.getBoundingClientRect();
+ row.style.width = rowRect.width + 'px';
+ row.style.height = rowRect.height + 'px';
+ row.style.boxSizing = 'border-box';
+
+ Array.prototype.forEach.call(row.children, function (cell, index) {
+ var cellRect = metrics && metrics.cellRects && metrics.cellRects[index] ? metrics.cellRects[index] : cell.getBoundingClientRect();
+ cell.style.width = cellRect.width + 'px';
+ cell.style.height = cellRect.height + 'px';
+ cell.style.boxSizing = 'border-box';
+ });
+ }
+
+ function syncDraggedRowCellPadding(row, sourceRow) {
+ if (!row || !sourceRow) {
+ return;
+ }
+
+ Array.prototype.forEach.call(row.children, function (cell, index) {
+ var sourceCell = sourceRow.children[index];
+ if (!sourceCell) {
+ return;
+ }
+
+ var sourceStyle = window.getComputedStyle(sourceCell);
+ cell.style.paddingLeft = sourceStyle.paddingLeft;
+ cell.style.paddingRight = sourceStyle.paddingRight;
+ cell.style.paddingTop = sourceStyle.paddingTop;
+ cell.style.paddingBottom = sourceStyle.paddingBottom;
+ });
+ }
+
+ function unlockDraggedRowWidths(row) {
+ if (!row) {
+ return;
+ }
+
+ row.style.width = '';
+ row.style.height = '';
+ row.style.boxSizing = '';
+
+ Array.prototype.forEach.call(row.children, function (cell) {
+ cell.style.width = '';
+ cell.style.height = '';
+ cell.style.boxSizing = '';
+ });
+ }
+
+ function animateRowReorder(previousRects, excludedRows) {
+ var excluded = Array.isArray(excludedRows) ? excludedRows : [];
+
+ if (!previousRects || typeof previousRects.forEach !== 'function') {
+ return;
+ }
+
+ window.requestAnimationFrame(function () {
+ previousRects.forEach(function (previousRect, row) {
+ var nextRect;
+ var deltaY;
+ var previousTransition;
+
+ if (!row || excluded.indexOf(row) !== -1 || !row.parentNode) {
+ return;
+ }
+
+ nextRect = row.getBoundingClientRect();
+ deltaY = previousRect.top - nextRect.top;
+
+ if (!deltaY) {
+ return;
+ }
+
+ previousTransition = row.style.transition;
+ row.style.transition = 'none';
+ row.style.transform = 'translate3d(0, ' + deltaY + 'px, 0)';
+ row.offsetHeight;
+ row.style.transition = 'transform 180ms ease';
+
+ window.requestAnimationFrame(function () {
+ if (!row.parentNode) {
+ return;
+ }
+
+ row.style.transform = '';
+
+ var cleanup = function () {
+ row.style.transition = previousTransition;
+ row.removeEventListener('transitionend', cleanup);
+ row.removeEventListener('transitioncancel', cleanup);
+ };
+
+ row.addEventListener('transitionend', cleanup, { once: true });
+ row.addEventListener('transitioncancel', cleanup, { once: true });
+ });
+ });
+ });
+ }
+
+ function initPlaylistTableDrag(options) {
+ var tbody = options && options.tbody ? options.tbody : null;
+ var onOrderChanged = options && typeof options.onOrderChanged === 'function' ? options.onOrderChanged : function () {};
+ var rowDragState = null;
+
+ if (!tbody) {
+ return;
+ }
+
+ if (tbody.dataset && tbody.dataset.playlistTableDragBound === 'true') {
+ return;
+ }
+
+ if (tbody.dataset) {
+ tbody.dataset.playlistTableDragBound = 'true';
+ }
+
+ function getRows() {
+ return Array.prototype.slice.call(tbody.querySelectorAll('tr[data-playlist-slide-row]:not([data-playlist-ghost-row])'));
+ }
+
+ function getDragHandle(target) {
+ return target && target.closest ? target.closest('[data-playlist-drag-handle]') : null;
+ }
+
+ function prepareGhostRow(row) {
+ if (!row) {
+ return;
+ }
+
+ row.setAttribute('data-playlist-ghost-row', 'true');
+ row.setAttribute('aria-hidden', 'true');
+ row.classList.add('playlist-row-ghost');
+ row.style.pointerEvents = 'none';
+
+ Array.prototype.forEach.call(row.querySelectorAll('[id]'), function (node) {
+ node.removeAttribute('id');
+ });
+
+ Array.prototype.forEach.call(row.querySelectorAll('input, select, textarea, button'), function (control) {
+ control.disabled = true;
+ control.setAttribute('tabindex', '-1');
+ if (control.tagName === 'BUTTON') {
+ control.setAttribute('type', 'button');
+ }
+ });
+ }
+
+ function createDragLayer(row, width) {
+ var table = row.ownerDocument.createElement('table');
+ var tbodyElement = row.ownerDocument.createElement('tbody');
+
+ table.className = 'playlist-row-drag-layer';
+ table.style.position = 'fixed';
+ table.style.top = '0';
+ table.style.left = '0';
+ table.style.margin = '0';
+ table.style.borderCollapse = 'collapse';
+ table.style.borderSpacing = '0';
+ table.style.tableLayout = 'fixed';
+ table.style.pointerEvents = 'none';
+ table.style.zIndex = '1080';
+ table.style.width = width + 'px';
+
+ tbodyElement.appendChild(row);
+ table.appendChild(tbodyElement);
+ row.ownerDocument.body.appendChild(table);
+
+ return table;
+ }
+
+ function positionDragLayer(layer, metrics, point) {
+ if (!layer || !metrics || !point) {
+ return;
+ }
+
+ layer.style.transform = 'translate3d(' + (point.x - metrics.offsetX) + 'px, ' + (point.y - metrics.offsetY) + 'px, 0)';
+ }
+
+ function beginRowDrag() {
+ if (!rowDragState || rowDragState.active) {
+ return;
+ }
+
+ var row = rowDragState.row;
+ var metrics = rowDragState.metrics;
+ var placeholder = row.cloneNode(true);
+ var rowRect = metrics.rowRect;
+
+ prepareGhostRow(placeholder);
+ lockDraggedRowWidths(placeholder, metrics);
+ syncDraggedRowCellPadding(placeholder, row);
+ placeholder.classList.add('playlist-row-ghost');
+ tbody.insertBefore(placeholder, row);
+
+ rowDragState.placeholder = placeholder;
+ rowDragState.dragLayer = createDragLayer(row, metrics.rowRect.width);
+ rowDragState.active = true;
+ rowDragState.metrics.offsetX = rowDragState.startX - rowRect.left;
+ rowDragState.metrics.offsetY = rowDragState.startY - rowRect.top;
+
+ lockDraggedRowWidths(row, metrics);
+ syncDraggedRowCellPadding(row, placeholder);
+
+ row.classList.add('playlist-row-dragging');
+ row.style.pointerEvents = 'none';
+ row.setAttribute('aria-grabbed', 'true');
+
+ positionDragLayer(rowDragState.dragLayer, metrics, rowDragState.currentPoint || {
+ x: rowDragState.startX,
+ y: rowDragState.startY
+ });
+ }
+
+ function repositionGhostRow(clientY) {
+ var rows = getRows();
+ var insertBeforeRow = null;
+ var previousRects = new Map();
+ var moved = false;
+
+ rows.forEach(function (row) {
+ if (row !== rowDragState.placeholder) {
+ previousRects.set(row, row.getBoundingClientRect());
+ }
+ });
+
+ for (var index = 0; index < rows.length; index += 1) {
+ var candidateRow = rows[index];
+ var rect = candidateRow.getBoundingClientRect();
+
+ if (clientY < rect.top + rect.height / 2) {
+ insertBeforeRow = candidateRow;
+ break;
+ }
+ }
+
+ if (insertBeforeRow) {
+ if (rowDragState.placeholder !== insertBeforeRow && rowDragState.placeholder.nextSibling !== insertBeforeRow) {
+ tbody.insertBefore(rowDragState.placeholder, insertBeforeRow);
+ moved = true;
+ }
+ } else if (rowDragState.placeholder.parentNode !== tbody || rowDragState.placeholder.nextSibling) {
+ tbody.appendChild(rowDragState.placeholder);
+ moved = true;
+ }
+
+ if (moved) {
+ animateRowReorder(previousRects, [rowDragState.row, rowDragState.placeholder]);
+ }
+ }
+
+ function teardownRowDrag() {
+ if (!rowDragState) {
+ return;
+ }
+
+ if (rowDragState.supportsPointerEvents) {
+ document.removeEventListener('pointermove', handleRowDragMove);
+ document.removeEventListener('pointerup', handleRowDragEnd);
+ document.removeEventListener('pointercancel', handleRowDragEnd);
+ } else {
+ document.removeEventListener('mousemove', handleRowDragMove);
+ document.removeEventListener('mouseup', handleRowDragEnd);
+ document.removeEventListener('touchmove', handleRowDragMove);
+ document.removeEventListener('touchend', handleRowDragEnd);
+ document.removeEventListener('touchcancel', handleRowDragEnd);
+ }
+
+ if (rowDragState.active) {
+ if (rowDragState.placeholder && rowDragState.placeholder.parentNode) {
+ rowDragState.placeholder.parentNode.insertBefore(rowDragState.row, rowDragState.placeholder);
+ rowDragState.placeholder.parentNode.removeChild(rowDragState.placeholder);
+ } else if (rowDragState.row.parentNode !== tbody) {
+ tbody.appendChild(rowDragState.row);
+ }
+
+ if (rowDragState.dragLayer && rowDragState.dragLayer.parentNode) {
+ rowDragState.dragLayer.parentNode.removeChild(rowDragState.dragLayer);
+ }
+
+ rowDragState.row.classList.remove('playlist-row-dragging');
+ rowDragState.row.style.pointerEvents = rowDragState.previousPointerEvents;
+ rowDragState.row.removeAttribute('aria-grabbed');
+ unlockDraggedRowWidths(rowDragState.row);
+ onOrderChanged();
+ }
+
+ rowDragState = null;
+ }
+
+ function handleRowDragMove(event) {
+ if (!rowDragState) {
+ return;
+ }
+
+ var point = getDragEventPoint(event);
+
+ if (!point) {
+ return;
+ }
+
+ rowDragState.currentPoint = point;
+
+ if (!rowDragState.active) {
+ var threshold = typeof rowDragState.threshold === 'number' ? rowDragState.threshold : 3;
+
+ if (Math.abs(point.x - rowDragState.startX) < threshold && Math.abs(point.y - rowDragState.startY) < threshold) {
+ return;
+ }
+
+ beginRowDrag();
+ }
+
+ event.preventDefault();
+ positionDragLayer(rowDragState.dragLayer, rowDragState.metrics, point);
+ repositionGhostRow(point.y);
+ }
+
+ function handleRowDragEnd() {
+ teardownRowDrag();
+ }
+
+ function handleRowDragStart(event) {
+ var handle = getDragHandle(event.target);
+ var row = handle ? handle.closest('tr[data-playlist-slide-row]') : null;
+ var point = getDragEventPoint(event);
+
+ if (!row || rowDragState) {
+ return;
+ }
+
+ if (event.type === 'mousedown' && event.button !== 0) {
+ return;
+ }
+
+ if (!point) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ rowDragState = {
+ row: row,
+ metrics: measureDraggedRowWidths(row),
+ startX: point.x,
+ startY: point.y,
+ currentPoint: point,
+ threshold: 3,
+ active: false,
+ previousPointerEvents: row.style.pointerEvents || '',
+ placeholder: null,
+ dragLayer: null,
+ supportsPointerEvents: typeof window !== 'undefined' && typeof window.PointerEvent === 'function'
+ };
+
+ if (rowDragState.supportsPointerEvents) {
+ document.addEventListener('pointermove', handleRowDragMove, { passive: false });
+ document.addEventListener('pointerup', handleRowDragEnd);
+ document.addEventListener('pointercancel', handleRowDragEnd);
+ } else {
+ document.addEventListener('mousemove', handleRowDragMove);
+ document.addEventListener('mouseup', handleRowDragEnd);
+ document.addEventListener('touchmove', handleRowDragMove, { passive: false });
+ document.addEventListener('touchend', handleRowDragEnd);
+ document.addEventListener('touchcancel', handleRowDragEnd);
+ }
+ }
+
+ // The drag handle is intentionally isolated from the rest of the playlist
+ // editor so it can be swapped or removed without touching schedule logic.
+ if (typeof window !== 'undefined' && window.PointerEvent) {
+ tbody.addEventListener('pointerdown', handleRowDragStart);
+ } else {
+ tbody.addEventListener('mousedown', handleRowDragStart);
+ tbody.addEventListener('touchstart', handleRowDragStart, { passive: false });
+ }
+ }
+
+ window.initPlaylistTableDrag = initPlaylistTableDrag;
+}());
\ No newline at end of file
diff --git a/src/web/public/js/regions/type/schedule.js b/src/web/public/js/regions/type/schedule.js
index 6596a84..d336730 100644
--- a/src/web/public/js/regions/type/schedule.js
+++ b/src/web/public/js/regions/type/schedule.js
@@ -1,4 +1,4 @@
-// Schedule region helpers for editor previews and defaults.
+// Timetable region helpers for editor previews and defaults.
(function () {
var registry = window.pulseRegionTypes;
@@ -27,13 +27,13 @@
};
}
- function getScheduleGroups() {
- return Array.isArray(window.initialData && window.initialData.scheduleGroups) ? window.initialData.scheduleGroups : [];
+ function getTimetableGroups() {
+ return Array.isArray(window.initialData && window.initialData.timetableGroups) ? window.initialData.timetableGroups : [];
}
function getGroupById(groupId, groups) {
var normalizedId = Number(groupId || 0);
- return (Array.isArray(groups) ? groups : getScheduleGroups()).find(function (group) {
+ return (Array.isArray(groups) ? groups : getTimetableGroups()).find(function (group) {
return Number(group.id) === normalizedId;
}) || null;
}
@@ -81,6 +81,12 @@
return isUpcoming(entry, now);
});
+ if (!entries.length && mode === 'upcoming') {
+ entries = getEntries(groupId, groups).filter(function (entry) {
+ return isLive(entry, now);
+ });
+ }
+
return entries.slice(0, Math.max(1, Number(maxItems || 5)));
}
@@ -112,7 +118,7 @@
function getCurrentConfig(region, existingContent) {
var current = existingContent[region.region_key] || {};
return {
- schedule_group_id: current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id),
+ timetable_group_id: current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id),
display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
value: String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : ''),
max_items: Math.max(1, Number(current.max_items || 5)),
@@ -137,18 +143,18 @@
}).join('');
}
- function getCurrentSelection(regionId, card, existingContent, scheduleGroups) {
+ function getCurrentSelection(regionId, card, existingContent, timetableGroups) {
var current = existingContent[regionId] || {};
- var scheduleGroupInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_group_id_' + regionId + '"]') : null;
- var displayModeInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_display_mode_' + regionId + '"]') : null;
- var maxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_schedule_max_items_' + regionId + '"]') : null;
- var groupId = scheduleGroupInput ? scheduleGroupInput.value : (current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id));
- var displayMode = displayModeInput ? displayModeInput.value : String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
- var maxItems = Math.max(1, Number(maxItemsInput ? maxItemsInput.value : current.max_items || 5));
- var group = getGroupById(groupId, scheduleGroups);
- var entries = getVisibleEntries(groupId, displayMode, maxItems, scheduleGroups);
+ var timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + regionId + '"]') : null;
+ var timetableDisplayModeInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_display_mode_' + regionId + '"]') : null;
+ var timetableMaxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_timetable_max_items_' + regionId + '"]') : null;
+ var groupId = timetableGroupInput ? timetableGroupInput.value : (current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id));
+ var displayMode = timetableDisplayModeInput ? timetableDisplayModeInput.value : String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
+ var maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || 5));
+ var group = getGroupById(groupId, timetableGroups);
+ var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups);
if (!entries.length) {
- entries = getEntries(groupId, scheduleGroups).slice(0, maxItems);
+ entries = getEntries(groupId, timetableGroups).slice(0, maxItems);
}
return {
@@ -162,17 +168,17 @@
function renderEditorCard(context) {
var region = context.region;
var current = context.current || {};
- var scheduleGroups = Array.isArray(context.scheduleGroups) ? context.scheduleGroups : [];
+ var timetableGroups = Array.isArray(context.timetableGroups) ? context.timetableGroups : [];
var currentValue = String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : '');
- var currentGroupId = current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id);
+ var currentGroupId = current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id);
var currentDisplayMode = String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var currentMaxItems = Math.max(1, Number(current.max_items || 5));
- var currentGroup = getGroupById(currentGroupId, scheduleGroups);
- var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, scheduleGroups);
+ var currentGroup = getGroupById(currentGroupId, timetableGroups);
+ var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups);
if (!currentEntries.length) {
- currentEntries = getEntries(currentGroupId, scheduleGroups).slice(0, currentMaxItems);
+ currentEntries = getEntries(currentGroupId, timetableGroups).slice(0, currentMaxItems);
}
- var groupOptions = scheduleGroups.map(function (group) {
+ var groupOptions = timetableGroups.map(function (group) {
var selected = Number(group.id) === Number(currentGroupId) ? ' selected' : '';
return '
';
}).join('');
@@ -181,7 +187,7 @@
'
' +
'
';
}
@@ -241,7 +246,7 @@
return;
}
- var groups = getScheduleGroups();
+ var groups = getTimetableGroups();
var current = getCurrentSelection(regionId, card, {}, groups);
panel.innerHTML = renderSchedulePlaceholderChips(current.group, current.entries);
}
@@ -255,7 +260,7 @@
return;
}
- if (!target.closest('select[name^="region_schedule_group_id_"], select[name^="region_schedule_display_mode_"], input[name^="region_schedule_max_items_"]')) {
+ if (!target.closest('select[name^="region_timetable_group_id_"], select[name^="region_timetable_display_mode_"], input[name^="region_timetable_max_items_"]')) {
return;
}
@@ -270,15 +275,15 @@
return {
region: context.region,
current: context.current || {},
- scheduleGroups: Array.isArray(context.scheduleGroups) ? context.scheduleGroups : []
+ timetableGroups: Array.isArray(context.timetableGroups) ? context.timetableGroups : []
};
}
- function buildPreviewRenderContext(region, card, existingContent, scheduleGroups) {
+ function buildPreviewRenderContext(region, card, existingContent, timetableGroups) {
var current = getCurrentConfig(region, existingContent || {});
- var scheduleGroupInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_group_id_' + region.id + '"]') : null;
- var displayModeInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_display_mode_' + region.id + '"]') : null;
- var maxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_schedule_max_items_' + region.id + '"]') : null;
+ var timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + region.id + '"]') : null;
+ var timetableDisplayModeInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_display_mode_' + region.id + '"]') : null;
+ var timetableMaxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_timetable_max_items_' + region.id + '"]') : null;
var textAreaInput = card && card.querySelector ? card.querySelector('textarea.editor-source') : null;
var hiddenInput = card && card.querySelector ? card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]') : null;
var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null;
@@ -287,33 +292,30 @@
return {
value: value,
style: getTextStyle(region, current),
- schedule_group_id: scheduleGroupInput ? scheduleGroupInput.value || current.schedule_group_id : current.schedule_group_id,
- display_mode: displayModeInput ? displayModeInput.value || current.display_mode : current.display_mode,
- max_items: maxItemsInput ? maxItemsInput.value || current.max_items : current.max_items,
+ timetable_group_id: timetableGroupInput ? timetableGroupInput.value || current.timetable_group_id : current.timetable_group_id,
+ display_mode: timetableDisplayModeInput ? timetableDisplayModeInput.value || current.display_mode : current.display_mode,
+ max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items,
existingContent: existingContent || {},
- scheduleGroups: Array.isArray(scheduleGroups) ? scheduleGroups : []
+ timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : []
};
}
function renderPreview(region, regionContent, context) {
- var groups = context && context.scheduleGroups ? context.scheduleGroups : [];
+ var groups = context && context.timetableGroups ? context.timetableGroups : [];
var style = regionContent && regionContent.style ? regionContent.style : getTextStyle(region, regionContent || {});
var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim();
- var groupId = regionContent && regionContent.schedule_group_id !== undefined ? regionContent.schedule_group_id : '';
+ var groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_group_id : '';
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
var group = getGroupById(groupId, groups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups);
- if (!entries.length) {
- entries = getEntries(groupId, groups).slice(0, Math.max(1, Number(maxItems || 5)));
- }
if (!value) {
- return '