Compare commits

..
19 Commits
Author SHA1 Message Date
lzstealth 815c808d73 Release 1.5.13 2026-07-26 20:26:01 +01:00
lzstealth 4afaeaafb1 Release v1.5.12 2026-07-26 16:22:28 +01:00
lzstealth ef9cdd2986 Merge remote-tracking branch 'Gitea/main' 2026-07-26 15:02:18 +01:00
lzstealth 2176fb9042 Release v1.5.11 2026-07-26 15:01:03 +01:00
lzstealth 6cb45d7839 Merge pull request 'Implement video region support' (#4) from feature/issue-2-video-region into main
Reviewed-on: LZStealth/pulse-signage#4
2026-07-26 14:59:14 +01:00
lzstealth eb1f33d82f Implement video region support 2026-07-26 14:54:50 +01:00
lzstealth 07ec17ad91 Release v1.5.10 2026-07-26 12:32:01 +01:00
lzstealth b5fbf79af7 Gitea Templates Update 2026-07-26 01:45:16 +01:00
lzstealth 4ca4da473f Gitea Templates 2026-07-26 01:35:57 +01:00
lzstealth f4de31269d Commit all changes 2026-07-26 01:28:38 +01:00
lzstealth 235d2da6aa Release 1.5.9 2026-07-26 01:28:21 +01:00
lzstealth d24c9c4035 Release 1.5.8 2026-07-25 18:24:12 +01:00
lzstealth 04ddec50ad Release 1.5.7 2026-07-25 17:58:00 +01:00
lzstealth 47613e61a2 Tune playlist slide thumbnail placeholder 2026-07-25 17:49:00 +01:00
lzstealth bd130a1070 Bump version to 1.5.6 2026-07-25 17:43:41 +01:00
lzstealth d48f0e779f Backfill slide thumbnail migration 2026-07-25 17:27:25 +01:00
lzstealth 90ec3bb2df Bump version to 1.5.5 2026-07-25 17:27:12 +01:00
lzstealth 0ca20035cd Updated changelog 2026-07-25 17:24:26 +01:00
lzstealth c18f597068 Release 1.5.4 2026-07-25 17:22:21 +01:00
101 changed files with 5286 additions and 5245 deletions
+48
View File
@@ -0,0 +1,48 @@
name: Bug report
about: Report a problem with Pulse Signage
labels:
- bug
body:
- type: markdown
attributes:
value: |
Please provide enough detail to reproduce the problem.
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: List the actions that trigger the bug.
placeholder: |
1. Open the dashboard
2. Change the playlist on a screen
3. Save and wait for playback to refresh
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What you expected to happen.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: What actually happened.
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: Include version, browser, player setup, and any relevant logs.
placeholder: |
Version:
Browser:
Player version:
Logs:
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://git.lzstealth.com/LZStealth/pulse-signage
about: Check the project repository and docs before opening an issue.
+37
View File
@@ -0,0 +1,37 @@
name: Feature request
about: Suggest an improvement or new capability
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Share the problem you're trying to solve and the outcome you'd like.
- type: textarea
id: problem
attributes:
label: Problem statement
description: What limitation or workflow gap are you trying to address?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: Describe the change you would like to see.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Any workarounds or other approaches you've considered.
- type: textarea
id: context
attributes:
label: Additional context
description: Mockups, screenshots, examples, or related links.
+95
View File
@@ -6,6 +6,101 @@ All notable changes to this project will be documented in this file.
- No unreleased changes recorded yet.
## 1.5.13 - 2026-07-26
### Added
- Shared table helpers for pagination, search, and sorting across the admin and signage views.
### Changed
- Refreshed the shared admin shell and moved common UI behavior into shared layout and helper modules.
- Reworked dashboard, background tasks, RBAC, and signage list pages to use the newer shared table and state flow.
- Updated the admin and player branding assets and tightened the page scripts around the new layout structure.
## 1.5.12 - 2026-07-26
### Changed
- Shared web UI helpers are now loaded once in the admin shell and reused across the dashboard, admin, and template editor.
- Template lists now show slide counts, and the template editor tracks which regions are already in use.
### Fixed
- Template editing now blocks removal of regions that are still referenced by slides.
## 1.5.11 - 2026-07-26
### Added
- Video regions are now supported end to end, including player rendering, admin editing, playlist scheduling, and player sync.
### Changed
- The slide, template, and playlist flows were updated so video content can be created, configured, and scheduled alongside existing region types.
- Player rendering and related media handling now recognize the new video region implementation.
### Fixed
- Region and upload handling were adjusted so video media syncs cleanly through the player and web admin paths.
## 1.5.10 - 2026-07-26
### Added
- Scheduled background tasks now have their own settings page, separate permission gate, and manual run action alongside the main queue view.
- The slide editor now includes a refreshed preview/sidebar layout and a cropper-based image upload flow.
### Changed
- Background task and admin list views now use richer pagination and filtering so queue state, task sources, and recurring jobs stay easier to navigate.
- The admin shell and shared styling were refreshed to support the updated settings pages, slide editor layout, and task table presentation.
### Fixed
- Upload sync and slide thumbnail refresh handling were tightened so slide changes queue follow-up work more reliably.
## 1.5.9 - 2026-07-26
### Fixed
- User creation now keeps the role checkboxes inside the add-user form so selected roles are submitted correctly.
## 1.5.8 - 2026-07-25
### Fixed
- Upload removal requests now authenticate correctly when a deployed player parses a bodyless `DELETE` request as an empty object, which could cause 401s for newly uploaded images.
## 1.5.6 - 2026-07-25
### Changed
- Playlist slide picker now uses a button to reveal already added slides, while still defaulting to showing only available slides.
- Already-added slides in the picker are now labeled and visually muted to make them easier to spot.
### Fixed
- Thumbnails in slide and playlist tables now follow the slide canvas ratio and cap at 5.5rem tall.
- Thumbnail-only table columns are no longer sortable.
## 1.5.5 - 2026-07-25
### Fixed
- Fixed a migration gap that could leave `slides.thumbnail_path` missing on upgraded databases.
## 1.5.4 - 2026-07-25
### Added
- Cropper.js has been added to aid with image uploading.
### Changed
- Upgraded Multer to 2.x to remove the deprecated 1.x dependency warning.
- Tightened a few admin UI details, including slide editor and RBAC list spacing/typography adjustments.
## 1.5.3 - 2026-07-25
### Added
+37 -91
View File
@@ -1,20 +1,21 @@
{
"name": "pulse-signage",
"version": "1.5.3",
"version": "1.5.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "1.5.3",
"version": "1.5.13",
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
"bootstrap-icons": "1.11.3",
"cropperjs": "^1.6.2",
"dotenv": "^17.4.2",
"express": "^4.21.2",
"handlebars": "^4.7.8",
"hls.js": "^1.5.15",
"multer": "^1.4.5-lts.1",
"multer": "^2.2.0",
"mysql2": "^3.14.3",
"puppeteer-core": "^24.16.0",
"qrcode": "^1.5.4",
@@ -1158,17 +1159,17 @@
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 0.8"
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^2.2.2",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
@@ -1208,10 +1209,10 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"node_modules/cropperjs": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
@@ -2074,12 +2075,6 @@
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -2213,18 +2208,6 @@
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -2232,22 +2215,22 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "1.4.5-lts.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 6.0.0"
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/mysql2": {
@@ -2388,15 +2371,6 @@
"node": ">=0.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -2573,12 +2547,6 @@
"node": ">=10.13.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -2774,26 +2742,19 @@
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -3165,20 +3126,14 @@
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
"safe-buffer": "~5.2.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -3443,15 +3398,6 @@
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "1.5.3",
"version": "1.5.13",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"repository": {
@@ -18,11 +18,12 @@
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
"bootstrap-icons": "1.11.3",
"cropperjs": "^1.6.2",
"dotenv": "^17.4.2",
"express": "^4.21.2",
"handlebars": "^4.7.8",
"hls.js": "^1.5.15",
"multer": "^1.4.5-lts.1",
"multer": "^2.2.0",
"mysql2": "^3.14.3",
"puppeteer-core": "^24.16.0",
"qrcode": "^1.5.4",
+15
View File
@@ -2,6 +2,18 @@ const db = require('./db');
const data = require('./data');
const player = require('./player/render');
function getSearchQuery(req) {
return String(req && req.query && req.query.search || '').trim();
}
function getSortQuery(req) {
return String(req && req.query && req.query.sort || '').trim();
}
function getSortDirectionQuery(req) {
return String(req && req.query && req.query.direction || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
}
module.exports = {
createPool: db.createPool,
ensureSchema: db.ensureSchema,
@@ -14,6 +26,9 @@ module.exports = {
fetchTemplatesPage: data.fetchTemplatesPage,
fetchCanvasSizesPage: data.fetchCanvasSizesPage,
fetchScreensPage: data.fetchScreensPage,
getSearchQuery: getSearchQuery,
getSortQuery: getSortQuery,
getSortDirectionQuery: getSortDirectionQuery,
fetchPlaylistById: data.fetchPlaylistById,
fetchApiSourcesData: data.fetchApiSourcesData,
fetchApiSourcesPage: data.fetchApiSourcesPage,
+62 -7
View File
@@ -25,7 +25,7 @@ async function fetchAdminData(pool) {
ORDER BY s.id DESC
`);
const [playlistSlides] = await pool.query(`
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, sl.content_json, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id
@@ -35,13 +35,23 @@ async function fetchAdminData(pool) {
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
}
async function fetchPlaylistsPage(pool, page, pageSize) {
async function fetchPlaylistsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT p.id, p.name, p.fade_between_slides, p.skip_unavailable_rtmp, p.created_at, p.modified_at, p.created_by, p.modified_by,
(SELECT COUNT(*) FROM playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count
FROM playlists p
ORDER BY p.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM playlists',
searchColumns: ['p.name'],
searchTerm: searchTerm,
sortColumns: {
name: 'p.name',
slides: 'slide_count',
created: 'p.created_at',
modified: 'p.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
@@ -49,7 +59,7 @@ async function fetchPlaylistsPage(pool, page, pageSize) {
return Object.assign({ playlists: paged.rows }, paged);
}
async function fetchSlidesPage(pool, page, pageSize) {
async function fetchSlidesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
FROM slides s
@@ -57,6 +67,16 @@ async function fetchSlidesPage(pool, page, pageSize) {
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY s.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM slides',
searchColumns: ['s.title', 's.body', 's.media_path', 'st.name', 's.content_json'],
searchTerm: searchTerm,
sortColumns: {
title: 's.title',
template: 'st.name',
created: 's.created_at',
modified: 's.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
@@ -64,15 +84,28 @@ async function fetchSlidesPage(pool, page, pageSize) {
return Object.assign({ slides: paged.rows }, paged);
}
async function fetchTemplatesPage(pool, page, pageSize) {
async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height,
(SELECT COUNT(*) FROM slide_template_regions str WHERE str.template_id = st.id) AS region_count
(SELECT COUNT(*) FROM slide_template_regions str WHERE str.template_id = st.id) AS region_count,
(SELECT COUNT(*) FROM slides s WHERE s.template_id = st.id) AS slide_count
FROM slide_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM slide_templates',
searchColumns: ['st.name', 'st.background_image_path', 'st.background_color', 'cs.name'],
searchTerm: searchTerm,
sortColumns: {
name: 'st.name',
canvas: ['cs.width', 'cs.height', 'cs.name'],
regions: 'region_count',
slides: 'slide_count',
created: 'st.created_at',
modified: 'st.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
@@ -80,13 +113,24 @@ async function fetchTemplatesPage(pool, page, pageSize) {
return Object.assign({ templates: paged.rows }, paged);
}
async function fetchCanvasSizesPage(pool, page, pageSize) {
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by,
(SELECT COUNT(*) FROM slide_templates st WHERE st.canvas_size_id = canvas_sizes.id) AS template_count
FROM canvas_sizes
ORDER BY width ASC, height ASC, name ASC`,
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes',
searchColumns: ['name', 'width', 'height'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
dimensions: ['width', 'height', 'name'],
templates: 'template_count',
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
@@ -94,13 +138,24 @@ async function fetchCanvasSizesPage(pool, page, pageSize) {
return Object.assign({ canvasSizes: paged.rows }, paged);
}
async function fetchScreensPage(pool, page, pageSize) {
async function fetchScreensPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
FROM screens s
LEFT JOIN playlists p ON p.id = s.playlist_id
ORDER BY s.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM screens',
searchColumns: ['s.name', 's.slug', 'p.name'],
searchTerm: searchTerm,
sortColumns: {
name: 's.name',
url: 's.slug',
playlist: 'p.name',
created: 's.created_at',
modified: 's.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
+14 -1
View File
@@ -15,10 +15,23 @@ async function fetchApiSourcesData(pool) {
return { apiSources: apiSources };
}
async function fetchApiSourcesPage(pool, page, pageSize) {
async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM api_sources',
searchColumns: ['name', 'api_url', 'last_pull_error'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
url: 'api_url',
interval: ['update_interval_value', 'update_interval_unit'],
last_pulled: 'last_pulled_at',
last_response: 'last_response_status',
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
+11 -1
View File
@@ -5,10 +5,20 @@ async function fetchCanvasSizesData(pool) {
return { canvasSizes };
}
async function fetchCanvasSizesPage(pool, page, pageSize) {
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC',
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes',
searchColumns: ['name', 'width', 'height'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
dimensions: ['width', 'height', 'name'],
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
+13 -1
View File
@@ -15,10 +15,22 @@ async function fetchRssFeedsData(pool) {
return { rssFeeds: rssFeeds };
}
async function fetchRssFeedsPage(pool, page, pageSize) {
async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM rss_feeds',
searchColumns: ['name', 'feed_url'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
url: 'feed_url',
interval: ['update_interval_value', 'update_interval_unit'],
items: 'item_limit',
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
+12
View File
@@ -92,6 +92,18 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
type: 'image',
value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
};
} else if (region.region_type === 'video') {
const uploaded = filesByField[`region_video_${region.id}`];
const existing = body[`existing_region_video_${region.id}`];
const durationValue = body[`existing_region_video_duration_${region.id}`];
const existingDuration = existingContent && existingContent[region.region_key] ? Number(existingContent[region.region_key].duration_seconds || 0) : 0;
const parsedDuration = Number(durationValue || existingDuration || 0);
const normalizedDuration = Math.round(parsedDuration * 1000) / 1000;
content[region.region_key] = {
type: 'video',
value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : ''),
duration_seconds: Number.isFinite(normalizedDuration) && normalizedDuration > 0 ? normalizedDuration : null
};
} else if (region.region_type === 'webpage') {
const submitted = body[`region_webpage_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
+1 -1
View File
@@ -1,6 +1,6 @@
const { parseJsonSafe, readFormArray } = require('./utils');
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html', 'rtmp', 'rss', 'api'];
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'video', 'webpage', 'html', 'rtmp', 'rss', 'api'];
const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api'];
function sanitizeBackgroundColor(value) {
+151 -2
View File
@@ -27,23 +27,168 @@ function normalizePageNumber(value) {
return Math.max(1, pageNumber);
}
function escapeLikeValue(value) {
return String(value || '').replace(/[\\%_]/g, '\\$&');
}
function normalizeSortDirection(value) {
return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
}
function buildSortOrderClause(sortColumns, sortKey, sortDirection) {
const normalizedSortKey = String(sortKey || '').trim();
const normalizedSortDirection = normalizeSortDirection(sortDirection);
const sortColumnMap = sortColumns && typeof sortColumns === 'object' ? sortColumns : {};
const sortExpression = normalizedSortKey ? sortColumnMap[normalizedSortKey] : null;
if (!sortExpression) {
return {
clause: '',
sortKey: normalizedSortKey,
sortDirection: normalizedSortDirection
};
}
const expressions = Array.isArray(sortExpression) ? sortExpression : [sortExpression];
const orderBySql = expressions.map(function (expression) {
return `${expression} ${normalizedSortDirection.toUpperCase()}`;
}).join(', ');
return {
clause: ` ORDER BY ${orderBySql}`,
sortKey: normalizedSortKey,
sortDirection: normalizedSortDirection
};
}
function findTopLevelOrderByIndex(sql) {
const text = String(sql || '');
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inBacktick = false;
let lastOrderByIndex = -1;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
const previousCharacter = index > 0 ? text[index - 1] : '';
if (inSingleQuote) {
if (character === '\'' && previousCharacter !== '\\') {
inSingleQuote = false;
}
continue;
}
if (inDoubleQuote) {
if (character === '"' && previousCharacter !== '\\') {
inDoubleQuote = false;
}
continue;
}
if (inBacktick) {
if (character === '`') {
inBacktick = false;
}
continue;
}
if (character === '\'') {
inSingleQuote = true;
continue;
}
if (character === '"') {
inDoubleQuote = true;
continue;
}
if (character === '`') {
inBacktick = true;
continue;
}
if (character === '(') {
depth += 1;
continue;
}
if (character === ')' && depth > 0) {
depth -= 1;
continue;
}
if (depth === 0 && /[oO]/.test(character)) {
const remaining = text.slice(index);
if (/^order\s+by\b/i.test(remaining)) {
lastOrderByIndex = index;
}
}
}
return lastOrderByIndex;
}
function buildSearchFilter(searchColumns, searchTerm) {
const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) {
return String(column || '').trim();
}).filter(Boolean) : [];
const normalizedSearchTerm = String(searchTerm || '').trim().toLowerCase();
if (!columns.length || !normalizedSearchTerm) {
return {
clause: '',
params: []
};
}
const likeValue = `%${escapeLikeValue(normalizedSearchTerm)}%`;
return {
clause: ` WHERE (${columns.map(function (column) {
return `LOWER(COALESCE(CAST(${column} AS CHAR), '')) LIKE ? ESCAPE '\\\\'`;
}).join(' OR ')})`,
params: columns.map(function () {
return likeValue;
})
};
}
async function fetchPagedRows(pool, options) {
const selectSql = String(options && options.selectSql || '').trim();
const countSql = String(options && options.countSql || '').trim();
const params = Array.isArray(options && options.params) ? options.params : [];
const searchFilter = buildSearchFilter(options && options.searchColumns, options && options.searchTerm);
const sortOrder = buildSortOrderClause(options && options.sortColumns, options && options.sortKey, options && options.sortDirection);
const pageSize = Math.max(1, Number(options && options.pageSize) || 10);
const currentPage = normalizePageNumber(options && options.page);
if (!selectSql || !countSql) {
throw new Error('fetchPagedRows requires selectSql and countSql.');
}
const orderByIndex = findTopLevelOrderByIndex(selectSql);
let baseSelectSql = selectSql;
let orderBySql = '';
const [countRows] = await pool.query(countSql, params);
if (orderByIndex >= 0) {
baseSelectSql = selectSql.slice(0, orderByIndex).trim();
orderBySql = selectSql.slice(orderByIndex).trim();
}
const filteredSelectSql = `${baseSelectSql}${searchFilter.clause}`;
const countQuery = searchFilter.clause
? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows`
: countSql;
const countParams = searchFilter.clause ? params.concat(searchFilter.params) : params;
const [countRows] = await pool.query(countQuery, countParams);
const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages);
const offset = (safeCurrentPage - 1) * pageSize;
const [rows] = await pool.query(`${selectSql} LIMIT ? OFFSET ?`, params.concat([pageSize, offset]));
const activeOrderBySql = sortOrder.clause || (orderBySql ? ` ${orderBySql}` : '');
const selectQuery = `${filteredSelectSql}${activeOrderBySql} LIMIT ? OFFSET ?`;
const [rows] = await pool.query(selectQuery, params.concat(searchFilter.params, [pageSize, offset]));
return {
rows: rows || [],
@@ -77,6 +222,10 @@ module.exports = {
parseJsonSafe,
readFormArray,
normalizePageNumber,
normalizeSortDirection,
buildSortOrderClause,
findTopLevelOrderByIndex,
buildSearchFilter,
fetchPagedRows,
fetchDuplicateName
};
+2 -1
View File
@@ -117,7 +117,8 @@ async function ensureSchema(pool, options) {
playlist_id INT NOT NULL,
slide_id INT NOT NULL,
position INT NOT NULL DEFAULT 0,
duration_seconds INT NOT NULL DEFAULT 10,
duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
schedule_start_datetime DATETIME NULL,
schedule_end_datetime DATETIME NULL,
+40 -13
View File
@@ -605,20 +605,20 @@ const migrations = [
{
key: 'schema-columns-current',
version: appVersion,
comment: 'Add the current table columns and audit fields that define the released schema.',
comment: 'Backfill the released schema columns for older databases.',
order: 20,
up: async function (pool) {
// v1.4.6: keep the current canvas_sizes shape available in older databases.
// Current released schema: keep canvas_sizes audit fields available in older databases.
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'canvas_sizes');
// v1.4.6: playlists gained fade_between_slides plus audit fields.
// Current released schema: ensure playlists carry the playback and audit fields.
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'playlists');
// v1.4.6: slide_templates now store canvas sizing and background metadata.
// Current released schema: keep slide template canvas and background fields available.
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
@@ -626,13 +626,13 @@ const migrations = [
await addAuditColumns(pool, 'slide_templates');
await backfillLegacySlideTemplateCanvasSize(pool);
// v1.4.6: slide_template_regions gained font family and audit fields.
// Current released schema: keep slide template region typography and audit fields available.
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'lock_ratio', 'VARCHAR(20) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slide_template_regions');
// v1.4.6: slides gained structured content and media fields.
// Current released schema: keep structured slide content and media fields available.
await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL');
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
@@ -642,8 +642,8 @@ const migrations = [
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slides');
// v1.4.6: playlist_slides gained scheduling fields and audit fields.
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'INT NOT NULL DEFAULT 10');
// Current released schema: keep playlist slide duration, schedule, and audit fields available.
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'DECIMAL(10,3) NOT NULL DEFAULT 10.000');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
@@ -653,12 +653,12 @@ const migrations = [
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'playlist_slides');
// v1.4.6: screens gained an optional playlist binding and audit fields.
// Current released schema: keep screen playlist bindings and audit fields available.
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'screens');
// v1.4.6: RSS feeds now use a neutral interval value plus unit.
// Current released schema: keep RSS feed interval and audit fields available.
await addColumnIfMissing(pool, 'rss_feeds', 'name', 'VARCHAR(255) NOT NULL');
await addColumnIfMissing(pool, 'rss_feeds', 'feed_url', 'VARCHAR(1024) NOT NULL');
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
@@ -667,14 +667,14 @@ const migrations = [
await addColumnIfMissing(pool, 'rss_feeds', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'rss_feeds');
// v1.4.6: rss_feed_items stores normalized JSON snapshots.
// Current released schema: keep normalized RSS feed item snapshots available.
await addColumnIfMissing(pool, 'rss_feed_items', 'rss_feed_id', 'INT NOT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'position', 'INT NOT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'item_json', 'MEDIUMTEXT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await backfillLegacyRssFeedItemJson(pool);
// v1.4.6: API sources now track their latest response snapshot.
// Current released schema: keep API source polling and snapshot fields available.
await addColumnIfMissing(pool, 'api_sources', 'name', 'VARCHAR(255) NOT NULL');
await addColumnIfMissing(pool, 'api_sources', 'api_url', 'VARCHAR(1024) NOT NULL');
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
@@ -687,7 +687,7 @@ const migrations = [
await addColumnIfMissing(pool, 'api_sources', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'api_sources');
// v1.4.6: onboarding devices, users, roles, permissions, and link tables now carry audit fields.
// Current released schema: keep onboarding device and RBAC audit fields available.
await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
@@ -732,6 +732,33 @@ const migrations = [
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
}
},
{
key: 'slides-thumbnail-path-column',
version: appVersion,
comment: 'Backfill the slides.thumbnail_path column for databases that already recorded the broader schema migration.',
order: 22,
up: async function (pool) {
await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL');
}
},
{
key: 'playlist-slide-use-video-duration-flag',
version: appVersion,
comment: 'Add the playlist slide flag that follows the current video duration.',
order: 23,
up: async function (pool) {
await addColumnIfMissing(pool, 'playlist_slides', 'use_video_duration', 'TINYINT(1) NOT NULL DEFAULT 0');
}
},
{
key: 'playlist-slide-duration-decimals',
version: appVersion,
comment: 'Widen playlist slide durations to preserve fractional seconds.',
order: 24,
up: async function (pool) {
await pool.query('ALTER TABLE playlist_slides MODIFY duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000');
}
},
{
key: 'media-path-prefix-rename',
version: appVersion,
+1
View File
@@ -13,6 +13,7 @@
let slideMarkupCache = Object.create(null);
let templateLayoutCache = Object.create(null);
let templateRenderPlanCache = Object.create(null);
let videoRegionRenderVersion = 0;
let renderCacheViewportKey = '';
let index = 0;
let timer = null;
+40 -5
View File
@@ -73,11 +73,11 @@ function createPlayerPlaylistService(options) {
return payloadWithoutPlaylist;
}
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
const playlist = playlistRows[0] || null;
const [slideRows] = await pool.query(`
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
ps.position, ps.duration_seconds AS duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
@@ -108,12 +108,46 @@ function createPlayerPlaylistService(options) {
});
}
function getVideoRegionDurationSeconds(contentJson) {
if (!contentJson) {
return null;
}
try {
const parsed = common.parseJsonSafe(contentJson) || {};
if (!parsed || typeof parsed !== 'object') {
return null;
}
const videoRegion = Object.keys(parsed).map(function (key) { return parsed[key]; }).find(function (region) {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
});
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
return Number.isFinite(duration) && duration > 0 ? duration : null;
} catch (_error) {
return null;
}
}
const slides = slideRows.map(function (slide) {
const storedDuration = Number(slide.duration_seconds || 0);
const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(slide.content_json) : null;
const videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || '');
const content = common.parseJsonSafe(slide.content_json) || {};
Object.keys(content).forEach(function (key) {
const region = content[key];
if (region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video') {
region.cache_bust = videoCacheBust;
}
});
return {
id: slide.id,
title: slide.title,
body: slide.body,
duration_seconds: slide.duration_seconds,
modified_at: slide.modified_at,
duration_seconds: videoDuration || storedDuration,
use_video_duration: Boolean(slide.use_video_duration),
schedule_mode: slide.schedule_mode,
schedule_start_datetime: slide.schedule_start_datetime,
schedule_end_datetime: slide.schedule_end_datetime,
@@ -125,7 +159,7 @@ function createPlayerPlaylistService(options) {
kind: common.mediaKind(slide.media_path),
template_id: slide.template_id,
template: slide.template_id ? templatesById[slide.template_id] || null : null,
content: common.parseJsonSafe(slide.content_json) || {}
content: content
};
});
@@ -193,6 +227,7 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, slide.modified_at);
updatePlaylistRevisionHash(hash, slide.position);
updatePlaylistRevisionHash(hash, slide.duration_seconds);
updatePlaylistRevisionHash(hash, slide.use_video_duration);
updatePlaylistRevisionHash(hash, slide.schedule_mode);
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
+13
View File
@@ -364,6 +364,19 @@ body.screen-blackout #app {
display: block;
}
.template-region.video {
background: transparent;
}
.template-region.video video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
background: transparent;
}
.template-region.webpage iframe {
width: 100%;
height: 100%;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+49 -4
View File
@@ -89,10 +89,10 @@ function scheduleSlideAdvance(delayMs) {
}, holdDelayMs);
}
// Add the fade time to a slide's hold duration so the configured duration remains visible.
function getSlideHoldDelay(delayMs) {
// Add the fade time to a slide's hold duration unless the slide duration already accounts for it.
function getSlideHoldDelay(delayMs, skipFadePadding) {
var holdDelayMs = Math.max(1, Number(delayMs || 0));
return holdDelayMs + (currentPlaylistFadeBetweenSlides ? slideFadeDurationMs : 0);
return holdDelayMs + (currentPlaylistFadeBetweenSlides && !skipFadePadding ? slideFadeDurationMs : 0);
}
// Cancel any pending fade-transition cleanup.
@@ -109,11 +109,48 @@ function renderSlideMarkup(markup, shouldFade) {
if (typeof destroyRtmpRegions === 'function') {
destroyRtmpRegions(app);
}
function initializeRenderedVideoPlayback(root) {
if (!root) {
return;
}
var videos = root.querySelectorAll('.template-region.video video');
Array.prototype.forEach.call(videos, function (video) {
if (!video) {
return;
}
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
function startPlayback() {
var playPromise = video.play && video.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(function () {
return null;
});
}
}
if (video.readyState >= 2) {
startPlayback();
return;
}
video.addEventListener('canplay', startPlayback, { once: true });
video.addEventListener('loadedmetadata', startPlayback, { once: true });
});
}
if (!shouldFade) {
app.innerHTML = markup;
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(app);
}
initializeRenderedVideoPlayback(app);
return app.firstElementChild;
}
@@ -143,6 +180,7 @@ function renderSlideMarkup(markup, shouldFade) {
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell);
}
initializeRenderedVideoPlayback(nextShell);
return nextShell;
}
@@ -162,6 +200,8 @@ function renderSlideMarkup(markup, shouldFade) {
syncRtmpRegions(nextShell);
}
initializeRenderedVideoPlayback(nextShell);
slideTransitionTimer = window.setTimeout(function () {
if (previousShell && previousShell.parentNode) {
previousShell.parentNode.removeChild(previousShell);
@@ -280,7 +320,12 @@ function handleCommandMessage(rawMessage) {
}
return;
case 'pause':
setPaused(!isPaused);
var desiredPause = normalizeBoolean(payload.paused);
if (desiredPause !== null) {
setPaused(desiredPause);
} else {
setPaused(!isPaused);
}
return;
case 'blackout':
var desiredBlackout = normalizeBoolean(payload.blackout);
+1 -1
View File
@@ -147,7 +147,7 @@ function refresh() {
markRefreshHealthy();
setOfflineBannerVisible(false);
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
}
return;
}
+16 -1
View File
@@ -452,6 +452,9 @@ function getTemplateRenderPlan(template) {
if (region.regionType === 'image') {
return renderImageRegion(region, regionContent);
}
if (region.regionType === 'video') {
return renderVideoRegion(region, regionContent);
}
if (region.regionType === 'webpage') {
return renderWebpageRegion(region, regionContent);
}
@@ -517,9 +520,21 @@ function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHt
// Build the cache key for rendered slide markup.
function getSlideMarkupCacheKey(slide) {
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', viewportKey].join('|');
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', slide && slide.modified_at ? slide.modified_at : '', viewportKey, videoRegionRenderVersion || 0].join('|');
}
function notifyVideoRegionSourceReady() {
if (typeof videoRegionRenderVersion === 'number') {
videoRegionRenderVersion += 1;
}
slideMarkupCache = Object.create(null);
if (typeof showCurrent === 'function' && slides && slides.length) {
showCurrent();
}
}
window.notifyVideoRegionSourceReady = notifyVideoRegionSourceReady;
// Look up a previously rendered slide in the cache.
function getCachedSlideMarkup(slide) {
var cacheKey = getSlideMarkupCacheKey(slide);
+6 -2
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = 'v1';
const CACHE_VERSION = 'v2';
const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
@@ -87,6 +87,10 @@ async function staleWhileRevalidate(request, cacheName) {
return new Response('', { status: 504, statusText: 'Offline' });
}
async function networkOnly(request) {
return fetch(request);
}
self.addEventListener('install', function (event) {
self.skipWaiting();
event.waitUntil(Promise.resolve());
@@ -126,7 +130,7 @@ self.addEventListener('fetch', function (event) {
}
if (url.pathname.startsWith('/media/')) {
event.respondWith(staleWhileRevalidate(request, MEDIA_CACHE));
event.respondWith(networkOnly(request));
return;
}
+145
View File
@@ -0,0 +1,145 @@
var videoRegionLastGoodSrcCache = Object.create(null);
var videoRegionProbeStateCache = Object.create(null);
var videoRegionProbeTimerCache = Object.create(null);
var VIDEO_REGION_RETRY_DELAY_MS = 5000;
function getVideoRegionCacheKey(region) {
return String(region && (region.regionKey || region.label) || '').trim();
}
function isDirectlyRenderableSource(src) {
return /^(?:https?:)?\/\//i.test(src) || /^data:/i.test(src) || /^blob:/i.test(src) || /^\/media\//i.test(src);
}
function appendCacheBust(src, cacheBust) {
var key = String(cacheBust || '').trim();
var raw = String(src || '').trim();
if (!raw || !key) {
return raw;
}
return raw + (raw.indexOf('?') === -1 ? '?' : '&') + 'v=' + encodeURIComponent(key);
}
function getVideoSourceAvailability(src) {
return videoRegionProbeStateCache[String(src || '').trim()] || null;
}
function setVideoSourceAvailability(src, available) {
var key = String(src || '').trim();
if (!key) {
return;
}
videoRegionProbeStateCache[key] = {
available: available === null ? null : Boolean(available),
checkedAt: Date.now()
};
}
function logVideoRegionStatus(message, details, level) {
if (typeof logDebug === 'function') {
logDebug(message, details || '', level || 'info');
}
}
function triggerVideoRegionSourceRefresh() {
if (typeof window.notifyVideoRegionSourceReady === 'function') {
window.notifyVideoRegionSourceReady();
return;
}
if (typeof videoRegionRenderVersion === 'number') {
videoRegionRenderVersion += 1;
}
slideMarkupCache = Object.create(null);
if (typeof showCurrent === 'function' && slides && slides.length) {
showCurrent();
}
}
function scheduleVideoSourceProbe(regionKey, src, isRetry) {
var key = String(src || '').trim();
if (!regionKey || !key || isDirectlyRenderableSource(key)) {
if (key) {
setVideoSourceAvailability(key, true);
}
return;
}
if (videoRegionProbeTimerCache[key]) {
return;
}
if (!isRetry) {
logVideoRegionStatus('Video source changed; probing mirrored file before swapping.', 'region=' + regionKey + ' src=' + key);
} else {
logVideoRegionStatus('Video source still unavailable; retrying mirrored file probe.', 'region=' + regionKey + ' src=' + key, 'warn');
}
videoRegionProbeTimerCache[key] = window.setTimeout(function () {
delete videoRegionProbeTimerCache[key];
fetch(key, {
method: 'HEAD',
cache: 'no-store',
credentials: 'same-origin'
}).then(function (response) {
if (response && response.ok) {
setVideoSourceAvailability(key, true);
if (videoRegionLastGoodSrcCache[regionKey] !== key) {
videoRegionLastGoodSrcCache[regionKey] = key;
logVideoRegionStatus('Mirrored video is ready; switching to the new source.', 'region=' + regionKey + ' src=' + key);
triggerVideoRegionSourceRefresh();
}
return;
}
setVideoSourceAvailability(key, false);
logVideoRegionStatus('Mirrored video probe returned unavailable; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
scheduleVideoSourceProbe(regionKey, key, true);
}).catch(function () {
setVideoSourceAvailability(key, false);
logVideoRegionStatus('Mirrored video probe failed; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
scheduleVideoSourceProbe(regionKey, key, true);
});
}, isRetry ? VIDEO_REGION_RETRY_DELAY_MS : 0);
}
function renderVideoRegion(region, regionContent) {
var requestedSrc = String(regionContent && regionContent.value || '').trim();
var requestedSrcVersioned = appendCacheBust(requestedSrc, regionContent && regionContent.cache_bust);
var regionKey = getVideoRegionCacheKey(region);
var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
if (!requestedSrc) {
return '<div class="template-region video" style="' + region.baseStyle + '"><div class="template-region-placeholder">Video</div></div>';
}
if (isDirectlyRenderableSource(requestedSrc)) {
if (regionKey) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
}
setVideoSourceAvailability(requestedSrc, true);
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
}
var requestedState = getVideoSourceAvailability(requestedSrc);
var requestedReady = Boolean(requestedState && requestedState.available === true);
if (requestedReady) {
if (regionKey) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
}
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
}
scheduleVideoSourceProbe(regionKey, requestedSrc, false);
if (cachedSrc) {
if (cachedSrc !== requestedSrc) {
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
}
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
}
return '<div class="template-region video" style="' + region.baseStyle + '"><div class="template-region-placeholder">Video</div></div>';
}
+1
View File
@@ -286,6 +286,7 @@ const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'playe
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
const playerRegionScriptPaths = [
path.join(__dirname, 'regions', 'image.js'),
path.join(__dirname, 'regions', 'video.js'),
path.join(__dirname, 'regions', 'webpage.js'),
path.join(__dirname, 'regions', 'html.js'),
path.join(__dirname, 'regions', 'rtmp.js'),
+1 -1
View File
@@ -114,7 +114,7 @@ function registerPlayerRoutes(app, options) {
});
});
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '100mb' }), requireRequestAuth, async function (req, res, next) {
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) {
try {
const filePath = resolveMediaFilePath(req.params.filename);
if (!filePath) {
+11 -1
View File
@@ -109,10 +109,20 @@ const PERMISSION_SECTIONS = [
name: 'Background tasks',
sectionName: 'Settings',
actions: [
{ key: 'read', name: 'Read', description: 'View background tasks and scheduled refreshes.' },
{ 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: 'users',
order: 80,
+46 -5
View File
@@ -47,6 +47,19 @@ function canonicalize(value) {
return value === undefined ? undefined : value;
}
function normalizeRequestPath(pathValue) {
const value = String(pathValue || '').trim();
if (!value) {
return '';
}
try {
return decodeURIComponent(value);
} catch (_error) {
return value;
}
}
function stableJson(value) {
return JSON.stringify(canonicalize(value));
}
@@ -56,10 +69,38 @@ function hashPayload(value) {
return crypto.createHash('sha256').update(normalized).digest('hex');
}
function normalizeRequestAuthBody(req) {
if (!req) {
return null;
}
const body = req.body;
if (body === undefined || body === null) {
return null;
}
if (Buffer.isBuffer(body)) {
return body;
}
if (typeof body === 'object' && !Array.isArray(body)) {
const hasFields = Object.keys(body).length > 0;
if (!hasFields) {
const contentLength = Number(String(req.headers && req.headers['content-length'] || '').trim() || 0);
const contentType = String(req.headers && req.headers['content-type'] || '').trim();
if (!contentLength || !contentType) {
return null;
}
}
}
return body;
}
function getRequestPath(req) {
const explicitPath = String(req && req.path ? req.path : '').trim();
if (explicitPath) {
return explicitPath;
return normalizeRequestPath(explicitPath);
}
const rawUrl = String(req && req.url ? req.url : '').trim();
@@ -68,9 +109,9 @@ function getRequestPath(req) {
}
try {
return new URL(rawUrl, 'http://localhost').pathname;
return normalizeRequestPath(new URL(rawUrl, 'http://localhost').pathname);
} catch (_error) {
return rawUrl.split('?')[0] || '';
return normalizeRequestPath(rawUrl.split('?')[0] || '');
}
}
@@ -167,7 +208,7 @@ function createRequestAuthHeaders(options) {
}
const method = String(options && options.method || 'GET').trim().toUpperCase();
const pathname = String(options && options.pathname || '').trim();
const pathname = normalizeRequestPath(options && options.pathname || '');
const timestamp = String(options && options.timestamp || Date.now()).trim();
const bodyDigest = hashPayload(options && Object.prototype.hasOwnProperty.call(options, 'body') ? options.body : null);
const signature = signText(secret, `request\n${method}\n${pathname}\n${timestamp}\n${bodyDigest}`);
@@ -199,7 +240,7 @@ function verifyRequestAuth(req) {
return false;
}
const expectedSignature = signText(secret, `request\n${String(req.method || 'GET').trim().toUpperCase()}\n${getRequestPath(req)}\n${timestamp}\n${hashPayload(req.body)}`);
const expectedSignature = signText(secret, `request\n${String(req.method || 'GET').trim().toUpperCase()}\n${getRequestPath(req)}\n${timestamp}\n${hashPayload(normalizeRequestAuthBody(req))}`);
return timingSafeEqualHex(expectedSignature, signature);
}
+31 -1
View File
@@ -25,6 +25,7 @@ const rbacData = require('./web/lib/rbac-data');
const { createPlayerActionService } = require('./web/lib/player-actions');
const { isClientNameAvailable, withClientNameReservation } = require('./data/client-name-check');
const { createSessionService } = require('./web/lib/session');
const { hasAnyPermission } = require('./rbac');
const {
formatDashboardDate,
readArrayField,
@@ -92,12 +93,15 @@ async function start() {
dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000),
formatDashboardDate: formatDashboardDate,
notifyPlayerScreens: notifyPlayerScreens,
backgroundTaskQueue: backgroundTaskQueue
backgroundTaskQueue: backgroundTaskQueue,
hasAnyPermission: hasAnyPermission,
});
const upload = webBootstrap.upload;
const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide;
const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate;
const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload;
const removeUnusedUploadFiles = webBootstrap.removeUnusedUploadFiles;
const collectUploadPathsFromDirectory = webBootstrap.collectUploadPathsFromDirectory;
const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange;
const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer;
const runMediaSyncTask = webBootstrap.runMediaSyncTask;
@@ -163,6 +167,7 @@ async function start() {
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use('/assets', express.static(ASSET_DIR));
app.use('/assets/vendor/cropperjs', express.static(path.join(__dirname, '..', 'node_modules', 'cropperjs', 'dist')));
app.use('/media', express.static(MEDIA_DIR));
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
@@ -285,12 +290,14 @@ async function start() {
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
removeUnusedUploadFiles: removeUnusedUploadFiles,
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
getAuditUserId: getAuditUserId,
redirectAfterSave: redirectAfterSave,
notifyPlayerScreens: notifyPlayerScreens,
broadcastDashboardState: broadcastDashboardState,
backgroundTaskQueue: backgroundTaskQueue,
hasAnyPermission: hasAnyPermission,
getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage,
getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage,
getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage,
@@ -406,6 +413,28 @@ async function start() {
});
}
async function registerRecurringMaintenanceTasks() {
async function runUnusedUploadSweep() {
const uploadPaths = await collectUploadPathsFromDirectory(UPLOADS_DIR);
if (!uploadPaths.length) {
return;
}
await removeUnusedUploadFiles(pool, UPLOADS_DIR, uploadPaths);
}
backgroundTaskQueue.registerRecurringTask({
key: 'unused-upload-sweep',
title: 'Unused upload sweep',
category: 'media-sync',
intervalMs: 24 * 60 * 60 * 1000,
metadata: {
uploadDir: UPLOADS_DIR
},
run: runUnusedUploadSweep
});
}
async function scheduleInitialDataSourceRefreshes() {
const apiSourcesData = await common.fetchApiSourcesData(pool);
const rssFeedsData = await common.fetchRssFeedsData(pool);
@@ -462,6 +491,7 @@ async function start() {
}
await syncRecurringRefreshes();
await registerRecurringMaintenanceTasks();
scheduleInitialDataSourceRefreshes().catch(function (error) {
console.warn('Unable to schedule startup data source refreshes:', error);
});
+4
View File
@@ -120,6 +120,8 @@ function createWebBootstrap(options) {
const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide;
const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate;
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
const removeUnusedUploadFiles = uploadSyncService.removeUnusedUploadFiles;
const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory;
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
@@ -210,6 +212,8 @@ function createWebBootstrap(options) {
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
removeUnusedUploadFiles: removeUnusedUploadFiles,
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
runMediaSyncTask: runMediaSyncTask,
+11
View File
@@ -357,6 +357,16 @@ function createBackgroundTaskQueue(options) {
scheduleRecurringRun(job, job.intervalMs);
}
function runRecurringTask(recurringKey) {
const normalizedKey = normalizeText(recurringKey);
if (!normalizedKey || !recurringJobsByKey.has(normalizedKey)) {
return false;
}
triggerRecurringJob(normalizedKey);
return true;
}
function syncRecurringTaskState(task, status, errorMessage) {
const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey);
if (!recurringKey) {
@@ -737,6 +747,7 @@ function createBackgroundTaskQueue(options) {
setTaskHandler: setTaskHandler,
registerRecurringTask: registerRecurringTask,
removeRecurringTask: removeRecurringTask,
runRecurringTask: runRecurringTask,
listTasks: listTasks,
listRecurringTasks: listRecurringTasks,
getTaskById: getTaskById,
+19 -5
View File
@@ -33,6 +33,18 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa
});
}
function compareScreenNames(left, right) {
const leftName = String(left && left.name || '').trim();
const rightName = String(right && right.name || '').trim();
const nameCompare = leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
if (nameCompare !== 0) {
return nameCompare;
}
return String(left && left.slug || '').localeCompare(String(right && right.slug || ''), undefined, { sensitivity: 'base', numeric: true });
}
function createDashboardStateService(options) {
const pool = options && options.pool;
const common = options && options.common;
@@ -82,11 +94,13 @@ function createDashboardStateService(options) {
}
});
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug).map(function (screen) {
return Object.assign({}, screen, {
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
});
});
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug)
.map(function (screen) {
return Object.assign({}, screen, {
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
});
})
.sort(compareScreenNames);
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate);
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
return socket && socket.readyState === WebSocket.OPEN;
+89
View File
@@ -0,0 +1,89 @@
function parsePageNumber(value) {
const pageNumber = Math.floor(Number(value) || 1);
return Math.max(1, pageNumber);
}
function normalizeSortDirection(value) {
return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
}
function getComparableSortValue(rawValue) {
const value = String(rawValue || '').trim();
if (!value) {
return { type: 'empty', value: '' };
}
const numericValue = Number(value.replace(/,/g, ''));
if (!Number.isNaN(numericValue)) {
return { type: 'number', value: numericValue };
}
const dateValue = Date.parse(value);
if (!Number.isNaN(dateValue)) {
return { type: 'date', value: dateValue };
}
return { type: 'string', value: value.toLowerCase() };
}
function compareSortValues(leftValue, rightValue) {
if (leftValue.type === 'empty' && rightValue.type === 'empty') {
return 0;
}
if (leftValue.type === 'empty') {
return 1;
}
if (rightValue.type === 'empty') {
return -1;
}
if (leftValue.type === rightValue.type) {
if (leftValue.value < rightValue.value) {
return -1;
}
if (leftValue.value > rightValue.value) {
return 1;
}
return 0;
}
return String(leftValue.value).localeCompare(String(rightValue.value), undefined, { numeric: true, sensitivity: 'base' });
}
function sortRows(rows, accessor, sortDirection) {
const multiplier = normalizeSortDirection(sortDirection) === 'desc' ? -1 : 1;
return (Array.isArray(rows) ? rows.slice() : []).sort(function (leftRow, rightRow) {
return compareSortValues(getComparableSortValue(accessor(leftRow)), getComparableSortValue(accessor(rightRow))) * multiplier;
});
}
function createSearchMatcher(searchTerm, fields) {
const query = String(searchTerm || '').trim().toLowerCase();
if (!query) {
return function () {
return true;
};
}
const normalizedFields = Array.isArray(fields) ? fields : [];
return function (item) {
const haystack = normalizedFields.map(function (field) {
if (typeof field === 'function') {
return field(item);
}
return item && field ? item[field] : '';
}).map(function (value) {
return String(value || '').trim().toLowerCase();
}).join(' ');
return haystack.indexOf(query) !== -1;
};
}
module.exports = {
parsePageNumber: parsePageNumber,
normalizeSortDirection: normalizeSortDirection,
getComparableSortValue: getComparableSortValue,
compareSortValues: compareSortValues,
sortRows: sortRows,
createSearchMatcher: createSearchMatcher
};
+66 -10
View File
@@ -17,6 +17,64 @@ function buildQueryString(query) {
return queryString ? `?${queryString}` : '';
}
function createPageEntry(pageNumber, currentPage, pageParam, queryState) {
const nextQuery = Object.assign({}, queryState, { [pageParam]: pageNumber });
return {
number: pageNumber,
active: pageNumber === currentPage,
url: buildQueryString(nextQuery)
};
}
function createEllipsisEntry() {
return {
ellipsis: true
};
}
function buildPaginationPages(totalPages, currentPage, pageParam, queryState, maxVisiblePages) {
const pages = [];
const visiblePageCount = Math.max(1, Math.min(Number(maxVisiblePages) || 7, totalPages));
const firstPage = 1;
const lastPage = totalPages;
if (totalPages <= visiblePageCount) {
for (let pageNumber = firstPage; pageNumber <= lastPage; pageNumber += 1) {
pages.push(createPageEntry(pageNumber, currentPage, pageParam, queryState));
}
return pages;
}
const innerPageCount = Math.max(1, visiblePageCount - 2);
let startPage = Math.max(2, currentPage - Math.floor(innerPageCount / 2));
let endPage = startPage + innerPageCount - 1;
if (endPage > lastPage - 1) {
endPage = lastPage - 1;
startPage = Math.max(2, endPage - innerPageCount + 1);
}
pages.push(createPageEntry(firstPage, currentPage, pageParam, queryState));
if (startPage > 2) {
pages.push(createEllipsisEntry());
}
for (let pageNumber = startPage; pageNumber <= endPage; pageNumber += 1) {
pages.push(createPageEntry(pageNumber, currentPage, pageParam, queryState));
}
if (endPage < lastPage - 1) {
pages.push(createEllipsisEntry());
}
pages.push(createPageEntry(lastPage, currentPage, pageParam, queryState));
return pages;
}
function buildPagination(totalItems, currentPage, pageParam, queryState, pageSize, itemLabel, ariaLabel) {
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
const normalizedPageSize = Math.max(1, Number(pageSize) || 10);
@@ -24,19 +82,12 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
const safeCurrentPage = Math.min(normalizePageNumber(currentPage), totalPages);
const startIndex = totalItems <= 0 ? 0 : (safeCurrentPage - 1) * normalizedPageSize;
const endIndex = totalItems <= 0 ? 0 : Math.min(totalItems, startIndex + normalizedPageSize);
const pages = [];
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: pageNumber });
pages.push({
number: pageNumber,
active: pageNumber === safeCurrentPage,
url: buildQueryString(nextQuery)
});
}
const pages = buildPaginationPages(totalPages, safeCurrentPage, normalizedPageParam, queryState, 7);
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
const firstQuery = Object.assign({}, queryState, { [normalizedPageParam]: 1 });
const lastQuery = Object.assign({}, queryState, { [normalizedPageParam]: totalPages });
return {
currentPage: safeCurrentPage,
@@ -45,8 +96,12 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
hasMultiplePages: totalPages > 1,
startItem: startIndex + 1,
endItem: endIndex,
hasFirst: safeCurrentPage > 1,
hasPrevious: safeCurrentPage > 1,
hasNext: safeCurrentPage < totalPages,
hasLast: safeCurrentPage < totalPages,
firstUrl: buildQueryString(firstQuery),
lastUrl: buildQueryString(lastQuery),
previousUrl: buildQueryString(previousQuery),
nextUrl: buildQueryString(nextQuery),
pages: pages,
@@ -60,5 +115,6 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
module.exports = {
normalizePageNumber,
buildQueryString,
buildPaginationPages,
buildPagination
};
+25 -2
View File
@@ -28,7 +28,7 @@ async function fetchRoles(pool) {
return rows || [];
}
async function fetchRolesPage(pool, page, pageSize) {
async function fetchRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
@@ -36,6 +36,18 @@ async function fetchRolesPage(pool, page, pageSize) {
FROM roles r
ORDER BY r.name ASC`,
countSql: 'SELECT COUNT(*) AS count FROM roles',
searchColumns: ['r.role_key', 'r.name', 'r.description'],
searchTerm: searchTerm,
sortColumns: {
role: 'r.name',
description: 'r.description',
users: 'user_count',
permissions: 'permission_count',
created: 'r.created_at',
modified: 'r.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
@@ -122,7 +134,7 @@ async function fetchUsersWithRoles(pool) {
});
}
async function fetchUsersWithRolesPage(pool, page, pageSize) {
async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
COALESCE(role_data.role_names, '') AS role_names,
@@ -138,6 +150,17 @@ async function fetchUsersWithRolesPage(pool, page, pageSize) {
) role_data ON role_data.user_id = u.id
ORDER BY u.id ASC`,
countSql: 'SELECT COUNT(*) AS count FROM users',
searchColumns: ['u.name', 'u.username', 'role_data.role_names'],
searchTerm: searchTerm,
sortColumns: {
user: 'u.username',
name: 'u.name',
roles: 'role_names',
created: 'u.created_at',
modified: 'u.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
+45 -121
View File
@@ -98,6 +98,13 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
: '';
}
if (regionType === 'video') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? '<video src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video>'
: '';
}
if (regionType === 'webpage') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
@@ -120,124 +127,6 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
return buildTextRegionMarkup(region, regionContent);
}
function buildSlideHtml(slide, baseUrl) {
const canvasSize = getCanvasSize(slide);
const template = slide && slide.template ? slide.template : null;
const backgroundColor = sanitizeTextColor(template && template.background_color ? template.background_color : '#111111', '#111111');
const backgroundImagePath = template && template.background_image_path ? resolveAssetUrl(baseUrl, template.background_image_path) : '';
if (!template) {
const mediaPath = String(slide && slide.media_path || '').trim();
const mediaUrl = resolveAssetUrl(baseUrl, mediaPath);
const kind = mediaKind(mediaPath || slide.media_type || '');
const mediaMarkup = kind === 'image' && mediaUrl
? '<img src="' + escapeHtml(mediaUrl) + '" alt="' + escapeHtml(slide.title || 'slide') + '" />'
: '';
return [
'<!doctype html>',
'<html>',
' <head>',
' <meta charset="utf-8" />',
' <meta name="viewport" content="width=' + canvasSize.width + ', initial-scale=1" />',
' <link rel="stylesheet" href="' + escapeHtml(baseUrl + '/assets/css/player.css') + '" />',
' <style>',
' html, body { margin: 0; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: #111111; }',
' body { display: flex; align-items: stretch; justify-content: stretch; }',
' .thumbnail-stage { position: relative; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: ' + escapeHtml(backgroundColor) + '; }',
' .thumbnail-stage .template-region.text { color: #fff; display: block; padding: 0; text-align: left; white-space: normal; word-break: break-word; line-height: 1.35; }',
' .thumbnail-stage .template-region.text .template-region-text-scale { display: block; transform-origin: top left; width: 100%; height: 100%; white-space: normal; word-break: break-word; line-height: 1.35; text-align: left; overflow: hidden; }',
' .thumbnail-stage .template-region.text .template-region-text-scale > * { margin: 0; }',
' .thumbnail-stage .template-region.text .template-region-text-scale > * + * { margin-top: 0.5em; }',
' .thumbnail-stage .template-region.text .template-region-text-scale ul,',
' .thumbnail-stage .template-region.text .template-region-text-scale ol { padding-left: 1.2em; }',
' .thumbnail-stage .template-region.image img { width: 100%; height: 100%; object-fit: contain; display: block; }',
' .thumbnail-stage .template-region.webpage iframe { width: 100%; height: 100%; border: 0; display: block; background: #fff; overflow: hidden; }',
' .thumbnail-stage .template-region.html iframe { width: 100%; height: 100%; border: 0; display: block; background: transparent; overflow: hidden; }',
' .thumbnail-stage .template-region.rtmp { background: #000; }',
' .thumbnail-stage .template-region.rtmp video { width: 100%; height: 100%; object-fit: contain; display: block; pointer-events: none; }',
' .thumbnail-stage .template-region-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.65); background: rgba(255, 255, 255, 0.06); font-size: 0.9rem; }',
' .thumbnail-stage .template-region-rtmp-placeholder { position: absolute; inset: 0; }',
' </style>',
' </head>',
' <body>',
' <div class="thumbnail-stage">',
mediaMarkup ? '<div class="slide"><div class="slide-canvas slide-media" style="width:' + canvasSize.width + 'px;height:' + canvasSize.height + 'px;">' + mediaMarkup + '</div></div>' : '',
' </div>',
' </body>',
'</html>'
].join('\n');
}
const regions = Array.isArray(template && template.regions) ? template.regions.slice().sort(function (left, right) {
return Number(left.z_index || 0) - Number(right.z_index || 0) || Number(left.id || 0) - Number(right.id || 0);
}) : [];
const regionsHtml = regions.map(function (region) {
const content = getRegionContent(slide, region);
const left = Math.max(0, Number(region.x || 0));
const top = Math.max(0, Number(region.y || 0));
const width = Math.max(1, Number(region.width || 1));
const height = Math.max(1, Number(region.height || 1));
const regionType = String(region.region_type || 'text').toLowerCase();
const pixelWidth = Math.max(1, Math.round(Number(region.width || 0) || 1));
const pixelHeight = Math.max(1, Math.round(Number(region.height || 0) || 1));
const innerHtml = regionType === 'text'
? buildTextRegionMarkup(Object.assign({}, region, { baseStyle: 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';', pixelWidth: pixelWidth, pixelHeight: pixelHeight }), content)
: buildRegionInnerHtml(Object.assign({}, region, { baseStyle: 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';', pixelWidth: pixelWidth, pixelHeight: pixelHeight }), content, baseUrl);
if (!innerHtml) {
return '';
}
return [
'<div class="template-region ' + escapeHtml(regionType) + '" style="left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';">',
innerHtml,
'</div>'
].join('');
}).join('');
return [
'<!doctype html>',
'<html>',
' <head>',
' <meta charset="utf-8" />',
' <meta name="viewport" content="width=' + canvasSize.width + ', initial-scale=1" />',
' <link rel="stylesheet" href="' + escapeHtml(baseUrl + '/assets/css/player.css') + '" />',
' <style>',
' html, body { margin: 0; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: #111111; }',
' body { display: flex; align-items: stretch; justify-content: stretch; }',
' .thumbnail-stage { position: relative; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: ' + escapeHtml(backgroundColor) + '; }',
' .thumbnail-stage .template-stage { position: relative; width: 100%; height: 100%; }',
' .thumbnail-stage .template-stage .template-background { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: fill; display: block; z-index: 0; }',
' .thumbnail-stage .template-region { position: absolute; overflow: hidden; box-sizing: border-box; }',
' .thumbnail-stage .template-region.image img { width: 100%; height: 100%; object-fit: contain; display: block; }',
' .thumbnail-stage .template-region.webpage iframe { width: 100%; height: 100%; border: 0; display: block; background: #fff; overflow: hidden; }',
' .thumbnail-stage .template-region.html iframe { width: 100%; height: 100%; border: 0; display: block; background: transparent; overflow: hidden; }',
' .thumbnail-stage .template-region.rtmp { background: #000; }',
' .thumbnail-stage .template-region.rtmp video { width: 100%; height: 100%; object-fit: contain; display: block; pointer-events: none; }',
' .thumbnail-stage .template-region.text { color: #fff; display: block; padding: 0; text-align: left; white-space: normal; word-break: break-word; line-height: 1.35; }',
' .thumbnail-stage .template-region.text .template-region-text-scale { display: block; transform-origin: top left; width: 100%; height: 100%; white-space: normal; word-break: break-word; line-height: 1.35; text-align: left; overflow: hidden; }',
' .thumbnail-stage .template-region.text .template-region-text-scale > * { margin: 0; }',
' .thumbnail-stage .template-region.text .template-region-text-scale > * + * { margin-top: 0.5em; }',
' .thumbnail-stage .template-region.text .template-region-text-scale ul,',
' .thumbnail-stage .template-region.text .template-region-text-scale ol { padding-left: 1.2em; }',
' .thumbnail-stage .template-region-rtmp-placeholder { position: absolute; inset: 0; }',
' .thumbnail-stage .template-region-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.65); background: rgba(255, 255, 255, 0.06); font-size: 0.9rem; }',
' </style>',
' </head>',
' <body>',
' <div class="thumbnail-stage">',
' <div class="template-stage" style="background-color:' + escapeHtml(backgroundColor) + ';">',
backgroundImagePath ? ' <img class="template-background" src="' + escapeHtml(backgroundImagePath) + '" alt="" />' : '',
regionsHtml,
' </div>',
' </div>',
' </body>',
'</html>'
].join('\n');
}
async function launchBrowser() {
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
return fs.existsSync(candidate);
@@ -304,6 +193,42 @@ async function captureSlideThumbnail(options) {
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
async function waitForThumbnailRender(page) {
await page.waitForFunction(function () {
return document.readyState === 'complete' && Boolean(document.querySelector('.slide-canvas'));
}, { timeout: 30000 });
await page.waitForFunction(function () {
var canvas = document.querySelector('.slide-canvas');
if (!canvas) {
return false;
}
var images = Array.prototype.slice.call(canvas.querySelectorAll('img'));
return images.every(function (image) {
return image.complete && typeof image.naturalWidth === 'number';
});
}, { timeout: 30000 });
await page.evaluate(async function () {
if (document.fonts && document.fonts.ready) {
try {
await document.fonts.ready;
} catch (_error) {
// Ignore font readiness failures and fall back to the rendered frame.
}
}
});
await page.evaluate(function () {
return new Promise(function (resolve) {
window.requestAnimationFrame(function () {
window.requestAnimationFrame(resolve);
});
});
});
}
const browser = await launchBrowser();
try {
const page = await browser.newPage();
@@ -312,12 +237,11 @@ async function captureSlideThumbnail(options) {
const previewUrl = baseUrl + previewPath;
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
method: 'GET',
pathname: previewPath,
body: {}
pathname: previewPath
}));
await page.setViewport(PLAYER_VIEWPORT);
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForSelector('.slide-canvas', { timeout: 30000, visible: true });
await waitForThumbnailRender(page);
const canvas = await page.$('.slide-canvas');
if (!canvas) {
throw new Error('Player render did not produce a slide canvas.');
+57 -5
View File
@@ -15,6 +15,7 @@ function createUploadSyncService(options) {
const playerSnapshotCache = options && options.playerSnapshotCache;
const notifyPlayerScreens = options && options.notifyPlayerScreens;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
let playerUploadSyncMode = null;
let playerUploadSyncModePromise = null;
@@ -40,7 +41,12 @@ function createUploadSyncService(options) {
cb(null, `${stamp}${safeExt}`);
}
});
return multer({ storage });
return multer({
storage: storage,
limits: {
fileSize: MAX_UPLOAD_BYTES
}
});
}
function normalizeUploadReference(uploadPath) {
@@ -176,6 +182,51 @@ function createUploadSyncService(options) {
}
}
async function collectUploadPathsFromDirectory(uploadDir) {
const normalizedUploadDir = normalizeUploadRoot(uploadDir);
if (!normalizedUploadDir) {
return [];
}
const uploadPaths = [];
async function walkDirectory(currentDir, relativeDir) {
let entries = [];
try {
entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
} catch (error) {
if (error && error.code !== 'ENOENT') {
console.warn('Unable to read upload directory:', currentDir, error);
}
return;
}
for (const entry of entries) {
const entryName = String(entry && entry.name || '').trim();
if (!entryName || entryName === '.' || entryName === '..') {
continue;
}
const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName;
const nextAbsolutePath = path.join(currentDir, entryName);
if (entry.isDirectory && entry.isDirectory()) {
await walkDirectory(nextAbsolutePath, nextRelativePath);
continue;
}
if (entry.isFile && !entry.isFile()) {
continue;
}
uploadPaths.push('/media/uploads/' + nextRelativePath.replace(/\\/g, '/'));
}
}
await walkDirectory(normalizedUploadDir, '');
return uploadPaths;
}
async function getPlayerUploadSyncMode(localUploadDir) {
if (playerUploadSyncMode) {
return playerUploadSyncMode;
@@ -576,6 +627,10 @@ function createUploadSyncService(options) {
if (mode === 'playlist') {
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
if (operation.nextUploadRefs.length) {
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
}
if (operation.previousUploadRefs.length) {
const nextUploadRefSet = new Set(operation.nextUploadRefs);
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
@@ -583,10 +638,6 @@ function createUploadSyncService(options) {
}));
}
if (operation.nextUploadRefs.length) {
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
}
if (operation.refreshScreenSlugs.length) {
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
if (refreshTargets.ready.length) {
@@ -639,6 +690,7 @@ function createUploadSyncService(options) {
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
countUploadReferences: countUploadReferences,
removeUnusedUploadFiles: removeUnusedUploadFiles,
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
getPlayerUploadSyncMode: getPlayerUploadSyncMode,
shouldMirrorUploads: shouldMirrorUploads,
queuePlayerUploadSync: queuePlayerUploadSync,
+2 -1
View File
@@ -33,7 +33,8 @@ module.exports = {
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')),
renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')),
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')),
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage,
renderBackgroundTasksScheduledPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksScheduledPage,
renderErrorPage: require('./error'),
renderRbacPage: require(routePath('settings', 'rbac', 'list')),
renderRbacAddPage: require(routePath('settings', 'rbac', 'add')),
+443 -36
View File
@@ -267,7 +267,7 @@
}
.dashboard-actions-card .card-header,
.dashboard-table-card .card-header {
.dashboard-screen-card-shell .card-header {
padding-top: 1rem;
padding-bottom: 1rem;
}
@@ -309,6 +309,107 @@
font-size: 0.875rem;
}
.dashboard-screen-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.dashboard-screen-card-header .dashboard-card-heading {
flex: 1 1 auto;
min-width: 0;
}
.dashboard-screen-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 1rem;
}
.dashboard-screen-tile {
display: grid;
gap: 0.95rem;
padding: 1rem;
border: 1px solid var(--bs-border-color);
border-radius: calc(var(--bs-border-radius) + 0.25rem);
background: var(--bs-body-bg);
}
.dashboard-screen-tile-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.dashboard-screen-tile-text {
min-width: 0;
}
.dashboard-screen-name {
margin: 0;
font-size: 1rem;
line-height: 1.2;
}
.dashboard-screen-link {
display: inline-block;
margin-top: 0.25rem;
color: var(--bs-secondary-color);
font-size: 0.875rem;
overflow-wrap: anywhere;
}
.dashboard-screen-pill {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
padding: 0.3rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
white-space: nowrap;
}
.dashboard-screen-pill.is-live {
background: rgba(25, 135, 84, 0.12);
color: var(--bs-success);
}
.dashboard-screen-pill.is-idle {
background: rgba(108, 117, 125, 0.12);
color: var(--bs-secondary-color);
}
.dashboard-screen-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
}
.dashboard-screen-meta div {
min-width: 0;
}
.dashboard-screen-meta dt {
color: var(--bs-secondary-color);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.dashboard-screen-meta dd {
margin: 0.15rem 0 0;
overflow-wrap: anywhere;
}
.dashboard-screen-empty {
margin: 0;
padding: 0.25rem 0;
}
.page-header {
display: flex;
align-items: flex-end;
@@ -759,6 +860,13 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
display: block;
}
.slide-preview-video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.slide-preview-background {
width: 100%;
height: 100%;
@@ -787,6 +895,246 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
z-index: 0;
}
.slide-preview-popup-body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #111;
color: #fff;
font-family: Arial, sans-serif;
}
.slide-preview-popup-stage {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #111;
}
.slide-preview-popup-canvas {
position: absolute;
top: 0;
left: 0;
transform-origin: top left;
will-change: transform;
}
.slide-preview-popup-canvas,
.slide-preview-popup-stage {
box-sizing: border-box;
}
.slide-image-cropper-frame {
position: relative;
width: 100%;
height: 560px;
min-height: 360px;
max-height: 70vh;
overflow: hidden;
border: 0;
border-radius: 0;
background: var(--bs-body-bg);
}
.slide-image-cropper-frame img {
display: block;
max-width: 100%;
max-height: 65vh;
}
.slide-image-cropper-frame > .cropper-container {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
display: block;
}
.slide-image-cropper-frame > .cropper-container img {
max-width: none !important;
max-height: none !important;
}
.slide-image-cropper-frame .cropper-container,
.slide-image-cropper-frame .cropper-canvas,
.slide-image-cropper-frame .cropper-wrap-box,
.slide-image-cropper-frame .cropper-crop-box,
.slide-image-cropper-frame .cropper-view-box,
.slide-image-cropper-frame .cropper-face {
border-radius: 0 !important;
}
.slide-image-cropper-frame .cropper-wrap-box,
.slide-image-cropper-frame .cropper-crop-box {
border: 0 !important;
}
.slide-image-cropper-toolbar .btn {
white-space: nowrap;
}
.slide-image-region-preview-box {
display: grid;
gap: 0;
justify-items: end;
}
.slide-image-region-preview-shell {
position: relative;
display: block;
width: 100%;
max-width: 288px;
height: 176px;
cursor: pointer;
}
.slide-image-region-preview-shell:focus-visible,
.slide-image-region-preview-shell:hover .slide-image-region-preview-remove,
.slide-image-region-preview-shell:focus-visible .slide-image-region-preview-remove {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.slide-image-region-preview-remove {
position: absolute;
top: 50%;
left: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
border-radius: 999px;
background: rgba(33, 37, 41, 0.82);
color: #fff;
opacity: 0;
transform: translate(-50%, -50%) scale(0.92);
transition: opacity 0.15s ease, transform 0.15s ease, background-color 0.15s ease;
pointer-events: none;
}
.slide-image-region-preview-shell:hover .slide-image-region-preview-remove,
.slide-image-region-preview-shell:focus-visible .slide-image-region-preview-remove {
background: rgba(var(--bs-danger-rgb), 0.92);
}
.slide-image-region-preview-remove i {
font-size: 1.5rem;
line-height: 1;
}
.slide-image-region-upload-zone {
flex: 1 1 auto;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.9rem;
min-height: 176px;
padding: 1rem 1.1rem;
border: 1px dashed var(--bs-border-color);
border-radius: 0;
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}
.slide-image-region-upload-zone-content,
.slide-image-region-upload-zone-progress {
display: contents;
}
.slide-image-region-upload-zone-progress {
display: none;
grid-column: 1 / -1;
gap: 0.5rem;
}
.slide-image-region-upload-zone-progress-label {
font-weight: 600;
color: var(--bs-body-color);
}
.slide-image-region-upload-zone-progress-bar {
width: 100%;
height: 0.85rem;
}
.slide-image-region-upload-zone-limit {
color: var(--bs-danger);
font-size: 0.75rem;
font-weight: 600;
}
.slide-image-region-upload-zone.is-uploading {
grid-template-columns: 1fr;
pointer-events: none;
cursor: progress;
opacity: 0.95;
}
.slide-image-region-upload-zone.is-uploading .slide-image-region-upload-zone-content {
display: none;
}
.slide-image-region-upload-zone.is-uploading .slide-image-region-upload-zone-progress {
display: grid;
}
.slide-image-region-upload-zone:hover,
.slide-image-region-upload-zone:focus-within,
.slide-image-region-upload-zone.is-dragover {
border-color: var(--bs-primary);
background: var(--bs-secondary-bg);
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.12);
}
.slide-image-region-upload-zone-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
font-size: 1.25rem;
color: var(--bs-primary);
}
.slide-image-region-upload-zone-copy {
display: grid;
gap: 0.2rem;
}
.slide-image-region-upload-zone-copy span {
color: var(--bs-secondary-color);
font-size: 0.875rem;
}
.slide-image-region-preview {
display: block;
width: 100%;
max-width: 288px;
height: 176px;
object-fit: contain;
border-radius: 0;
background: var(--bs-tertiary-bg);
border: 0;
}
.slide-image-region-preview-shell video.slide-image-region-preview {
pointer-events: none;
}
.slide-image-region-preview-empty {
display: flex;
align-items: center;
justify-content: center;
border: 0;
color: var(--bs-secondary-color);
font-size: 0.8rem;
}
.template-designer-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 20rem;
@@ -803,7 +1151,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
position: relative;
width: 100%;
min-height: 18rem;
overflow: hidden;
/* overflow: hidden; */
border: 1px solid var(--bs-border-color);
border-radius: 0;
background: var(--bs-body-bg);
@@ -832,7 +1180,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
.designer-overlay {
z-index: 2;
cursor: crosshair;
cursor: default;
}
.designer-rect {
@@ -840,9 +1188,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
border: 2px solid rgba(13, 110, 253, 0.95);
background: rgba(13, 110, 253, 0.12);
box-sizing: border-box;
border-radius: 0.35rem;
border-radius: 0;
min-width: 12px;
min-height: 12px;
cursor: move;
}
.designer-rect.selected {
@@ -852,12 +1201,12 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
.designer-rect-label {
position: absolute;
left: 0;
top: -1.55rem;
left: 0.4rem;
top: 0.35rem;
max-width: 100%;
padding: 0.15rem 0.45rem;
border-radius: 999px;
background: rgba(33, 37, 41, 0.88);
background: rgba(33, 37, 41, 0.92);
color: #fff;
font-size: 0.75rem;
line-height: 1.2;
@@ -881,26 +1230,30 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
}
.resize-handle.nw {
left: -0.4rem;
top: -0.4rem;
left: 0;
top: 0;
transform: translate(-50%, -50%);
cursor: nwse-resize;
}
.resize-handle.ne {
right: -0.4rem;
top: -0.4rem;
right: 0;
top: 0;
transform: translate(50%, -50%);
cursor: nesw-resize;
}
.resize-handle.sw {
left: -0.4rem;
bottom: -0.4rem;
left: 0;
bottom: 0;
transform: translate(-50%, 50%);
cursor: nesw-resize;
}
.resize-handle.se {
right: -0.4rem;
bottom: -0.4rem;
right: 0;
bottom: 0;
transform: translate(50%, 50%);
cursor: nwse-resize;
}
@@ -984,7 +1337,6 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.region-info-card .card-title {
@@ -1054,7 +1406,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
gap: 0;
min-height: 3.25rem;
}
@@ -1130,8 +1482,14 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
margin: 0;
}
.region-item .remove-region {
justify-self: end;
.region-item [data-region-remove-button] {
width: 100%;
}
.region-item [data-region-remove-button]:disabled {
opacity: 0.65;
box-shadow: none;
cursor: not-allowed;
}
.region-item .form-control:focus,
@@ -1265,6 +1623,19 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
box-shadow: none;
}
.playlist-slide-picker-card.is-assigned {
border-color: rgba(var(--bs-primary-rgb), 0.35);
background: rgba(var(--bs-primary-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-assigned .playlist-slide-picker-title {
color: var(--bs-body-secondary);
}
.playlist-slide-picker-card.is-hidden {
display: none !important;
}
@@ -1322,8 +1693,8 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
padding: 0 0.9rem 0.6rem;
}
.playlist-slide-picker-meta {
display: none;
.playlist-slide-picker-filter-button {
white-space: nowrap;
}
.playlist-slide-picker-check {
@@ -1335,7 +1706,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
opacity: 0;
background: rgba(var(--bs-body-bg-rgb), 0.88);
border-radius: 999px;
padding: 0.2rem 0.3rem;
padding: 0.2rem 0.45rem;
box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.12);
}
@@ -1345,6 +1716,17 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
.playlist-slide-picker-badge {
display: none;
position: absolute;
left: 0.5rem;
top: 0.5rem;
z-index: 1;
font-size: 0.65rem;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.playlist-slide-picker-card.is-assigned .playlist-slide-picker-badge {
display: inline-flex;
}
.playlist-slide-picker-empty.is-hidden {
@@ -1416,6 +1798,34 @@ td[data-label="Actions"] > div {
min-width: 6rem;
}
.playlist-duration-input::-webkit-outer-spin-button,
.playlist-duration-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.playlist-duration-input {
-moz-appearance: textfield;
appearance: textfield;
}
.playlist-duration-field {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
text-align: left;
}
.playlist-use-video-duration {
white-space: nowrap;
}
td[data-label="Duration"] {
text-align: left;
}
table.table > :not(caption) > * > * {
vertical-align: middle;
}
@@ -1438,8 +1848,9 @@ table.table > :not(caption) > * > * {
.playlist-slide-thumb {
flex: 0 0 auto;
width: 5.5rem;
height: 3.5rem;
max-width: 5.5rem;
max-height: 5.5rem;
aspect-ratio: var(--playlist-slide-thumb-aspect-ratio, 16 / 9);
border-radius: 0.5rem;
overflow: hidden;
background: rgba(15, 23, 42, 0.08);
@@ -1456,13 +1867,19 @@ table.table > :not(caption) > * > * {
}
.playlist-slide-thumb-placeholder {
display: inline-flex;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-width: 5.5rem;
min-height: 3.5rem;
color: var(--bs-secondary-color, #6c757d);
font-size: 1.1rem;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%),
rgba(15, 23, 42, 0.18);
font-size: 2.25rem;
line-height: 1;
}
.playlist-order-cell {
@@ -1547,16 +1964,6 @@ table.table thead th.sort-desc .table-sort-indicator {
grid-template-columns: 1fr;
}
.dashboard-hero-body {
flex-direction: column;
align-items: stretch;
}
.dashboard-hero-stats {
grid-template-columns: 1fr;
min-width: 0;
}
.page-header {
align-items: flex-start;
flex-direction: column;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 19 KiB

-599
View File
@@ -1,599 +0,0 @@
(function () {
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
var date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getClientDisplayName(client) {
if (client && client.client_name) {
return String(client.client_name).trim();
}
var clientId = String(client && client.clientId ? client.clientId : '').trim();
if (clientId) {
return clientId;
}
return '';
}
function setButtonVariant(button, classesToRemove, classToAdd) {
if (!button) {
return;
}
if (button.classList) {
classesToRemove.forEach(function (className) {
button.classList.remove(className);
});
if (classToAdd) {
button.classList.add(classToAdd);
}
return;
}
var className = String(button.className || '');
classesToRemove.forEach(function (removeClass) {
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
});
if (classToAdd) {
className += ' ' + classToAdd;
}
button.className = className.replace(/\s+/g, ' ').trim();
}
function normalizeDisplayIp(value) {
var ip = String(value || '').trim();
if (!ip) {
return '';
}
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
return ip.slice(7).trim();
}
return ip;
}
function initConfirmForms() {
document.addEventListener('submit', function (event) {
var form = event.target;
if (!form || !form.getAttribute) {
return;
}
if (form.hasAttribute && form.hasAttribute('data-async-command')) {
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
}
});
}
function markFormDirty(form) {
if (!form || !form.hasAttribute || form.hasAttribute('data-clean-on-load')) {
return;
}
form.dataset.dirty = 'true';
}
function clearFormDirty(form) {
if (!form) {
return;
}
form.dataset.dirty = 'false';
}
function isFormDirty(form) {
return Boolean(form && form.dataset && form.dataset.dirty === 'true');
}
function initDirtyTracking() {
document.addEventListener('input', function (event) {
var target = event.target;
if (!target || !target.form) {
return;
}
markFormDirty(target.form);
}, true);
document.addEventListener('change', function (event) {
var target = event.target;
if (!target || !target.form) {
return;
}
markFormDirty(target.form);
}, true);
}
function initCancelConfirm() {
document.addEventListener('click', function (event) {
var cancelTarget = event.target.closest('[data-confirm-unsaved]');
if (!cancelTarget) {
return;
}
var form = cancelTarget.form || cancelTarget.closest('form') || document.querySelector('form[data-dirty="true"]');
if (!isFormDirty(form)) {
return;
}
var message = cancelTarget.getAttribute('data-confirm-unsaved') || 'You have unsaved changes. Leave this page?';
if (!window.confirm(message)) {
event.preventDefault();
event.stopPropagation();
}
}, true);
}
function initAsyncCommandForms() {
document.addEventListener('submit', function (event) {
var form = event.target;
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-command')) {
return;
}
if (form.dataset && form.dataset.busy === 'true') {
event.preventDefault();
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
return;
}
event.preventDefault();
form.dataset.busy = 'true';
var formData = new FormData(form);
var body = new URLSearchParams();
formData.forEach(function (value, key) {
body.append(key, value);
});
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
body: body.toString(),
credentials: 'same-origin'
}).finally(function () {
delete form.dataset.busy;
});
}, true);
}
function initAsyncSaveForms() {
var refreshSequence = 0;
function setSaveActionValue(form, value) {
if (!form) {
return;
}
var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]');
if (!hiddenInput) {
hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'save_action';
form.appendChild(hiddenInput);
}
hiddenInput.value = String(value || '').trim().toLowerCase();
}
function getResponseQueryValue(responseUrl, key) {
try {
var url = new URL(responseUrl, window.location.href);
return String(url.searchParams.get(key) || '').trim();
} catch (_error) {
return '';
}
}
function rebindRefreshTarget(targetElement) {
if (!targetElement) {
return;
}
if (typeof window.initJsonTogglePanels === 'function') {
window.initJsonTogglePanels(targetElement);
}
if (typeof window.initLocalDateTimes === 'function') {
window.initLocalDateTimes(targetElement);
}
}
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
if (sequenceId !== refreshSequence) {
return;
}
fetch(window.location.href, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/xhtml+xml'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to refresh saved data.');
}
return response.text();
}).then(function (text) {
if (sequenceId !== refreshSequence) {
return;
}
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
var currentTarget = document.querySelector(refreshTargetSelector);
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
if (!currentTarget || !nextTarget) {
return;
}
currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
}).catch(function (_error) {
// Ignore refresh replacement failures and leave the existing content in place.
});
}
function watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, sequenceId) {
var pollDelayMs = 1000;
var maxAttempts = 60;
function poll(attempt) {
if (sequenceId !== refreshSequence) {
return;
}
var stateUrl;
try {
stateUrl = new URL(refreshStateUrl, window.location.href);
} catch (_error) {
return;
}
stateUrl.searchParams.set('refresh_task_id', refreshTaskId);
fetch(stateUrl.toString(), {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to check refresh status.');
}
return response.json();
}).then(function (payload) {
if (sequenceId !== refreshSequence) {
return;
}
var status = String(payload && payload.status || '').trim().toLowerCase();
if (status === 'queued' || status === 'running') {
if (attempt < maxAttempts) {
window.setTimeout(function () {
poll(attempt + 1);
}, pollDelayMs);
}
return;
}
replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId);
}).catch(function () {
if (attempt < maxAttempts) {
window.setTimeout(function () {
poll(attempt + 1);
}, pollDelayMs);
}
});
}
poll(0);
}
document.addEventListener('click', function (event) {
var target = event.target;
if (!target || !target.closest) {
return;
}
var button = target.closest('button[name="save_action"]');
if (!button) {
return;
}
var form = button.form || button.closest('form');
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
return;
}
setSaveActionValue(form, button.value || '');
form.dataset.submitterValue = String(button.value || '').trim().toLowerCase();
}, true);
document.addEventListener('submit', function (event) {
var form = event.target;
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
return;
}
if (form.dataset && form.dataset.busy === 'true') {
event.preventDefault();
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
return;
}
event.preventDefault();
form.dataset.busy = 'true';
var hiddenSaveAction = form.querySelector('input[type="hidden"][name="save_action"]');
var formData = new FormData(form);
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
if (event.submitter && event.submitter.name) {
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
formData.set(event.submitter.name, event.submitter.value || '');
}
var hasFileValue = false;
formData.forEach(function (value) {
if (value && typeof value === 'object' && typeof value.name === 'string') {
hasFileValue = true;
}
});
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
var body = isMultipart ? formData : new URLSearchParams();
if (!isMultipart) {
formData.forEach(function (value, key) {
body.append(key, value);
});
}
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
headers: Object.assign({
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/json, text/plain, */*'
}, isMultipart ? {} : {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
}),
body: isMultipart ? body : body.toString(),
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
var error = new Error(text || 'Unable to save changes.');
error.status = response.status;
throw error;
});
}
var actionUrl = '';
try {
actionUrl = new URL(form.action, window.location.href).pathname;
} catch (_error) {
actionUrl = String(form.action || '');
}
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
var refreshTaskId = getResponseQueryValue(response.url || '', 'refresh_task_id');
var shouldFollowRedirect = Boolean(form.hasAttribute('data-async-save-new-url')) && !/\/\d+(?:\/|$)/.test(actionUrl);
if (submitterValue === 'close' || submitterValue === 'new') {
var redirectUrl = submitterValue === 'close'
? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href)
: String(form.dataset.asyncSaveNewUrl || response.url || window.location.href);
window.location.replace(redirectUrl);
return;
}
if (shouldFollowRedirect && response.url) {
clearFormDirty(form);
window.location.replace(response.url);
return;
}
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 = '';
}
if (refreshTargetSelector && refreshStateUrl && refreshTaskId) {
refreshSequence += 1;
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
} else if (refreshTargetSelector && responseDocument) {
var currentTarget = document.querySelector(refreshTargetSelector);
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
if (currentTarget && nextTarget) {
currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
}
}
showToast(savedMessage || 'Saved.', 'success');
});
}).catch(function (error) {
if (typeof showToast === 'function') {
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
showToast(error.message || 'Unable to save changes.', variant);
return;
}
window.alert(error.message || 'Unable to save changes.');
}).finally(function () {
delete form.dataset.busy;
delete form.dataset.submitterValue;
if (hiddenSaveAction) {
hiddenSaveAction.value = '';
}
});
}, true);
}
function initSubmitOnChange() {
var fields = document.querySelectorAll('[data-submit-on-change]');
Array.prototype.forEach.call(fields, function (field) {
field.addEventListener('change', function () {
var form = field.form || field.closest('form');
if (form) {
form.submit();
}
});
});
}
function initJsonTogglePanels(root) {
var scope = root && root.querySelectorAll ? root : document;
var panels = scope.querySelectorAll('[data-json-toggle-panel]');
Array.prototype.forEach.call(panels, function (panel) {
var output = panel.querySelector('[data-json-toggle-output]');
if (!output) {
return;
}
var card = panel.closest ? panel.closest('.card') : null;
var button = card ? card.querySelector('[data-json-toggle]') : null;
var label = button ? button.querySelector('[data-json-toggle-label]') : null;
var sourceNode = panel.querySelector('[data-json-toggle-source]');
var rawJson = '';
try {
rawJson = JSON.parse(String(sourceNode ? sourceNode.textContent : '""'));
} catch (_error) {
rawJson = '';
}
if (typeof rawJson !== 'string' || !rawJson.trim()) {
if (button) {
button.classList.add('d-none');
}
return;
}
var parsedJson;
try {
parsedJson = JSON.parse(rawJson);
} catch (_error) {
if (button) {
button.classList.add('d-none');
}
return;
}
var compactJson = JSON.stringify(parsedJson);
var formattedJson = JSON.stringify(parsedJson, null, 2);
var isFormatted = true;
function syncButtonLabel() {
if (!button || !label) {
return;
}
label.textContent = isFormatted
? String(button.getAttribute('data-json-toggle-label-compact') || 'Unformat JSON')
: String(button.getAttribute('data-json-toggle-label-formatted') || 'Format JSON');
button.setAttribute('aria-pressed', isFormatted ? 'true' : 'false');
}
function syncOutput() {
output.textContent = isFormatted ? formattedJson : compactJson;
syncButtonLabel();
}
output.textContent = formattedJson;
syncButtonLabel();
if (button) {
button.addEventListener('click', function () {
isFormatted = !isFormatted;
syncOutput();
});
}
});
}
function initLocalDateTimes(root) {
var scope = root && root.querySelectorAll ? root : document;
var elements = scope.querySelectorAll('[data-local-datetime]');
Array.prototype.forEach.call(elements, function (element) {
var rawValue = String(element.getAttribute('datetime') || element.getAttribute('data-local-datetime') || element.textContent || '').trim();
if (!rawValue) {
return;
}
var date = new Date(rawValue);
if (Number.isNaN(date.getTime())) {
return;
}
element.textContent = formatDashboardDate(date);
});
}
if (window.initSortableTables) {
window.initSortableTables();
}
initConfirmForms();
initDirtyTracking();
initCancelConfirm();
initAsyncCommandForms();
initAsyncSaveForms();
initSubmitOnChange();
initJsonTogglePanels();
initLocalDateTimes();
window.initJsonTogglePanels = initJsonTogglePanels;
window.initLocalDateTimes = initLocalDateTimes;
}());
+11 -394
View File
@@ -1,84 +1,11 @@
(function () {
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
var date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getClientDisplayName(client) {
if (client && client.client_name) {
return String(client.client_name).trim();
}
var clientId = String(client && client.clientId ? client.clientId : '').trim();
if (clientId) {
return clientId;
}
return '';
}
function setButtonVariant(button, classesToRemove, classToAdd) {
if (!button) {
return;
}
if (button.classList) {
classesToRemove.forEach(function (className) {
button.classList.remove(className);
});
if (classToAdd) {
button.classList.add(classToAdd);
}
return;
}
var className = String(button.className || '');
classesToRemove.forEach(function (removeClass) {
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
});
if (classToAdd) {
className += ' ' + classToAdd;
}
button.className = className.replace(/\s+/g, ' ').trim();
}
function normalizeDisplayIp(value) {
var ip = String(value || '').trim();
if (!ip) {
return '';
}
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
return ip.slice(7).trim();
}
return ip;
}
var webUiHelpers = window.webUiHelpers || {};
var escapeHtml = webUiHelpers.escapeHtml;
var formatDashboardDate = webUiHelpers.formatDashboardDate;
var getClientRowKey = webUiHelpers.getClientRowKey;
var getClientDisplayName = webUiHelpers.getClientDisplayName;
var setButtonVariant = webUiHelpers.setButtonVariant;
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
function initConfirmForms() {
document.addEventListener('submit', function (event) {
@@ -237,9 +164,6 @@
if (typeof window.initTableSearches === 'function') {
window.initTableSearches(targetElement);
}
if (typeof window.initTablePaginations === 'function') {
window.initTablePaginations(targetElement);
}
}
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
@@ -274,7 +198,7 @@
currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
}).catch(function (_error) {
}).catch(function () {
// Ignore refresh replacement failures and leave the existing content in place.
});
}
@@ -588,303 +512,10 @@
});
}
function initTablePaginations(root) {
var scope = root && root.querySelectorAll ? root : document;
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-pagination="auto"]'), function (container) {
if (container.getAttribute('data-table-pagination-bound') === 'true') {
return;
}
var table = container.querySelector('[data-table-searchable]');
var cardHeader = container.querySelector('.card-header');
var cardBody = container.querySelector('.card-body.table-responsive') || container.querySelector('.card-body');
var footer = container.querySelector('[data-table-pagination-controls]');
var footerSummary = null;
var footerList = null;
var syncFrame = 0;
var state = {
currentPage: 1,
pageSize: 1,
totalPages: 1
};
if (!table || !cardBody || !table.tBodies.length) {
return;
}
container.setAttribute('data-table-pagination-bound', 'true');
function getRows() {
return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]'));
}
function isSearchMatched(row) {
return String(row.getAttribute('data-table-search-match') || 'true') !== 'false';
}
function ensureFooter() {
if (footer) {
footerSummary = footer.querySelector('[data-table-pagination-summary]');
footerList = footer.querySelector('[data-table-pagination-list]');
return footer;
}
footer = document.createElement('div');
footer.className = 'card-footer d-none';
footer.setAttribute('data-table-pagination-controls', 'true');
footer.innerHTML = '' +
'<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">' +
'<div class="text-muted small" data-table-pagination-summary></div>' +
'<nav aria-label="Table pages">' +
'<ul class="pagination pagination-sm mb-0" data-table-pagination-list></ul>' +
'</nav>' +
'</div>';
cardBody.parentNode.insertBefore(footer, cardBody.nextSibling);
footerSummary = footer.querySelector('[data-table-pagination-summary]');
footerList = footer.querySelector('[data-table-pagination-list]');
return footer;
}
function setRowVisible(row, visible) {
row.hidden = !visible;
row.classList.toggle('d-none', !visible);
}
function getAvailableHeight() {
var containerRect = container.getBoundingClientRect();
var headerHeight = cardHeader ? cardHeader.getBoundingClientRect().height : 0;
var footerEstimate = footer && !footer.classList.contains('d-none') ? footer.getBoundingClientRect().height : 56;
return Math.max(0, window.innerHeight - Math.max(0, containerRect.top) - headerHeight - footerEstimate - 24);
}
function renderFooter(totalItems) {
var pageButtons = [];
ensureFooter();
if (state.totalPages <= 1 || totalItems <= 0) {
footer.classList.add('d-none');
footerSummary.textContent = '';
footerList.innerHTML = '';
return;
}
footer.classList.remove('d-none');
footerSummary.textContent = 'Showing ' + (((state.currentPage - 1) * state.pageSize) + 1) + '-' + Math.min(totalItems, state.currentPage * state.pageSize) + ' of ' + totalItems;
pageButtons.push('<li class="page-item' + (state.currentPage <= 1 ? ' disabled' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + (state.currentPage - 1) + '" aria-label="Previous page">Previous</a></li>');
for (var pageNumber = 1; pageNumber <= state.totalPages; pageNumber += 1) {
pageButtons.push('<li class="page-item' + (pageNumber === state.currentPage ? ' active' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + pageNumber + '">' + pageNumber + '</a></li>');
}
pageButtons.push('<li class="page-item' + (state.currentPage >= state.totalPages ? ' disabled' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + (state.currentPage + 1) + '" aria-label="Next page">Next</a></li>');
footerList.innerHTML = pageButtons.join('');
}
function applyPagination(resetPage) {
var rows = getRows();
var matchedRows = rows.filter(isSearchMatched);
var totalItems = matchedRows.length;
var rowHeight = 48;
var pageSize;
var startIndex;
var endIndex;
if (resetPage) {
state.currentPage = 1;
}
rows.forEach(function (row) {
if (!isSearchMatched(row)) {
setRowVisible(row, false);
}
});
if (!totalItems) {
state.pageSize = 1;
state.totalPages = 1;
state.currentPage = 1;
renderFooter(0);
return;
}
matchedRows.forEach(function (row) {
setRowVisible(row, true);
});
if (matchedRows[0]) {
rowHeight = Math.max(24, matchedRows[0].getBoundingClientRect().height || matchedRows[0].offsetHeight || 48);
}
pageSize = Math.max(1, Math.floor(getAvailableHeight() / rowHeight));
if (pageSize >= totalItems) {
state.pageSize = totalItems;
state.totalPages = 1;
state.currentPage = 1;
matchedRows.forEach(function (row) {
setRowVisible(row, true);
});
renderFooter(totalItems);
return;
}
state.pageSize = pageSize;
state.totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
state.currentPage = Math.min(Math.max(1, state.currentPage), state.totalPages);
startIndex = (state.currentPage - 1) * pageSize;
endIndex = startIndex + pageSize;
matchedRows.forEach(function (row, index) {
setRowVisible(row, index >= startIndex && index < endIndex);
});
renderFooter(totalItems);
}
function scheduleSync(options) {
if (syncFrame) {
window.cancelAnimationFrame(syncFrame);
}
syncFrame = window.requestAnimationFrame(function () {
syncFrame = 0;
applyPagination(Boolean(options && options.resetPage));
});
}
ensureFooter();
if (footer) {
footer.addEventListener('click', function (event) {
var target = event.target && event.target.closest ? event.target.closest('[data-table-pagination-page]') : null;
var pageValue;
if (!target || target.closest('.disabled')) {
return;
}
event.preventDefault();
pageValue = Math.max(1, Math.floor(Number(target.getAttribute('data-table-pagination-page')) || 1));
state.currentPage = pageValue;
scheduleSync();
});
}
window.addEventListener('resize', function () {
scheduleSync();
});
if (typeof ResizeObserver === 'function') {
try {
new ResizeObserver(function () {
scheduleSync();
}).observe(container);
} catch (_error) {
// Ignore observer setup failures and rely on window resize.
}
}
container.syncTablePagination = scheduleSync;
scheduleSync({ resetPage: true });
});
}
function initTableSearches(root) {
var scope = root && root.querySelectorAll ? root : document;
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-search]'), function (input) {
if (input.getAttribute('data-table-search-bound') === 'true') {
return;
}
var container = input.closest('[data-table-search-container]') || input.closest('.card') || document;
var table = container.querySelector('[data-table-searchable]');
var emptyRow = null;
if (!table) {
return;
}
input.setAttribute('data-table-search-bound', 'true');
function getSearchableRows() {
return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]'));
}
function getDefaultEmptyRow() {
return table.querySelector('tbody tr[data-table-search-empty-default]');
}
function removeGeneratedEmptyRow() {
if (emptyRow && emptyRow.parentNode) {
emptyRow.parentNode.removeChild(emptyRow);
}
emptyRow = null;
}
function ensureGeneratedEmptyRow(message) {
var tbody = table.tBodies[0] || table.querySelector('tbody');
var columnCount = 1;
if (!tbody) {
return;
}
if (!emptyRow) {
emptyRow = document.createElement('tr');
emptyRow.setAttribute('data-table-search-empty-row', 'true');
var cell = document.createElement('td');
cell.className = 'empty';
cell.setAttribute('data-table-search-empty-cell', 'true');
emptyRow.appendChild(cell);
}
columnCount = table.tHead && table.tHead.rows && table.tHead.rows[0] ? table.tHead.rows[0].cells.length : (getSearchableRows()[0] ? getSearchableRows()[0].cells.length : 1);
emptyRow.firstElementChild.colSpan = Math.max(1, columnCount);
emptyRow.firstElementChild.textContent = message;
if (!emptyRow.parentNode) {
tbody.appendChild(emptyRow);
}
}
function syncSearch() {
var query = String(input.value || '').trim().toLowerCase();
var rows = getSearchableRows();
var visibleCount = 0;
var defaultEmptyRow = getDefaultEmptyRow();
removeGeneratedEmptyRow();
rows.forEach(function (row) {
var haystack = String(row.getAttribute('data-search-text') || row.textContent || '').toLowerCase();
var visible = !query || haystack.indexOf(query) !== -1;
row.setAttribute('data-table-search-match', visible ? 'true' : 'false');
row.classList.toggle('d-none', !visible);
row.hidden = !visible;
if (visible) {
visibleCount += 1;
}
});
if (defaultEmptyRow) {
defaultEmptyRow.classList.toggle('d-none', Boolean(query));
defaultEmptyRow.hidden = Boolean(query);
}
if (query && visibleCount === 0) {
ensureGeneratedEmptyRow('No results match your search.');
}
if (typeof window.syncTablePagination === 'function') {
window.syncTablePagination(container, { resetPage: true });
}
}
input.addEventListener('input', syncSearch);
syncSearch();
});
if (typeof window.initTableSearches === 'function') {
window.initTableSearches(root);
}
}
function createSlideThumbPlaceholder() {
@@ -914,10 +545,6 @@
});
}
if (window.initSortableTables) {
window.initSortableTables();
}
initConfirmForms();
initDirtyTracking();
initCancelConfirm();
@@ -927,19 +554,9 @@
initJsonTogglePanels();
initLocalDateTimes();
initTableSearches();
initTablePaginations();
attachSlideThumbFallbacks(document);
window.initJsonTogglePanels = initJsonTogglePanels;
window.initLocalDateTimes = initLocalDateTimes;
window.initTableSearches = initTableSearches;
window.initTablePaginations = initTablePaginations;
window.syncTablePagination = function (container, options) {
if (!container || !container.syncTablePagination) {
return;
}
container.syncTablePagination(options);
};
window.createSlideThumbPlaceholder = createSlideThumbPlaceholder;
window.attachSlideThumbFallbacks = attachSlideThumbFallbacks;
}());
-111
View File
@@ -1,111 +0,0 @@
(function () {
function getGroupCheckboxes(group) {
return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]'));
}
function getPermissionKey(checkbox) {
return String((checkbox && (checkbox.getAttribute('data-permission-key') || checkbox.value)) || '').trim().toLowerCase();
}
function getActionKey(checkbox) {
var permissionKey = getPermissionKey(checkbox);
var parts = permissionKey.split('.');
if (parts.length !== 2) {
return '';
}
return String(parts[1] || '').trim().toLowerCase();
}
function syncPermissionGroup(group) {
var checkboxes = getGroupCheckboxes(group);
if (!checkboxes.length) {
return;
}
var readCheckbox = null;
var nonReadChecked = false;
checkboxes.forEach(function (checkbox) {
if (getActionKey(checkbox) === 'read') {
readCheckbox = checkbox;
return;
}
if (checkbox.checked) {
nonReadChecked = true;
}
});
if (!readCheckbox) {
return;
}
if (!readCheckbox.checked && nonReadChecked) {
readCheckbox.checked = true;
}
if (!readCheckbox.checked) {
checkboxes.forEach(function (checkbox) {
if (getActionKey(checkbox) !== 'read') {
checkbox.checked = false;
}
});
}
}
function handleGroupChange(event) {
var checkbox = event.target && event.target.matches ? event.target : null;
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
return;
}
var actionKey = getActionKey(checkbox);
var group = checkbox.closest('.accordion-item');
if (!group) {
return;
}
var checkboxes = getGroupCheckboxes(group);
var readCheckbox = checkboxes.find(function (candidate) {
return getActionKey(candidate) === 'read';
}) || null;
if (!readCheckbox) {
return;
}
if (actionKey === 'read' && !checkbox.checked) {
checkboxes.forEach(function (candidate) {
if (candidate !== checkbox) {
candidate.checked = false;
}
});
return;
}
if (actionKey !== 'read' && checkbox.checked) {
readCheckbox.checked = true;
}
}
function initPermissionGroups() {
document.querySelectorAll('.accordion-item').forEach(function (group) {
syncPermissionGroup(group);
});
document.addEventListener('change', function (event) {
var checkbox = event.target;
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
return;
}
if (String(checkbox.getAttribute('name') || '') !== 'permission_keys[]') {
return;
}
handleGroupChange(event);
syncPermissionGroup(checkbox.closest('.accordion-item'));
});
}
initPermissionGroups();
}());
-102
View File
@@ -1,102 +0,0 @@
(function () {
function updateSidebarStatus(status, label) {
var dot = document.getElementById('sidebar-status-dot');
var text = document.getElementById('sidebar-status-text');
var pill = document.getElementById('sidebar-status-pill');
if (!dot && !text && !pill) {
return;
}
var normalizedStatus = status === 'online' || status === 'offline' || status === 'unknown'
? status
: (status ? 'online' : 'offline');
var statusLabel = normalizedStatus === 'online'
? (label || 'Connected to player feed')
: normalizedStatus === 'offline'
? 'Disconnected from player feed'
: (label || 'Connecting to player feed');
var pillLabel = normalizedStatus === 'online' ? 'Connected' : normalizedStatus === 'offline' ? 'Disconnected' : 'Connecting';
dot.classList.remove('status-dot--unknown', 'status-dot--online', 'status-dot--offline');
dot.classList.add(normalizedStatus === 'online' ? 'status-dot--online' : normalizedStatus === 'offline' ? 'status-dot--offline' : 'status-dot--unknown');
dot.setAttribute('aria-label', statusLabel);
dot.setAttribute('title', statusLabel);
if (text) {
text.textContent = statusLabel;
}
if (pill) {
pill.classList.remove('status-pill--unknown', 'status-pill--online', 'status-pill--offline');
pill.classList.add(normalizedStatus === 'online' ? 'status-pill--online' : normalizedStatus === 'offline' ? 'status-pill--offline' : 'status-pill--unknown');
pill.textContent = pillLabel;
}
}
function connectDashboardSocket() {
if (!window.WebSocket) {
return;
}
var hasStatusIndicators = document.getElementById('sidebar-status-dot') || document.getElementById('sidebar-status-text') || document.getElementById('sidebar-status-pill');
if (!hasStatusIndicators) {
return;
}
var socket = null;
var reconnectTimer = null;
function scheduleReconnect() {
if (reconnectTimer) {
return;
}
reconnectTimer = window.setTimeout(function () {
reconnectTimer = null;
connect();
}, 5000);
}
function connect() {
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/dashboard');
updateSidebarStatus('unknown', 'Connecting to player feed');
socket.onmessage = function (event) {
try {
var payload = JSON.parse(String(event.data || '{}'));
if (payload && payload.type === 'dashboard-state') {
if (typeof window.webHandleDashboardState === 'function') {
window.webHandleDashboardState(payload.state);
}
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed');
}
} catch (_error) {
// Ignore malformed dashboard payloads.
}
};
socket.onclose = function () {
updateSidebarStatus('offline');
scheduleReconnect();
};
socket.onerror = function () {
updateSidebarStatus('offline');
try {
socket.close();
} catch (_error) {
// ignore close errors
}
};
socket.onopen = function () {
updateSidebarStatus('unknown', 'Connecting to player feed');
};
}
connect();
}
connectDashboardSocket();
}());
-171
View File
@@ -1,171 +0,0 @@
(function () {
var THEME_STORAGE_KEY = 'web-theme';
var CKEDITOR_THEME_STYLE_ID = 'ckeditor-dark-theme-overrides';
function getPreferredTheme() {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
function getStoredTheme() {
try {
var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
if (storedTheme === 'dark' || storedTheme === 'light') {
return storedTheme;
}
} catch (error) {
return null;
}
return null;
}
function setStoredTheme(theme) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch (error) {
return;
}
}
function getOrCreateCkeditorThemeStyleElement() {
var styleElement = document.getElementById(CKEDITOR_THEME_STYLE_ID);
if (styleElement) {
return styleElement;
}
styleElement = document.createElement('style');
styleElement.id = CKEDITOR_THEME_STYLE_ID;
document.head.appendChild(styleElement);
return styleElement;
}
function syncCkeditorTheme(theme) {
var styleElement = getOrCreateCkeditorThemeStyleElement();
if (theme !== 'dark') {
styleElement.textContent = '';
return;
}
styleElement.textContent = [
'.ck-body-wrapper .ck.ck-dropdown__panel,',
'.ck-body-wrapper .ck.ck-list__panel,',
'.ck-body-wrapper .ck.ck-list,',
'.ck-body-wrapper .ck.ck-balloon-panel {',
' background: var(--bs-body-bg) !important;',
' background-color: var(--bs-body-bg) !important;',
' border-color: var(--bs-border-color) !important;',
' color: var(--bs-body-color) !important;',
'}',
'.ck-body-wrapper .ck.ck-list .ck-list-item-button {',
' background: transparent !important;',
' background-color: transparent !important;',
' color: var(--bs-body-color) !important;',
'}',
'.ck-body-wrapper .ck.ck-list .ck-list-item-button:hover {',
' background: var(--bs-secondary-bg) !important;',
' background-color: var(--bs-secondary-bg) !important;',
'}',
'.ck-body-wrapper .ck.ck-color-grid,',
'.ck-body-wrapper .ck.ck-color-grid__tile {',
' color: var(--bs-body-color) !important;',
'}',
'.ck-body-wrapper .ck.ck-color-grid__tile {',
' border-color: var(--bs-border-color) !important;',
'}'
].join('\n');
}
function applyTheme(theme) {
var normalizedTheme = theme === 'dark' ? 'dark' : 'light';
var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark';
var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode';
document.documentElement.dataset.bsTheme = normalizedTheme;
document.documentElement.style.colorScheme = normalizedTheme;
syncCkeditorTheme(normalizedTheme);
Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) {
var icon = toggleButton.querySelector('.theme-toggle__icon');
toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false');
toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase());
if (icon) {
icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun');
icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon');
}
});
return normalizedTheme;
}
function initThemeToggle() {
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
var storedTheme = getStoredTheme();
var theme = storedTheme || getPreferredTheme();
applyTheme(theme);
if (!toggleButtons.length) {
return;
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
var nextTheme = document.documentElement.dataset.bsTheme === 'dark' ? 'light' : 'dark';
setStoredTheme(nextTheme);
applyTheme(nextTheme);
});
});
}
function initSidebarToggle() {
var body = document.body;
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]'));
var backdrop = document.querySelector('[data-sidebar-backdrop]');
if (!toggleButtons.length || !backdrop) {
return;
}
function setSidebarOpen(isOpen) {
body.classList.toggle('is-sidebar-open', isOpen);
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
});
}
function toggleSidebar() {
setSidebarOpen(!body.classList.contains('is-sidebar-open'));
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
toggleSidebar();
});
});
backdrop.addEventListener('click', function () {
setSidebarOpen(false);
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
setSidebarOpen(false);
}
});
}
window.getPreferredTheme = getPreferredTheme;
window.getStoredTheme = getStoredTheme;
window.setStoredTheme = setStoredTheme;
window.applyTheme = applyTheme;
window.initThemeToggle = initThemeToggle;
window.initSidebarToggle = initSidebarToggle;
initThemeToggle();
initSidebarToggle();
}());
-134
View File
@@ -1,134 +0,0 @@
(function () {
function getBootstrapToast(toast) {
if (!toast || !window.bootstrap || !window.bootstrap.Toast) {
return null;
}
return window.bootstrap.Toast.getOrCreateInstance(toast, {
autohide: true,
delay: 4000
});
}
function removeToast(toast) {
if (toast && toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}
function dismissToast(toast) {
var instance = getBootstrapToast(toast);
if (instance) {
instance.hide();
return;
}
removeToast(toast);
}
function setToastVariant(toast, variant) {
if (!toast || !toast.classList) {
return;
}
var nextVariant = String(variant || 'info').trim().toLowerCase();
var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'];
variants.forEach(function (value) {
toast.classList.remove('text-bg-' + value);
});
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant));
}
function getMessageVariant(message, fallbackVariant) {
var text = String(message || '').trim();
if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
return 'danger';
}
if (/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i.test(text)) {
return 'warning';
}
return String(fallbackVariant || 'info').trim().toLowerCase() || 'info';
}
function showToast(message, variant) {
var text = String(message || '').trim();
if (!text) {
return;
}
var container = document.getElementById('app-toast-container');
if (!container) {
return;
}
var existingToast = document.getElementById('app-toast');
if (existingToast) {
var existingBody = existingToast.querySelector('.toast-body');
if (existingBody) {
existingBody.textContent = text;
}
var nextVariant = getMessageVariant(text, variant);
existingToast.setAttribute('data-toast-variant', nextVariant);
setToastVariant(existingToast, nextVariant);
var existingInstance = getBootstrapToast(existingToast);
if (existingInstance) {
existingInstance.show();
}
return;
}
var toast = document.createElement('div');
toast.className = 'toast align-items-center border-0';
toast.id = 'app-toast';
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.setAttribute('aria-atomic', 'true');
toast.setAttribute('data-bs-autohide', 'true');
toast.setAttribute('data-bs-delay', '4000');
toast.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>';
var toastVariant = getMessageVariant(text, variant);
toast.setAttribute('data-toast-variant', toastVariant);
setToastVariant(toast, toastVariant);
toast.querySelector('.toast-body').textContent = text;
toast.addEventListener('hidden.bs.toast', function () {
removeToast(toast);
});
container.appendChild(toast);
var instance = getBootstrapToast(toast);
if (instance) {
instance.show();
}
}
function initToast() {
var toast = document.getElementById('app-toast');
if (!toast) {
return;
}
try {
var url = new URL(window.location.href);
if (url.searchParams.has('message')) {
url.searchParams.delete('message');
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
}
} catch (_error) {
// ignore URL cleanup failures
}
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'info');
setToastVariant(toast, existingVariant);
var instance = getBootstrapToast(toast);
if (instance) {
instance.show();
}
}
window.dismissToast = dismissToast;
window.showToast = showToast;
window.initToast = initToast;
initToast();
}());
+76 -100
View File
@@ -1,83 +1,11 @@
(function () {
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
var date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getClientDisplayName(client) {
if (client && client.client_name) {
return String(client.client_name).trim();
}
var clientId = String(client && client.clientId ? client.clientId : '').trim();
if (clientId) {
return clientId;
}
return '';
}
function setButtonVariant(button, classesToRemove, classToAdd) {
if (!button) {
return;
}
if (button.classList) {
classesToRemove.forEach(function (className) {
button.classList.remove(className);
});
if (classToAdd) {
button.classList.add(classToAdd);
}
return;
}
var className = String(button.className || '');
classesToRemove.forEach(function (removeClass) {
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
});
if (classToAdd) {
className += ' ' + classToAdd;
}
button.className = className.replace(/\s+/g, ' ').trim();
}
function normalizeDisplayIp(value) {
var ip = String(value || '').trim();
if (!ip) {
return '';
}
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
return ip.slice(7).trim();
}
return ip;
}
var webUiHelpers = window.webUiHelpers || {};
var escapeHtml = webUiHelpers.escapeHtml;
var formatDashboardDate = webUiHelpers.formatDashboardDate;
var getClientRowKey = webUiHelpers.getClientRowKey;
var getClientDisplayName = webUiHelpers.getClientDisplayName;
var setButtonVariant = webUiHelpers.setButtonVariant;
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
function renderClientActionCell(client) {
var paused = Boolean(client.paused);
@@ -252,18 +180,50 @@
].join('');
}
function renderScreenRow(screen) {
function renderScreenTile(screen) {
var clientCount = Number(screen.player_connection_count || 0);
var playerUrl = String(screen.player_url || '').trim();
var connectionLabel = clientCount ? clientCount + ' live' : 'No clients';
var connectionStateClass = clientCount ? 'is-live' : 'is-idle';
var playlistLabel = screen.playlist_name ? escapeHtml(screen.playlist_name) : 'Unassigned';
var connectionsLabel = clientCount ? clientCount + ' connected' : 'No clients connected';
return [
'<tr>',
'<td data-label="Name">' + escapeHtml(screen.name) + '</td>',
'<td data-label="Player URL"><a href="' + escapeHtml(screen.player_url || '') + '" target="_blank">' + escapeHtml(screen.player_url || '') + '</a></td>',
'<td data-label="Playlist">' + escapeHtml(screen.playlist_name || '') + '</td>',
'<td data-label="Connected clients">' + (clientCount ? '<div class="connection-count" data-screen-connection-count="' + escapeHtml(screen.slug) + '">' + clientCount + ' connected</div>' : '<span class="empty">No clients connected.</span>') + '</td>',
'</tr>'
'<article class="dashboard-screen-tile" data-screen-key="' + escapeHtml(screen.id || '') + '">',
'<div class="dashboard-screen-tile-top">',
'<div class="dashboard-screen-tile-text">',
'<h4 class="dashboard-screen-name">' + escapeHtml(screen.name || '') + '</h4>',
'<a class="dashboard-screen-link" href="' + escapeHtml(playerUrl) + '" target="_blank">' + escapeHtml(playerUrl) + '</a>',
'</div>',
'<span class="dashboard-screen-pill ' + connectionStateClass + '">' + escapeHtml(connectionLabel) + '</span>',
'</div>',
'<dl class="dashboard-screen-meta">',
'<div><dt>Playlist</dt><dd>' + playlistLabel + '</dd></div>',
'<div><dt>Connections</dt><dd>' + escapeHtml(connectionsLabel) + '</dd></div>',
'</dl>',
'</article>'
].join('');
}
function compareScreensByConnectedClients(left, right) {
var leftCount = Number(left && left.player_connection_count || 0);
var rightCount = Number(right && right.player_connection_count || 0);
if (leftCount !== rightCount) {
return rightCount - leftCount;
}
var leftName = String(left && left.name || '').trim();
var rightName = String(right && right.name || '').trim();
var nameCompare = leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
if (nameCompare !== 0) {
return nameCompare;
}
return String(left && left.slug || '').localeCompare(String(right && right.slug || ''), undefined, { sensitivity: 'base', numeric: true });
}
function updateStats(state) {
var clientCount = document.getElementById('dashboard-client-count');
var screenCount = document.getElementById('dashboard-screen-count');
@@ -352,36 +312,52 @@
tbody.removeChild(tbody.lastElementChild);
}
window.applyTableSort(document.getElementById('dashboard-clients-table'));
}
function updateScreenTable(state) {
var table = document.getElementById('dashboard-screens-table');
if (!table || !Array.isArray(state.screens)) {
function updateScreenGrid(state) {
var grid = document.getElementById('dashboard-screens-grid');
if (!grid || !Array.isArray(state.screens)) {
return;
}
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
if (!tbody) {
var screens = state.screens.slice().sort(compareScreensByConnectedClients);
if (!screens.length) {
grid.innerHTML = '<div class="empty dashboard-screen-empty">No screens yet.</div>';
return;
}
if (!state.screens.length) {
tbody.innerHTML = '<tr><td colspan="4" class="empty">No screens yet.</td></tr>';
return;
}
tbody.innerHTML = state.screens.map(renderScreenRow).join('');
window.applyTableSort(table);
grid.innerHTML = screens.map(renderScreenTile).join('');
}
function updateDashboardQuickActions(state) {
var pauseButton = document.getElementById('dashboard-pause-all-button');
var blackoutButton = document.getElementById('dashboard-blackout-all-button');
if (!blackoutButton || !state || !Array.isArray(state.clients)) {
if ((!pauseButton && !blackoutButton) || !state || !Array.isArray(state.clients)) {
return;
}
var hasClients = state.clients.length > 0;
var allPaused = hasClients && state.clients.every(function (client) {
return Boolean(client && client.paused);
});
var allBlackout = hasClients && state.clients.every(function (client) {
return Boolean(client && client.blackout);
});
if (pauseButton) {
var pauseForm = pauseButton.form;
var pauseInput = pauseForm ? pauseForm.querySelector('input[name="paused"]') : null;
var pauseLabel = allPaused ? 'Resume all clients' : 'Pause all clients';
var pauseButtonIcon = allPaused ? 'bi-play-fill' : 'bi-pause-fill';
pauseButton.innerHTML = '<i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(pauseLabel);
setButtonVariant(pauseButton, ['btn-success', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
if (pauseInput) {
pauseInput.value = allPaused ? 'false' : 'true';
}
if (pauseForm) {
pauseForm.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients?' : 'Pause all connected clients?');
}
pauseButton.setAttribute('aria-label', pauseLabel);
}
var label = allBlackout ? 'Restore all clients' : 'Blackout all clients';
var blackoutForm = blackoutButton.form;
var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null;
@@ -403,7 +379,7 @@
return;
}
updateStats(state);
updateScreenTable(state);
updateScreenGrid(state);
updateClientTable(state);
updateDashboardQuickActions(state);
}
+579 -135
View File
@@ -1,6 +1,26 @@
(function () {
(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';
var PICKER_MEDIA_CLASS = 'playlist-slide-picker-media';
var PICKER_TITLE_CLASS = 'playlist-slide-picker-title';
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[]"]'
};
// Schedule helpers.
function formatDays(daysValue) {
var days = [];
if (Array.isArray(daysValue)) {
@@ -21,6 +41,192 @@
.join(', ');
}
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
};
}
// Video duration helpers.
function buildVideoDurationButtonMarkup(isPressed, enabled) {
if (!enabled) {
return '';
}
return '<button type="button" class="btn btn-outline-secondary btn-sm playlist-use-video-duration' + (isPressed ? ' active' : '') + '" data-use-video-duration-button aria-pressed="' + (isPressed ? 'true' : 'false') + '">' + (isPressed ? VIDEO_DURATION_ACTIVE_LABEL : VIDEO_DURATION_LABEL) + '</button>';
}
function syncVideoDurationButtonState(button, isPressed, label) {
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);
}
// Slide picker helpers.
function getSlidePickerEmptyMessage(query, showAssignedSlides, assignedCount, visibleCount) {
if (!showAssignedSlides && assignedCount && visibleCount === 0) {
return query ? 'No available slides match your search.' : 'No available slides are currently left for this playlist.';
}
return query ? 'No slides match your search.' : 'No slides are currently available for this playlist.';
}
function updateSlidePickerCardState(card, isAssigned, isCanvasMismatch) {
if (!card) {
return;
}
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) {
var element = document.createElement(tagName);
if (className) {
element.className = className;
}
if (textContent !== undefined) {
element.textContent = textContent;
}
return element;
}
function buildSlidePickerThumbPlaceholder() {
var placeholder = createElementWithClass('div', 'playlist-slide-picker-thumb-placeholder');
var icon = createElementWithClass('i', 'bi bi-image playlist-slide-picker-thumb-placeholder-icon');
var label = createElementWithClass('span', 'playlist-slide-picker-thumb-placeholder-label', PICKER_THUMB_EMPTY_LABEL);
icon.setAttribute('aria-hidden', 'true');
placeholder.appendChild(icon);
placeholder.appendChild(label);
return placeholder;
}
function buildSlidePickerThumbImage(slide) {
var image = createElementWithClass('img', 'playlist-slide-picker-thumb-image');
image.loading = 'lazy';
image.alt = String(slide.title || 'Slide');
image.src = String(slide.thumbnail_path || '');
image.addEventListener('error', function () {
if (image.parentNode) {
image.parentNode.replaceChild(buildSlidePickerThumbPlaceholder(), image);
}
});
return image;
}
function buildSlidePickerMedia(slide) {
var media = createElementWithClass('div', PICKER_MEDIA_CLASS);
var check = createElementWithClass('span', PICKER_CHECK_CLASS);
var badge = createElementWithClass('span', PICKER_BADGE_CLASS, PICKER_ASSIGNED_BADGE_LABEL);
check.setAttribute('aria-hidden', 'true');
badge.setAttribute('aria-hidden', 'true');
media.appendChild(slide && slide.thumbnail_path ? buildSlidePickerThumbImage(slide) : buildSlidePickerThumbPlaceholder());
media.appendChild(check);
media.appendChild(badge);
return media;
}
// Playlist row helpers.
function getPlaylistSlideRow(target) {
return target && target.closest ? target.closest('tr[data-playlist-slide-row]') : null;
}
function getPlaylistSlideRowKey(row) {
return String(row && row.getAttribute('data-row-key') || '');
}
function isDurationControl(target) {
return Boolean(target && target.closest && target.closest('.playlist-use-video-duration'));
}
function getDurationPointerState(target, type) {
var row = getPlaylistSlideRow(target);
var pointerType = type === 'button' ? 'button' : 'input';
if (!row) {
return null;
}
return {
type: pointerType,
rowKey: getPlaylistSlideRowKey(row)
};
}
function buildPlaylistOrderCellMarkup() {
return '' +
'<td class="playlist-order-cell" data-label="Order">' +
'<div class="playlist-order-cell-inner">' +
'<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"><span class="playlist-drag-handle-icon" aria-hidden="true"><svg class="playlist-drag-handle-svg" viewBox="0 0 24 32" focusable="false" aria-hidden="true"><polygon points="12,2 20,9 4,9"></polygon><rect x="4" y="14" width="16" height="4" rx="2"></rect><polygon points="4,23 20,23 12,30"></polygon></svg></span></button>' +
'<span class="playlist-order-number"></span>' +
'</div>' +
'</td>';
}
function buildPlaylistSlideCellMarkup(values, thumbnailStyle, thumbnailMarkup) {
return '' +
'<td data-label="Slide">' +
'<div class="playlist-slide-cell">' +
'<div class="playlist-slide-thumb" aria-hidden="true"' + thumbnailStyle + '>' + thumbnailMarkup + '</div>' +
'<div class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
'</div>' +
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" />' +
'</td>';
}
function buildPlaylistScheduleCellMarkup(values) {
return '' +
'<td>' +
'<div class="playlist-schedule-summary">' + values.summary + '</div>' +
'<input type="hidden" name="schedule_mode[]" value="' + values.schedule_mode + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_start_datetime[]" value="' + values.schedule_start_datetime + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_end_datetime[]" value="' + values.schedule_end_datetime + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_start_time[]" value="' + values.schedule_start_time + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" form="playlist-edit-form" />' +
'</td>';
}
function buildPlaylistDurationCellMarkup(values, durationActionMarkup) {
return '' +
'<td><div class="playlist-duration-field">' +
'<input name="duration_seconds[]" type="text" inputmode="decimal" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end"' + (values.useVideoDuration ? ' disabled' : '') + ' />' +
'<input type="hidden" name="use_video_duration[]" value="' + (values.useVideoDuration ? '1' : '0') + '" form="playlist-edit-form" />' +
(values.useVideoDuration ? '<input type="hidden" name="duration_seconds[]" value="' + values.duration_seconds + '" form="playlist-edit-form" data-video-duration-mirror />' : '') +
durationActionMarkup +
'</div></td>';
}
function buildPlaylistActionsCellMarkup(playlistId, rowKey) {
return '' +
'<td><div class="actions playlist-item-actions">' +
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
'</div></td>';
}
// Schedule modal wiring.
function initPlaylistScheduleModal() {
var dialog = document.getElementById('slide-schedule-dialog');
var content = document.getElementById('slide-schedule-content');
@@ -38,14 +244,9 @@
function collectScheduleParams(row, rowKey) {
var params = new URLSearchParams();
var scheduleFields = [
'schedule_mode[]',
'schedule_start_datetime[]',
'schedule_end_datetime[]',
'schedule_start_time[]',
'schedule_end_time[]',
'schedule_days_json[]'
];
var scheduleFields = Object.keys(SCHEDULE_ROW_FIELD_NAMES).map(function (key) {
return SCHEDULE_ROW_FIELD_NAMES[key].replace(/^\[name="|"\]$/g, '');
});
params.set('row_key', String(rowKey || ''));
scheduleFields.forEach(function (fieldName) {
@@ -113,6 +314,7 @@
}
// Schedule form behavior.
function scheduleSummary(values) {
var mode = String(values.schedule_mode || 'always');
if (mode === 'dates') {
@@ -214,10 +416,6 @@
});
select.addEventListener('change', clearScheduleValidity);
function notifyParentResize() {
return;
}
function updateVisibility() {
var mode = select.value;
if (datesPanel) {
@@ -234,9 +432,6 @@
endTimeInput.value = DEFAULT_END_TIME;
}
}
window.requestAnimationFrame(function () {
notifyParentResize();
});
}
select.addEventListener('change', updateVisibility);
@@ -302,12 +497,14 @@
});
}
// Playlist editor behavior.
function initPlaylistEditStaging() {
var tbody = document.getElementById('playlist-items-body');
var form = document.getElementById('playlist-edit-form');
var addSlideModal = document.getElementById('playlist-add-slide-modal');
var addSlideGrid = document.getElementById('playlist-slide-picker-grid');
var addSlideSearch = document.getElementById('playlist-slide-picker-search');
var addSlideShowAssigned = document.getElementById('playlist-slide-picker-show-assigned');
var addSlideEmpty = document.getElementById('playlist-slide-picker-empty');
var addSlideConfirm = document.getElementById('playlist-confirm-add-slides');
var addSlideCount = document.getElementById('playlist-slide-picker-selected-count');
@@ -316,6 +513,9 @@
var slidePickerCards = [];
var slidePickerSelection = new Set();
var slidePickerSlides = [];
var videoDurationCache = Object.create(null);
var lastDurationPointerDown = null;
var lastDurationPointerUp = null;
if (!tbody || !form) {
return;
@@ -330,13 +530,7 @@
}
}
function getModalInstance() {
if (!window.pulseModal) {
return null;
}
return window.pulseModal.getOrCreate(addSlideModal);
}
// Selection state helpers for the picker modal.
function setCardSelected(card, selected) {
if (!card) {
return;
@@ -369,65 +563,26 @@
}
}
function createPickerThumb(slide) {
function createPlaceholder() {
var placeholder = document.createElement('div');
var icon = document.createElement('i');
var label = document.createElement('span');
placeholder.className = 'playlist-slide-picker-thumb-placeholder';
icon.className = 'bi bi-image playlist-slide-picker-thumb-placeholder-icon';
icon.setAttribute('aria-hidden', 'true');
label.className = 'playlist-slide-picker-thumb-placeholder-label';
label.textContent = 'No thumbnail';
placeholder.appendChild(icon);
placeholder.appendChild(label);
return placeholder;
}
if (slide && slide.thumbnail_path) {
var image = document.createElement('img');
image.className = 'playlist-slide-picker-thumb-image';
image.loading = 'lazy';
image.alt = String(slide.title || 'Slide');
image.src = String(slide.thumbnail_path || '');
image.addEventListener('error', function () {
if (image.parentNode) {
image.parentNode.replaceChild(createPlaceholder(), image);
}
});
return image;
}
return createPlaceholder();
}
// Picker card rendering.
function createPickerCard(slide) {
var button = document.createElement('button');
var media = document.createElement('div');
var title = document.createElement('div');
var check = document.createElement('span');
var searchText = String(slide && slide.title ? slide.title : '').toLowerCase();
button.type = 'button';
button.className = 'playlist-slide-picker-card';
button.className = PICKER_CARD_CLASS;
button.setAttribute('aria-pressed', 'false');
button.setAttribute('data-slide-id', String(slide.id || ''));
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');
}
media.className = 'playlist-slide-picker-media';
media.appendChild(createPickerThumb(slide));
check.className = 'playlist-slide-picker-check bi bi-check2-circle';
check.setAttribute('aria-hidden', 'true');
media.appendChild(check);
title.className = 'playlist-slide-picker-title';
var media = buildSlidePickerMedia(slide);
var title = document.createElement('div');
title.className = PICKER_TITLE_CLASS;
title.textContent = String(slide && slide.title ? slide.title : 'Slide');
button.appendChild(media);
@@ -436,6 +591,17 @@
return button;
}
// Picker filtering and modal controls.
function setShowAssignedState(showAssignedSlides) {
if (!addSlideShowAssigned) {
return;
}
addSlideShowAssigned.setAttribute('aria-pressed', showAssignedSlides ? 'true' : 'false');
addSlideShowAssigned.classList.toggle('btn-secondary', showAssignedSlides);
addSlideShowAssigned.classList.toggle('btn-outline-secondary', !showAssignedSlides);
}
function renderSlidePicker() {
if (!addSlideGrid) {
return;
@@ -451,19 +617,27 @@
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;
slidePickerCards.forEach(function (card) {
var searchText = String(card.getAttribute('data-search-text') || '');
var matches = !query || searchText.indexOf(query) !== -1;
var isAssigned = card.getAttribute('data-is-assigned') === 'true';
var matchesSearch = !query || searchText.indexOf(query) !== -1;
var matchesAssignment = showAssignedSlides || !isAssigned;
var matches = matchesSearch && matchesAssignment;
setCardVisible(card, matches);
if (isAssigned) {
assignedCount += 1;
}
if (matches) {
visibleCount += 1;
}
});
if (addSlideEmpty) {
addSlideEmpty.textContent = query ? 'No slides match your search.' : 'No slides are currently available for this playlist.';
addSlideEmpty.textContent = getSlidePickerEmptyMessage(query, showAssignedSlides, assignedCount, visibleCount);
addSlideEmpty.classList.toggle('is-hidden', visibleCount !== 0);
}
@@ -474,6 +648,8 @@
if (addSlideConfirm) {
addSlideConfirm.disabled = slidePickerSelection.size === 0;
}
setShowAssignedState(showAssignedSlides);
}
function toggleCardSelection(card) {
@@ -512,7 +688,14 @@
row_key: 'new-' + Date.now() + '-' + slide.id,
slide_id: String(slide.id),
thumbnail_path: String(slide.thumbnail_path || ''),
canvas_width: slide.canvas_width,
canvas_height: slide.canvas_height,
canvas_signature: String(slide.canvasSignature || ''),
showVideoDurationButton: slide.showVideoDurationButton,
videoSourcePath: slide.videoSourcePath,
videoDurationSeconds: slide.videoDurationSeconds,
media_type: slide.media_type,
media_path: slide.media_path,
title: slide.title || 'Slide',
duration_seconds: 10,
schedule_mode: 'always',
@@ -573,24 +756,19 @@
}
function scheduleSummaryForRow(row) {
var modeInput = row.querySelector('[name="schedule_mode[]"]');
var startDatetime = row.querySelector('[name="schedule_start_datetime[]"]');
var endDatetime = row.querySelector('[name="schedule_end_datetime[]"]');
var startTime = row.querySelector('[name="schedule_start_time[]"]');
var endTime = row.querySelector('[name="schedule_end_time[]"]');
var daysJson = row.querySelector('[name="schedule_days_json[]"]');
var mode = String(modeInput ? modeInput.value : 'always');
var days = formatDays(daysJson && daysJson.value ? daysJson.value : '[]');
var fields = getScheduleRowFields(row);
var mode = String(fields.mode ? fields.mode.value : 'always');
var days = formatDays(fields.daysJson && fields.daysJson.value ? fields.daysJson.value : '[]');
if (mode === 'dates') {
if (startDatetime && endDatetime && startDatetime.value && endDatetime.value) {
return 'Dates: ' + startDatetime.value.replace('T', ' ') + ' to ' + endDatetime.value.replace('T', ' ');
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 (mode === 'times') {
if (days && startTime && endTime && startTime.value && endTime.value) {
return 'Times: ' + days + ' ' + startTime.value.slice(0, 5) + '-' + endTime.value.slice(0, 5);
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);
}
return 'Times: not set';
}
@@ -626,13 +804,9 @@
var isAssigned = Boolean(activeSlideIds[slideId]);
var isCanvasMismatch = Boolean(allowedCanvasSignature && canvasSignature && canvasSignature !== allowedCanvasSignature);
if (card) {
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');
}
slide.isAssigned = isAssigned;
updateSlidePickerCardState(card, isAssigned, isCanvasMismatch);
if (isAssigned) {
slidePickerSelection.delete(slideId);
if (card) {
@@ -700,31 +874,26 @@
}
function setRowSchedule(row, values) {
var modeInput = row.querySelector('[name="schedule_mode[]"]');
var startDatetime = row.querySelector('[name="schedule_start_datetime[]"]');
var endDatetime = row.querySelector('[name="schedule_end_datetime[]"]');
var startTime = row.querySelector('[name="schedule_start_time[]"]');
var endTime = row.querySelector('[name="schedule_end_time[]"]');
var daysJson = row.querySelector('[name="schedule_days_json[]"]');
var fields = getScheduleRowFields(row);
var summary = row.querySelector('.playlist-schedule-summary');
if (modeInput) {
modeInput.value = values.schedule_mode || 'always';
if (fields.mode) {
fields.mode.value = values.schedule_mode || 'always';
}
if (startDatetime) {
startDatetime.value = values.schedule_start_datetime || '';
if (fields.startDatetime) {
fields.startDatetime.value = values.schedule_start_datetime || '';
}
if (endDatetime) {
endDatetime.value = values.schedule_end_datetime || '';
if (fields.endDatetime) {
fields.endDatetime.value = values.schedule_end_datetime || '';
}
if (startTime) {
startTime.value = values.schedule_start_time || '';
if (fields.startTime) {
fields.startTime.value = values.schedule_start_time || '';
}
if (endTime) {
endTime.value = values.schedule_end_time || '';
if (fields.endTime) {
fields.endTime.value = values.schedule_end_time || '';
}
if (daysJson) {
daysJson.value = values.schedule_days_json || '[]';
if (fields.daysJson) {
fields.daysJson.value = values.schedule_days_json || '[]';
}
if (summary) {
summary.textContent = values.summary || scheduleSummaryForRow(row);
@@ -736,6 +905,12 @@
var row = document.createElement('tr');
var rowKey = values.row_key || ('new-' + Date.now() + '-' + Math.random().toString(36).slice(2));
var playlistId = tbody.getAttribute('data-playlist-id') || '';
var canvasWidth = Number(values.canvas_width);
var canvasHeight = Number(values.canvas_height);
var thumbnailStyle = '';
if (Number.isFinite(canvasWidth) && Number.isFinite(canvasHeight) && canvasWidth > 0 && canvasHeight > 0) {
thumbnailStyle = ' style="--playlist-slide-thumb-aspect-ratio: ' + canvasWidth + ' / ' + canvasHeight + ';"';
}
var thumbnailMarkup = values.thumbnail_path
? '<img class="playlist-slide-thumb-image" src="' + values.thumbnail_path + '" alt="" loading="lazy" />'
: '<span class="playlist-slide-thumb-placeholder"><i class="bi bi-image" aria-hidden="true"></i></span>';
@@ -743,40 +918,217 @@
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-media-type', String(values.media_type || ''));
row.setAttribute('data-media-path', String(values.media_path || ''));
row.setAttribute('data-video-source-path', String(values.videoSourcePath || values.media_path || ''));
row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || ''));
row.setAttribute('data-use-video-duration', String(Boolean(values.useVideoDuration)));
var durationActionMarkup = buildVideoDurationButtonMarkup(
Boolean(values.useVideoDuration),
Boolean(values.showVideoDurationButton) || String(values.media_type || '').trim() === 'video'
);
row.innerHTML = '' +
'<td class="playlist-order-cell" data-label="Order">' +
'<div class="playlist-order-cell-inner">' +
'<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"><span class="playlist-drag-handle-icon" aria-hidden="true"><svg class="playlist-drag-handle-svg" viewBox="0 0 24 32" focusable="false" aria-hidden="true"><polygon points="12,2 20,9 4,9"></polygon><rect x="4" y="14" width="16" height="4" rx="2"></rect><polygon points="4,23 20,23 12,30"></polygon></svg></span></button>' +
'<span class="playlist-order-number"></span>' +
'</div>' +
'</td>' +
'<td data-label="Slide">' +
'<div class="playlist-slide-cell">' +
'<div class="playlist-slide-thumb" aria-hidden="true">' + thumbnailMarkup + '</div>' +
'<div class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
'</div>' +
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" /></td>' +
'<td>' +
'<div class="playlist-schedule-summary">' + values.summary + '</div>' +
'<input type="hidden" name="schedule_mode[]" value="' + values.schedule_mode + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_start_datetime[]" value="' + values.schedule_start_datetime + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_end_datetime[]" value="' + values.schedule_end_datetime + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_start_time[]" value="' + values.schedule_start_time + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" form="playlist-edit-form" />' +
'</td>' +
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" /></td>' +
'<td><div class="actions playlist-item-actions">' +
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
'</div></td>';
buildPlaylistOrderCellMarkup() +
buildPlaylistSlideCellMarkup(values, thumbnailStyle, thumbnailMarkup) +
buildPlaylistScheduleCellMarkup(values) +
buildPlaylistDurationCellMarkup(values, durationActionMarkup) +
buildPlaylistActionsCellMarkup(playlistId, 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;
}
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;
if (row && input && isPressed && !row.getAttribute('data-video-duration-previous-value')) {
row.setAttribute('data-video-duration-previous-value', String(input.value || ''));
}
syncVideoDurationButtonState(button, isPressed);
if (input) {
if (!isPressed && row) {
var previousValue = String(row.getAttribute('data-video-duration-previous-value') || '').trim();
if (previousValue) {
input.value = previousValue;
}
row.removeAttribute('data-video-duration-previous-value');
}
input.disabled = Boolean(isPressed);
input.classList.toggle('playlist-duration-input-locked', Boolean(isPressed));
}
if (flag) {
flag.value = isPressed ? '1' : '0';
}
if (row) {
row.setAttribute('data-use-video-duration', isPressed ? 'true' : 'false');
}
if (!row || !input) {
return;
}
if (isPressed) {
if (!mirror) {
mirror = document.createElement('input');
mirror.type = 'hidden';
mirror.setAttribute('data-video-duration-mirror', '');
input.parentNode.insertBefore(mirror, input.nextSibling);
}
mirror.name = input.name;
mirror.value = input.value;
if (input.getAttribute('form')) {
mirror.setAttribute('form', input.getAttribute('form'));
}
} else if (mirror && mirror.parentNode) {
mirror.parentNode.removeChild(mirror);
}
}
async function applyVideoDurationToRow(row, button) {
var mediaType = String(row && row.getAttribute('data-media-type') || '').trim().toLowerCase();
var mediaPath = String(row && (row.getAttribute('data-video-source-path') || row.getAttribute('data-media-path')) || '').trim();
var input = row ? row.querySelector('[name="duration_seconds[]"]') : null;
var isVideoRow = mediaType === 'video' || Boolean(mediaPath);
var storedDuration = Number(row && row.getAttribute('data-video-duration-seconds') || 0);
if (!isVideoRow || !input) {
return;
}
if (button && button.getAttribute('aria-pressed') === 'true') {
setVideoDurationToggleState(row, button, false);
return;
}
setVideoDurationToggleState(row, button, true);
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;
}
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');
}
}
}
tbody.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 row = event.target.closest('tr[data-playlist-slide-row]');
var durationButton = event.target.closest('[data-use-video-duration]');
var row = getPlaylistSlideRow(event.target);
if (!row) {
return;
@@ -792,8 +1144,92 @@
if (scheduleButton) {
event.preventDefault();
if (typeof window.openScheduleModal === 'function') {
window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?row_key=' + encodeURIComponent(row.getAttribute('data-row-key') || ''));
window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?row_key=' + encodeURIComponent(getPlaylistSlideRowKey(row)));
}
return;
}
if (durationButton) {
var rowKey = getPlaylistSlideRowKey(row);
var pressedOnInput = lastDurationPointerDown && lastDurationPointerDown.type === 'input' && lastDurationPointerDown.rowKey === rowKey;
var releasedOnButton = lastDurationPointerUp && lastDurationPointerUp.type === 'button' && lastDurationPointerUp.rowKey === rowKey;
if (event.detail > 0 && (pressedOnInput || !releasedOnButton)) {
lastDurationPointerDown = null;
lastDurationPointerUp = null;
event.preventDefault();
return;
}
event.preventDefault();
void applyVideoDurationToRow(row, durationButton);
}
lastDurationPointerDown = null;
lastDurationPointerUp = null;
});
tbody.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;
if (row && !dragHandle) {
event.stopPropagation();
}
if (durationInput) {
lastDurationPointerDown = getDurationPointerState(event.target, 'input');
event.stopPropagation();
return;
}
if (durationToggle) {
lastDurationPointerDown = getDurationPointerState(durationToggle, 'button');
return;
}
lastDurationPointerDown = null;
});
tbody.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 row = getPlaylistSlideRow(event.target);
if (durationInput) {
lastDurationPointerUp = getDurationPointerState(row, 'input');
return;
}
if (durationToggle) {
lastDurationPointerUp = getDurationPointerState(row, 'button');
return;
}
lastDurationPointerUp = null;
});
tbody.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 editedMirror = editedRow ? editedRow.querySelector('[data-video-duration-mirror]') : null;
var useVideoDuration = Boolean(editedButton && editedButton.getAttribute('aria-pressed') === 'true');
if (editedRow) {
editedRow.setAttribute('data-use-video-duration', useVideoDuration ? 'true' : 'false');
}
if (useVideoDuration && editedMirror) {
editedMirror.value = event.target.value;
}
if (editedMirror && !useVideoDuration) {
editedMirror.parentNode.removeChild(editedMirror);
}
markPlaylistDirty();
}
});
@@ -839,6 +1275,14 @@
addSlideSearch.addEventListener('input', syncSlidePickerState);
}
if (addSlideShowAssigned) {
addSlideShowAssigned.addEventListener('click', function () {
var nextState = addSlideShowAssigned.getAttribute('aria-pressed') !== 'true';
setShowAssignedState(nextState);
syncSlidePickerState();
});
}
if (addSlideConfirm) {
addSlideConfirm.addEventListener('click', function () {
addSelectedSlides();
+8 -277
View File
@@ -1,285 +1,16 @@
(function () {
var REFRESH_INTERVAL_MS = 10000;
var pathname = window.location.pathname.replace(/\/$/, '');
var stateNode = document.getElementById('background-tasks-state');
var currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : '';
if (pathname !== '/settings/background-tasks') {
if (window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-background' && window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-scheduled') {
return;
}
function getPageUrl(href) {
try {
return new URL(href, window.location.href);
} catch (_error) {
return null;
}
}
function updateLocalDateTimes(root) {
if (typeof window.initLocalDateTimes === 'function') {
window.initLocalDateTimes(root || document);
}
}
function replaceSectionFromDocument(sectionName, responseDocument) {
var currentCard = document.querySelector('[data-pagination-card="' + sectionName + '"]');
var nextCard = responseDocument.querySelector('[data-pagination-card="' + sectionName + '"]');
if (!currentCard || !nextCard) {
return false;
}
currentCard.outerHTML = nextCard.outerHTML;
updateLocalDateTimes(document.querySelector('[data-pagination-card="' + sectionName + '"]'));
return true;
}
function replaceSummaryFromDocument(responseDocument) {
var currentSummary = document.getElementById('background-tasks-summary');
var nextSummary = responseDocument.getElementById('background-tasks-summary');
if (!currentSummary || !nextSummary) {
return false;
}
currentSummary.outerHTML = nextSummary.outerHTML;
return true;
}
function replaceFiltersFromDocument(responseDocument) {
var currentFilters = document.getElementById('background-tasks-filters');
var nextFilters = responseDocument.getElementById('background-tasks-filters');
if (!currentFilters || !nextFilters) {
return false;
}
currentFilters.outerHTML = nextFilters.outerHTML;
return true;
}
function replaceStateFromDocument(responseDocument) {
var currentState = document.getElementById('background-tasks-state');
var nextState = responseDocument.getElementById('background-tasks-state');
if (!currentState || !nextState) {
return false;
}
currentState.outerHTML = nextState.outerHTML;
stateNode = document.getElementById('background-tasks-state');
currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : currentVersion;
return true;
}
function applyPageResponse(responseDocument, sectionNames) {
var updated = false;
if (replaceStateFromDocument(responseDocument)) {
updated = true;
}
if (replaceSummaryFromDocument(responseDocument)) {
updated = true;
}
if (replaceFiltersFromDocument(responseDocument)) {
updated = true;
}
sectionNames.forEach(function (sectionName) {
if (replaceSectionFromDocument(sectionName, responseDocument)) {
updated = true;
}
});
return updated;
}
function loadPage(nextUrl, options) {
var url = getPageUrl(nextUrl);
var sectionNames = options && options.sectionNames ? options.sectionNames : [];
var replaceHistory = Boolean(options && options.replaceHistory);
if (!url) {
try {
var url = new URL(window.location.href);
if (!url.searchParams.has('message')) {
return;
}
fetch(url.toString(), {
method: 'GET',
headers: {
'Accept': 'text/html, application/xhtml+xml',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to load background tasks page.');
}
return response.text();
}).then(function (text) {
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
if (!applyPageResponse(responseDocument, sectionNames.length ? sectionNames : ['tasks', 'recurring'])) {
return;
}
if (replaceHistory) {
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
} else {
window.history.pushState({}, document.title, url.pathname + url.search + url.hash);
}
}).catch(function (_error) {
window.location.assign(url.pathname + url.search + url.hash);
});
url.searchParams.delete('message');
window.history.replaceState({}, document.title, url.pathname + (url.search ? url.search : '') + url.hash);
} catch (_error) {
// Ignore URL cleanup failures.
}
function stripMessageParameter() {
try {
var url = new URL(window.location.href);
if (!url.searchParams.has('message')) {
return;
}
url.searchParams.delete('message');
window.history.replaceState({}, document.title, url.pathname + (url.search ? url.search : '') + url.hash);
} catch (_error) {
// Ignore URL cleanup failures.
}
}
function findPaginationLink(target) {
if (!target || !target.closest) {
return null;
}
var link = target.closest('.pagination .page-link');
if (!link) {
return null;
}
var href = String(link.getAttribute('href') || '').trim();
if (!href || href === '#') {
return null;
}
var card = link.closest('[data-pagination-card]');
if (!card) {
return null;
}
return {
href: href,
sectionName: String(card.getAttribute('data-pagination-card') || '').trim()
};
}
function findFilterLink(target) {
if (!target || !target.closest) {
return null;
}
var link = target.closest('a[data-background-tasks-nav]');
if (!link) {
return null;
}
var href = String(link.getAttribute('href') || '').trim();
if (!href || href === '#') {
return null;
}
return {
href: href
};
}
function initPaginationNavigation() {
document.addEventListener('click', function (event) {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
var paginationLink = findPaginationLink(event.target);
if (paginationLink && paginationLink.sectionName) {
var currentUrl = getPageUrl(window.location.href);
var nextUrl = getPageUrl(paginationLink.href);
if (!currentUrl || !nextUrl) {
return;
}
if (currentUrl.pathname === nextUrl.pathname && currentUrl.search === nextUrl.search && currentUrl.hash === nextUrl.hash) {
event.preventDefault();
return;
}
event.preventDefault();
loadPage(nextUrl.toString(), { sectionNames: [paginationLink.sectionName] });
return;
}
var filterLink = findFilterLink(event.target);
if (!filterLink) {
return;
}
var filterUrl = getPageUrl(filterLink.href);
if (!filterUrl) {
return;
}
if (filterUrl.pathname === window.location.pathname && filterUrl.search === window.location.search && filterUrl.hash === window.location.hash) {
event.preventDefault();
return;
}
event.preventDefault();
loadPage(filterUrl.toString(), { sectionNames: ['tasks'] });
}, true);
window.addEventListener('popstate', function () {
loadPage(window.location.href, { replaceHistory: true });
});
}
function refreshPage() {
if (document.visibilityState !== 'visible') {
return;
}
fetch('/settings/background-tasks/state', {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
return null;
}
return response.json();
}).then(function (payload) {
if (!payload || !payload.version) {
return;
}
var nextVersion = String(payload.version || '');
if (!currentVersion) {
currentVersion = nextVersion;
return;
}
if (nextVersion !== currentVersion) {
window.location.reload();
}
}).catch(function (_error) {
// Ignore refresh probe errors and try again on the next interval.
});
}
stripMessageParameter();
initPaginationNavigation();
window.setInterval(refreshPage, REFRESH_INTERVAL_MS);
}());
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,437 @@
(function () {
var templateFields = document.getElementById('template-fields');
var modal = document.getElementById('slide-image-cropper-modal');
var image = document.getElementById('slide-image-cropper-image');
var status = document.getElementById('slide-image-cropper-status');
if (!templateFields || !modal || !image || !status) {
return;
}
var cropper = null;
var currentInput = null;
var currentFile = null;
var currentObjectUrl = '';
var flipX = 1;
var flipY = 1;
var currentAspectRatio = NaN;
var currentRegionAspectRatio = NaN;
var currentRegionAspectRatioLabel = 'Region';
var listenersAttached = false;
var uploadMaxBytes = 100 * 1024 * 1024;
var uploadMaxLabel = '100 MB';
function getRegionId(input) {
var match = String(input && input.name || '').match(/^region_image_(\d+)$/);
return match ? match[1] : '';
}
function getExistingInput(regionId) {
if (!regionId) {
return null;
}
return document.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
}
function setStatus(message) {
status.textContent = String(message || '');
}
function showModal() {
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(modal);
return;
}
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(modal).show();
}
}
function hideModal() {
if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
window.pulseModal.hide(modal);
return;
}
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(modal).hide();
}
}
function destroyCropper() {
if (cropper) {
cropper.destroy();
cropper = null;
}
}
function revokeObjectUrl() {
if (currentObjectUrl) {
URL.revokeObjectURL(currentObjectUrl);
currentObjectUrl = '';
}
}
function resetModalState() {
destroyCropper();
revokeObjectUrl();
currentInput = null;
currentFile = null;
flipX = 1;
flipY = 1;
image.removeAttribute('src');
image.alt = 'Selected image to crop';
setStatus('');
}
function setButtonState(disabled) {
modal.querySelectorAll('[data-slide-image-cropper-action]').forEach(function (button) {
button.disabled = Boolean(disabled) && button.getAttribute('data-slide-image-cropper-action') !== 'reset';
});
}
function ratioLabelForValue(value) {
if (value === 'free') {
return 'free';
}
if (value === 'region') {
return 'region';
}
return String(value || '').trim();
}
function parseAspectRatio(value) {
var parts = String(value || '').trim().split(/[:/]/);
var width = Number(parts[0]);
var height = Number(parts[1]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return NaN;
}
return width / height;
}
function setActiveRatioButton(value) {
var targetValue = ratioLabelForValue(value);
modal.querySelectorAll('[data-slide-image-cropper-action="ratio"]').forEach(function (button) {
var buttonValue = ratioLabelForValue(button.getAttribute('data-slide-image-cropper-ratio'));
var isActive = buttonValue === targetValue;
button.classList.toggle('active', isActive);
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
});
}
function applyAspectRatio(value) {
if (!cropper) {
return;
}
currentAspectRatio = value;
if (value === 'free') {
cropper.setAspectRatio(NaN);
setActiveRatioButton('free');
return;
}
if (value === 'region') {
if (!Number.isFinite(currentRegionAspectRatio) || currentRegionAspectRatio <= 0) {
cropper.setAspectRatio(NaN);
setActiveRatioButton('free');
return;
}
cropper.setAspectRatio(currentRegionAspectRatio);
setActiveRatioButton('region');
return;
}
var parts = String(value || '').split(':');
var width = Number(parts[0]);
var height = Number(parts[1]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return;
}
cropper.setAspectRatio(width / height);
setActiveRatioButton(value);
}
function initCropper() {
if (!currentInput || !currentFile || !window.Cropper) {
return;
}
destroyCropper();
cropper = new window.Cropper(image, {
aspectRatio: NaN,
autoCropArea: 1,
background: false,
dragMode: 'move',
initialAspectRatio: NaN,
movable: true,
responsive: true,
rotatable: true,
scalable: true,
viewMode: 1,
zoomOnTouch: true,
zoomOnWheel: true,
ready: function () {
if (cropper && cropper.container) {
cropper.container.style.width = '100%';
cropper.container.style.height = '560px';
cropper.container.style.maxHeight = '70vh';
}
var containerData = cropper.getContainerData();
var imageData = cropper.getImageData();
var fitRatio = Math.min(
Number(containerData.width || 0) / Number(imageData.naturalWidth || 1),
Number(containerData.height || 0) / Number(imageData.naturalHeight || 1),
1
);
if (Number.isFinite(fitRatio) && fitRatio > 0) {
cropper.zoomTo(fitRatio);
} else {
cropper.reset();
}
applyAspectRatio(currentAspectRatio === undefined ? 'free' : currentAspectRatio);
}
});
setButtonState(false);
setActiveRatioButton(currentAspectRatio === undefined ? 'free' : currentAspectRatio);
setStatus('Use the toolbar to crop, rotate, or flip the image before applying it.');
}
function openEditor(input, file) {
currentInput = input;
currentFile = file;
flipX = 1;
flipY = 1;
currentAspectRatio = 'free';
currentRegionAspectRatio = parseAspectRatio(input && input.dataset && input.dataset.slideImageCropperRegionRatio);
currentRegionAspectRatioLabel = String(input && input.dataset && input.dataset.slideImageCropperRegionRatioLabel || 'Region').trim() || 'Region';
setButtonState(true);
setStatus('Loading image editor...');
modal.querySelectorAll('[data-slide-image-cropper-action="ratio"][data-slide-image-cropper-ratio="region"]').forEach(function (button) {
button.title = currentRegionAspectRatioLabel ? 'Region ratio ' + currentRegionAspectRatioLabel : 'Region ratio';
});
revokeObjectUrl();
currentObjectUrl = URL.createObjectURL(file);
image.alt = file.name || 'Selected image to crop';
image.src = currentObjectUrl;
if (!listenersAttached) {
listenersAttached = true;
modal.addEventListener('shown.bs.modal', function () {
if (currentInput && currentFile) {
initCropper();
}
});
modal.addEventListener('hidden.bs.modal', function () {
resetModalState();
});
}
showModal();
}
function setInputFile(input, file) {
var dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
input.files = dataTransfer.files;
}
function rejectOversizeFile(input) {
if (!input) {
return;
}
if (typeof window.showToast === 'function') {
window.showToast('File must be ' + uploadMaxLabel + ' or smaller.', 'warning');
} else {
window.alert('File must be ' + uploadMaxLabel + ' or smaller.');
}
input.value = '';
}
function finalizeCropFile(file) {
if (!currentInput) {
return;
}
var input = currentInput;
var existing = getExistingInput(getRegionId(input));
input.dataset.cropperBypass = '1';
setInputFile(input, file);
if (existing) {
existing.value = '';
}
input.dispatchEvent(new Event('change', { bubbles: true }));
window.setTimeout(function () {
delete input.dataset.cropperBypass;
}, 0);
hideModal();
}
function commitCrop() {
if (!currentInput || !currentFile) {
return;
}
if (!cropper) {
finalizeCropFile(currentFile);
return;
}
setButtonState(true);
setStatus('Creating the cropped image...');
var canvas = cropper.getCroppedCanvas({
fillColor: 'transparent',
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high'
});
if (!canvas) {
setStatus('Unable to create the cropped image.');
setButtonState(false);
return;
}
var outputType = /^image\/(jpeg|jpg|webp|png)$/i.test(currentFile.type || '') ? currentFile.type : 'image/png';
canvas.toBlob(function (blob) {
if (!blob) {
setStatus('Unable to create the cropped image.');
setButtonState(false);
return;
}
var finalFile = new File([blob], currentFile.name || 'slide-region-image.png', {
lastModified: Date.now(),
type: blob.type || outputType
});
finalizeCropFile(finalFile);
}, outputType, outputType.indexOf('jpeg') !== -1 ? 0.92 : undefined);
}
function rotateImage(direction) {
if (cropper) {
cropper.rotate(direction);
}
}
function flipImage(axis) {
if (!cropper) {
return;
}
if (axis === 'x') {
flipX *= -1;
cropper.scaleX(flipX);
return;
}
flipY *= -1;
cropper.scaleY(flipY);
}
function resetImage() {
if (!cropper) {
return;
}
cropper.reset();
flipX = 1;
flipY = 1;
}
templateFields.addEventListener('change', function (event) {
var input = event.target;
if (!input || !input.matches || !input.matches('input[type="file"][name^="region_image_"]')) {
return;
}
if (input.dataset.cropperBypass === '1') {
return;
}
var file = input.files && input.files[0];
if (!file) {
return;
}
if (Number(file.size || 0) > uploadMaxBytes) {
event.stopImmediatePropagation();
event.stopPropagation();
rejectOversizeFile(input);
return;
}
if (!/^image\//i.test(file.type || '')) {
input.value = '';
return;
}
event.stopImmediatePropagation();
event.stopPropagation();
input.value = '';
if (!window.Cropper) {
currentInput = input;
currentFile = file;
commitCrop();
return;
}
openEditor(input, file);
}, true);
modal.addEventListener('click', function (event) {
var button = event.target && event.target.closest ? event.target.closest('[data-slide-image-cropper-action]') : null;
if (!button) {
return;
}
var action = button.getAttribute('data-slide-image-cropper-action');
if (action === 'apply') {
commitCrop();
return;
}
if (action === 'ratio') {
applyAspectRatio(button.getAttribute('data-slide-image-cropper-ratio') || 'free');
return;
}
if (action === 'rotate-left') {
rotateImage(-90);
return;
}
if (action === 'rotate-right') {
rotateImage(90);
return;
}
if (action === 'flip-horizontal') {
flipImage('x');
return;
}
if (action === 'flip-vertical') {
flipImage('y');
return;
}
if (action === 'reset') {
resetImage();
return;
}
});
}());
-238
View File
@@ -1,238 +0,0 @@
(function () {
function getTableHeaderText(headerCell) {
return String(headerCell && headerCell.textContent ? headerCell.textContent : '').trim().toLowerCase();
}
function cellHasButtonContent(cell) {
return Boolean(cell && cell.querySelector && cell.querySelector('button, form, input, select, textarea'));
}
function getCellSortValue(cell) {
if (!cell) {
return '';
}
var sortValue = cell.getAttribute && cell.getAttribute('data-sort-value');
if (sortValue !== null && sortValue !== undefined && sortValue !== '') {
return String(sortValue).trim();
}
return String(cell.textContent || '').replace(/\s+/g, ' ').trim();
}
function getComparableSortValue(rawValue) {
var value = String(rawValue || '').trim();
if (!value) {
return { type: 'empty', value: '' };
}
var numericValue = Number(value.replace(/,/g, ''));
if (!Number.isNaN(numericValue) && value !== '') {
return { type: 'number', value: numericValue };
}
var dateValue = Date.parse(value);
if (!Number.isNaN(dateValue)) {
return { type: 'date', value: dateValue };
}
return { type: 'string', value: value.toLowerCase() };
}
function compareSortValues(leftValue, rightValue) {
if (leftValue.type === 'empty' && rightValue.type === 'empty') {
return 0;
}
if (leftValue.type === 'empty') {
return 1;
}
if (rightValue.type === 'empty') {
return -1;
}
if (leftValue.type === rightValue.type) {
if (leftValue.value < rightValue.value) {
return -1;
}
if (leftValue.value > rightValue.value) {
return 1;
}
return 0;
}
return String(leftValue.value).localeCompare(String(rightValue.value), undefined, { numeric: true, sensitivity: 'base' });
}
function getSortableTableState(table) {
if (!table._sortableState) {
table._sortableState = {
columnIndex: null,
direction: 'asc'
};
}
return table._sortableState;
}
function ensureSortableHeaderIndicator(headerCell) {
var indicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
if (indicator) {
return indicator;
}
indicator = document.createElement('i');
indicator.className = 'table-sort-indicator bi bi-arrow-down-up ms-1';
indicator.setAttribute('aria-hidden', 'true');
headerCell.appendChild(indicator);
return indicator;
}
function updateSortableHeaderIndicator(headerCell, isSortable, isActive, direction) {
if (!isSortable) {
var hiddenIndicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
if (hiddenIndicator) {
hiddenIndicator.style.display = 'none';
}
return;
}
var indicator = ensureSortableHeaderIndicator(headerCell);
indicator.style.display = '';
indicator.className = 'table-sort-indicator bi ms-1';
if (isActive && direction === 'desc') {
indicator.classList.add('bi-caret-down-fill');
return;
}
if (isActive && direction === 'asc') {
indicator.classList.add('bi-caret-up-fill');
return;
}
indicator.classList.add('bi-arrow-down-up');
}
function isSortableTableColumn(table, columnIndex) {
var headerCell = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0].cells[columnIndex] : null;
if (!headerCell) {
return false;
}
if (/actions?|buttons?/i.test(getTableHeaderText(headerCell))) {
return false;
}
var bodies = table.tBodies ? Array.prototype.slice.call(table.tBodies) : [];
for (var i = 0; i < bodies.length; i += 1) {
var rows = Array.prototype.slice.call(bodies[i].rows || []);
for (var j = 0; j < rows.length; j += 1) {
var cell = rows[j].cells ? rows[j].cells[columnIndex] : null;
if (cell && cellHasButtonContent(cell)) {
return false;
}
}
}
return true;
}
function updateSortableHeaderState(table) {
var state = getSortableTableState(table);
var headerCells = table.tHead && table.tHead.rows && table.tHead.rows.length ? Array.prototype.slice.call(table.tHead.rows[0].cells || []) : [];
headerCells.forEach(function (headerCell, index) {
if (!headerCell) {
return;
}
var sortable = isSortableTableColumn(table, index);
headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable');
headerCell.removeAttribute('aria-sort');
headerCell.removeAttribute('role');
headerCell.removeAttribute('tabindex');
if (sortable) {
headerCell.classList.add('sortable');
updateSortableHeaderIndicator(headerCell, true, state.columnIndex === index, state.direction);
headerCell.setAttribute('role', 'button');
headerCell.setAttribute('tabindex', '0');
if (state.columnIndex === index) {
headerCell.classList.add(state.direction === 'desc' ? 'sort-desc' : 'sort-asc');
headerCell.setAttribute('aria-sort', state.direction === 'desc' ? 'descending' : 'ascending');
} else {
headerCell.setAttribute('aria-sort', 'none');
}
} else {
headerCell.classList.add('unsortable');
updateSortableHeaderIndicator(headerCell, false);
}
});
}
function sortTable(table, columnIndex, direction) {
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
if (!tbody) {
return;
}
var rows = Array.prototype.slice.call(tbody.rows || []);
if (!rows.length) {
return;
}
var multiplier = direction === 'desc' ? -1 : 1;
rows.sort(function (leftRow, rightRow) {
var leftCell = leftRow.cells ? leftRow.cells[columnIndex] : null;
var rightCell = rightRow.cells ? rightRow.cells[columnIndex] : null;
var leftComparable = getComparableSortValue(getCellSortValue(leftCell));
var rightComparable = getComparableSortValue(getCellSortValue(rightCell));
return compareSortValues(leftComparable, rightComparable) * multiplier;
});
rows.forEach(function (row) {
tbody.appendChild(row);
});
}
function applyTableSort(table) {
var state = getSortableTableState(table);
if (state.columnIndex === null || state.columnIndex === undefined) {
return;
}
sortTable(table, state.columnIndex, state.direction);
updateSortableHeaderState(table);
}
function initSortableTables() {
var tables = Array.prototype.slice.call(document.querySelectorAll('table'));
tables.forEach(function (table) {
var headerRow = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0] : null;
if (!headerRow) {
return;
}
Array.prototype.forEach.call(headerRow.cells, function (headerCell, index) {
if (!isSortableTableColumn(table, index)) {
return;
}
ensureSortableHeaderIndicator(headerCell);
headerCell.addEventListener('click', function () {
var state = getSortableTableState(table);
var nextDirection = state.columnIndex === index && state.direction === 'asc' ? 'desc' : 'asc';
state.columnIndex = index;
state.direction = nextDirection;
sortTable(table, index, nextDirection);
updateSortableHeaderState(table);
});
headerCell.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
headerCell.click();
}
});
});
updateSortableHeaderState(table);
});
}
window.applyTableSort = applyTableSort;
window.initSortableTables = initSortableTables;
}());
+170
View File
@@ -0,0 +1,170 @@
(function () {
function rebindTableContainer(container) {
if (!container) {
return;
}
if (typeof window.initSortableTables === 'function') {
window.initSortableTables(container);
}
if (typeof window.initTableSearches === 'function') {
window.initTableSearches(container);
}
if (typeof window.initLocalDateTimes === 'function') {
window.initLocalDateTimes(container);
}
}
function focusSearchInput(input) {
if (!input || !input.focus) {
return;
}
input.focus();
if (typeof input.setSelectionRange === 'function') {
var valueLength = String(input.value || '').length;
try {
input.setSelectionRange(valueLength, valueLength);
} catch (_error) {
// Ignore selection failures on unsupported input types.
}
}
}
function replaceTableResults(container, responseDocument) {
if (!container || !responseDocument || !responseDocument.querySelector) {
return false;
}
var currentTable = container.querySelector('[data-table-searchable]');
var nextContainer = responseDocument.querySelector('[data-table-search-container]') || responseDocument.querySelector('.card');
var nextTable = nextContainer ? nextContainer.querySelector('[data-table-searchable]') : null;
var currentFooter = container.querySelector('.card-footer');
var nextFooter = nextContainer ? nextContainer.querySelector('.card-footer') : null;
if (!currentTable || !nextTable) {
return false;
}
var currentBody = currentTable.tBodies && currentTable.tBodies[0] ? currentTable.tBodies[0] : null;
var nextBody = nextTable.tBodies && nextTable.tBodies[0] ? nextTable.tBodies[0] : null;
if (!currentBody || !nextBody) {
return false;
}
currentBody.outerHTML = nextBody.outerHTML;
if (currentFooter && nextFooter) {
currentFooter.outerHTML = nextFooter.outerHTML;
} else if (currentFooter && !nextFooter) {
currentFooter.parentNode.removeChild(currentFooter);
} else if (!currentFooter && nextFooter) {
currentTable.parentNode.appendChild(nextFooter.cloneNode(true));
}
return true;
}
function initTableSearches(root) {
var scope = root && root.querySelectorAll ? root : document;
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-search]'), function (input) {
if (input.getAttribute('data-table-search-bound') === 'true') {
return;
}
input.setAttribute('data-table-search-bound', 'true');
var searchParam = String(input.getAttribute('data-table-search-param') || 'search').trim() || 'search';
var currentUrl = new URL(window.location.href);
var pendingSearchTimer = null;
var requestSequence = 0;
var container = input.closest('[data-table-search-container]') || input.closest('.card') || null;
input.value = String(currentUrl.searchParams.get(searchParam) || '').trim();
function updateSearch() {
var query = String(input.value || '').trim();
var nextUrl = new URL(window.location.href);
if (query) {
nextUrl.searchParams.set(searchParam, query);
} else {
nextUrl.searchParams.delete(searchParam);
}
nextUrl.searchParams.delete('page');
if (nextUrl.toString() === currentUrl.toString()) {
return;
}
requestSequence += 1;
var sequenceId = requestSequence;
fetch(nextUrl.toString(), {
method: 'GET',
headers: {
'Accept': 'text/html, application/xhtml+xml',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to load search results.');
}
return response.text();
}).then(function (text) {
if (sequenceId !== requestSequence) {
return;
}
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, nextUrl.pathname + nextUrl.search + nextUrl.hash);
}
if (replaceTableResults(container, responseDocument)) {
currentUrl = nextUrl;
rebindTableContainer(container || document);
focusSearchInput(input);
}
}).catch(function () {
window.location.assign(nextUrl.toString());
});
}
function scheduleSearchUpdate() {
if (pendingSearchTimer) {
window.clearTimeout(pendingSearchTimer);
}
pendingSearchTimer = window.setTimeout(function () {
pendingSearchTimer = null;
updateSearch();
}, 300);
}
input.addEventListener('input', scheduleSearchUpdate);
input.addEventListener('search', scheduleSearchUpdate);
input.addEventListener('change', updateSearch);
input.addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
event.preventDefault();
if (pendingSearchTimer) {
window.clearTimeout(pendingSearchTimer);
pendingSearchTimer = null;
}
updateSearch();
}
});
});
}
window.initTableSearches = initTableSearches;
window.replaceTableResults = replaceTableResults;
}());
+197
View File
@@ -0,0 +1,197 @@
(function () {
function getTableSortState(table) {
if (!table._tableSortState) {
table._tableSortState = {
sortKey: '',
direction: 'asc'
};
}
return table._tableSortState;
}
function ensureSortableHeaderIndicator(headerCell) {
var indicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
if (indicator) {
return indicator;
}
indicator = document.createElement('i');
indicator.className = 'table-sort-indicator bi bi-arrow-down-up ms-1';
indicator.setAttribute('aria-hidden', 'true');
headerCell.appendChild(indicator);
return indicator;
}
function updateSortableHeaderState(table) {
var state = getTableSortState(table);
var currentUrl = new URL(window.location.href);
var activeSortKey = String(currentUrl.searchParams.get('sort') || '').trim();
var activeDirection = String(currentUrl.searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
var headerCells = table.tHead && table.tHead.rows && table.tHead.rows.length ? Array.prototype.slice.call(table.tHead.rows[0].cells || []) : [];
state.sortKey = activeSortKey;
state.direction = activeDirection;
headerCells.forEach(function (headerCell) {
if (!headerCell) {
return;
}
var sortKey = String(headerCell.getAttribute && headerCell.getAttribute('data-table-sort-key') || '').trim();
var sortable = Boolean(sortKey) && String(headerCell.getAttribute('data-sortable') || '').toLowerCase() !== 'false';
headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable');
headerCell.removeAttribute('aria-sort');
if (!sortable) {
headerCell.classList.add('unsortable');
var hiddenIndicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
if (hiddenIndicator) {
hiddenIndicator.style.display = 'none';
}
return;
}
var indicator = ensureSortableHeaderIndicator(headerCell);
indicator.style.display = '';
indicator.className = 'table-sort-indicator bi ms-1';
headerCell.classList.add('sortable');
headerCell.setAttribute('role', 'button');
headerCell.setAttribute('tabindex', '0');
if (sortKey === activeSortKey) {
headerCell.classList.add(activeDirection === 'desc' ? 'sort-desc' : 'sort-asc');
headerCell.setAttribute('aria-sort', activeDirection === 'desc' ? 'descending' : 'ascending');
if (activeDirection === 'desc') {
indicator.classList.add('bi-caret-down-fill');
} else {
indicator.classList.add('bi-caret-up-fill');
}
return;
}
headerCell.setAttribute('aria-sort', 'none');
indicator.classList.add('bi-arrow-down-up');
});
}
function buildSortedUrl(sortKey) {
var currentUrl = new URL(window.location.href);
var nextUrl = new URL(window.location.href);
var currentSortKey = String(currentUrl.searchParams.get('sort') || '').trim();
var currentDirection = String(currentUrl.searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
if (currentSortKey === sortKey) {
nextUrl.searchParams.set('direction', currentDirection === 'asc' ? 'desc' : 'asc');
} else {
nextUrl.searchParams.set('sort', sortKey);
nextUrl.searchParams.set('direction', 'asc');
}
nextUrl.searchParams.delete('page');
return nextUrl;
}
function updateUrlState(nextUrl) {
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, nextUrl.pathname + nextUrl.search + nextUrl.hash);
}
}
function refreshSortedTable(table, nextUrl, requestSequence, sequenceId) {
var container = table ? (table.closest('[data-table-search-container]') || table.closest('.card') || null) : null;
fetch(nextUrl.toString(), {
method: 'GET',
headers: {
'Accept': 'text/html, application/xhtml+xml',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to load sorted results.');
}
return response.text();
}).then(function (text) {
if (sequenceId !== requestSequence.value) {
return;
}
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
updateUrlState(nextUrl);
if (window.replaceTableResults && replaceTableResults(container, responseDocument)) {
if (typeof window.initSortableTables === 'function') {
window.initSortableTables(container || document);
}
if (typeof window.initTableSearches === 'function') {
window.initTableSearches(container || document);
}
if (typeof window.initLocalDateTimes === 'function') {
window.initLocalDateTimes(container || document);
}
}
}).catch(function () {
window.location.assign(nextUrl.toString());
});
}
function initSortableTables(root) {
var scope = root && root.querySelectorAll ? root : document;
Array.prototype.forEach.call(scope.querySelectorAll('table[data-table-searchable]'), function (table) {
var headerRow = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0] : null;
if (!headerRow) {
return;
}
if (table.getAttribute('data-table-sort-bound') === 'true') {
updateSortableHeaderState(table);
return;
}
table.setAttribute('data-table-sort-bound', 'true');
var requestSequence = { value: 0 };
Array.prototype.forEach.call(headerRow.cells, function (headerCell) {
if (!headerCell) {
return;
}
var sortKey = String(headerCell.getAttribute && headerCell.getAttribute('data-table-sort-key') || '').trim();
var isSortable = Boolean(sortKey) && String(headerCell.getAttribute('data-sortable') || '').toLowerCase() !== 'false';
if (!isSortable) {
return;
}
if (headerCell.getAttribute('data-table-sort-bound') === 'true') {
return;
}
headerCell.setAttribute('data-table-sort-bound', 'true');
ensureSortableHeaderIndicator(headerCell);
headerCell.addEventListener('click', function () {
var nextUrl = buildSortedUrl(sortKey);
requestSequence.value += 1;
var sequenceId = requestSequence.value;
refreshSortedTable(table, nextUrl, requestSequence, sequenceId);
});
headerCell.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
headerCell.click();
}
});
});
updateSortableHeaderState(table);
});
}
window.initSortableTables = initSortableTables;
initSortableTables();
}());
-606
View File
@@ -1,606 +0,0 @@
(function () {
var dataElement = document.getElementById('template-editor-data');
if (!dataElement) {
return;
}
var utils = window.templateDesignerUtils || {};
var templateData = {};
try {
templateData = JSON.parse(dataElement.getAttribute('data-json') || dataElement.textContent || '{}') || {};
} catch (_error) {
templateData = {};
}
var existingRegions = Array.isArray(templateData.regions)
? templateData.regions
: (Array.isArray(templateData) ? templateData : []);
var stage = document.getElementById('designer-stage');
var overlay = document.getElementById('designer-overlay');
var regionList = document.getElementById('region-list');
var regionSelect = document.getElementById('region-select');
var canvasSizeSelect = document.getElementById('canvas-size-select');
var canvasSizeIdInput = document.getElementById('canvas-size-id');
var canvasSizeSummary = document.getElementById('canvas-size-summary');
var canvasWidthInput = document.getElementById('canvas-width');
var canvasHeightInput = document.getElementById('canvas-height');
var backgroundInput = document.getElementById('background-image');
var backgroundColorInput = document.getElementById('background-color');
var backgroundPreview = document.getElementById('background-preview');
var backgroundEmpty = document.getElementById('background-empty');
var removeBackgroundButton = document.getElementById('remove-background-image');
var removeBackgroundFlag = document.getElementById('remove-background-image-flag');
var addRegionButton = document.getElementById('add-region-button');
var regionAddModal = document.getElementById('region-add-modal');
var regionCardTemplate = document.getElementById('region-card-template');
var regionsJsonInput = document.getElementById('regions-json');
var templateForm = document.getElementById('template-form');
var draft = null;
var selectedIndex = -1;
var overlayRenderFrame = 0;
function escapeHtml(value) {
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
}
function valueOrDefault(value, fallback) {
return utils.valueOrDefault ? utils.valueOrDefault(value, fallback) : (value === undefined || value === null || value === '' ? fallback : value);
}
function getCanvasSize() {
return {
width: Math.max(1, Number(canvasWidthInput.value || 1920)),
height: Math.max(1, Number(canvasHeightInput.value || 1080))
};
}
function clamp(value, min, max) {
return utils.clamp ? utils.clamp(value, min, max) : Math.max(min, Math.min(max, value));
}
function getCards() {
return Array.prototype.slice.call(regionList.querySelectorAll('.region-item'));
}
function cardAt(index) {
return getCards()[index] || null;
}
function getRegionName(card) {
return utils.getRegionName ? utils.getRegionName(card) : String(card.querySelector('[name="region_name[]"]').value || '').trim();
}
function syncRegionIdentity(card, value) {
if (utils.syncRegionIdentity) {
utils.syncRegionIdentity(card, value);
return;
}
var next = String(value || '').trim();
card.querySelector('[name="region_name[]"]').value = next;
card.querySelector('[name="region_key[]"]').value = next;
card.querySelector('[name="region_label[]"]').value = next;
}
function validateRegionNames() {
var cards = getCards();
var names = {};
var hasDuplicate = false;
cards.forEach(function (card) {
var input = card.querySelector('[name="region_name[]"]');
if (!input) {
return;
}
var normalized = String(input.value || '').trim().toLowerCase();
if (!normalized) {
input.setCustomValidity('Region name is required.');
return;
}
if (!names[normalized]) {
names[normalized] = [];
}
names[normalized].push(input);
});
Object.keys(names).forEach(function (key) {
var inputs = names[key];
if (inputs.length > 1) {
hasDuplicate = true;
inputs.forEach(function (input) {
input.setCustomValidity('Region names must be unique on this template.');
});
} else {
inputs[0].setCustomValidity('');
}
});
return !hasDuplicate;
}
function readCard(card) {
return utils.readCard ? utils.readCard(card) : {
region_key: getRegionName(card),
label: getRegionName(card),
region_type: card.querySelector('[name="region_type[]"]').value,
font_family: card.querySelector('[name="font_family[]"]').value,
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
height: Number(card.querySelector('[name="region_height[]"]').value || 0),
z_index: Number(card.querySelector('[name="region_z[]"]').value || 0)
};
}
function writeCard(card, values) {
if (utils.writeCard) {
utils.writeCard(card, values);
return;
}
if (values.region_name !== undefined) {
syncRegionIdentity(card, values.region_name);
} else if (values.region_key !== undefined) {
syncRegionIdentity(card, values.region_key);
} else if (values.label !== undefined) {
syncRegionIdentity(card, values.label);
}
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
if (values.height !== undefined) { card.querySelector('[name="region_height[]"]').value = Math.round(values.height); }
if (values.z_index !== undefined) { card.querySelector('[name="region_z[]"]').value = Math.round(values.z_index); }
}
function getOverlayRect() {
return utils.getOverlayRect ? utils.getOverlayRect(overlay) : overlay.getBoundingClientRect();
}
function toCanvasPoint(event) {
return utils.toCanvasPoint ? utils.toCanvasPoint(event, overlay, getCanvasSize()) : {
x: 0,
y: 0
};
}
function canvasRectToPixels(region) {
return utils.canvasRectToPixels ? utils.canvasRectToPixels(region, overlay, getCanvasSize()) : {
left: 0,
top: 0,
width: 0,
height: 0
};
}
function updateAspectRatio() {
var size = getCanvasSize();
stage.style.aspectRatio = size.width + ' / ' + size.height;
}
function updateCanvasSizeSummary() {
var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex];
canvasSizeSummary.textContent = option ? option.textContent : '';
}
function updateCanvasSizeLock() {
var lockOnExistingTemplate = canvasSizeSelect.dataset.lockOnExistingTemplate === 'true';
var locked = lockOnExistingTemplate && getCards().length > 0;
canvasSizeSelect.disabled = locked;
canvasSizeSummary.classList.toggle('is-locked', locked);
}
function syncCanvasSizeSelection() {
var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex];
if (!option) {
return;
}
if (canvasSizeIdInput) {
canvasSizeIdInput.value = option.value;
}
canvasWidthInput.value = Math.max(1, Number(option.dataset.width || canvasWidthInput.value || 1920));
canvasHeightInput.value = Math.max(1, Number(option.dataset.height || canvasHeightInput.value || 1080));
updateAspectRatio();
updateCanvasSizeSummary();
}
function updateBackgroundPreview(file) {
if (!file) {
return;
}
if (removeBackgroundFlag) {
removeBackgroundFlag.checked = false;
}
var reader = new FileReader();
reader.onload = function () {
backgroundPreview.src = reader.result;
backgroundPreview.style.display = 'block';
backgroundEmpty.style.display = 'none';
};
reader.readAsDataURL(file);
}
function updateStageBackgroundColor() {
if (!stage) {
return;
}
stage.style.backgroundColor = backgroundColorInput && backgroundColorInput.value ? backgroundColorInput.value : '#111111';
}
function getRegionChipLabel(regionType) {
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rss' ? 'RSS' : 'Text';
}
function populateRegionCard(card, region) {
var chip = card.querySelector('[data-region-chip]');
var title = card.querySelector('[data-region-title]');
var nameInput = card.querySelector('[name="region_name[]"]');
var fontFamilyInput = card.querySelector('[name="font_family[]"]');
var regionTypeInput = card.querySelector('[name="region_type[]"]');
var regionKeyInput = card.querySelector('[name="region_key[]"]');
var regionLabelInput = card.querySelector('[name="region_label[]"]');
if (title) {
title.textContent = region.label || region.region_key || 'Region';
}
if (chip) {
chip.textContent = getRegionChipLabel(region.region_type);
}
if (nameInput) {
nameInput.value = region.region_key || region.label || '';
}
if (fontFamilyInput) {
fontFamilyInput.value = region.region_type === 'image' ? '' : (region.font_family || 'Arial');
}
if (regionTypeInput) {
regionTypeInput.value = region.region_type || 'text';
}
if (regionKeyInput) {
regionKeyInput.value = region.region_key || region.label || '';
}
if (regionLabelInput) {
regionLabelInput.value = region.label || region.region_key || '';
}
card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80);
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
card.querySelector('[name="region_width[]"]').value = valueOrDefault(region.width, 300);
card.querySelector('[name="region_height[]"]').value = valueOrDefault(region.height, 120);
}
function updateRegionLabel(card) {
var label = getRegionName(card) || 'Region';
var cards = getCards();
var index = cards.indexOf(card);
var title = card.querySelector('.template-field-head strong');
if (title) {
title.textContent = label;
}
if (index >= 0 && regionSelect.options[index]) {
regionSelect.options[index].textContent = label;
}
}
function makeRegionCard(region) {
var card;
if (regionCardTemplate && regionCardTemplate.content) {
card = regionCardTemplate.content.firstElementChild.cloneNode(true);
} else {
card = document.createElement('div');
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
}
populateRegionCard(card, region);
var nameInput = card.querySelector('[name="region_name[]"]');
nameInput.addEventListener('input', function () {
syncRegionIdentity(card, nameInput.value);
updateRegionLabel(card);
validateRegionNames();
renderRegionSidebar();
renderOverlay();
});
card.addEventListener('click', function (event) {
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) {
return;
}
setSelected(getCards().indexOf(card));
});
card.querySelector('.remove-region').addEventListener('click', function (event) {
event.preventDefault();
card.remove();
if (!getCards().length) {
selectedIndex = -1;
} else if (selectedIndex >= getCards().length) {
selectedIndex = getCards().length - 1;
}
renderRegionSidebar();
renderOverlay();
});
return card;
}
function renderRegionList(initialRegions) {
regionList.innerHTML = '';
initialRegions.forEach(function (region) {
regionList.appendChild(makeRegionCard(region));
});
}
function renderRegionSidebar() {
var cards = getCards();
regionSelect.innerHTML = '';
if (!cards.length) {
regionSelect.disabled = true;
regionList.innerHTML = '';
updateCanvasSizeLock();
return;
}
regionSelect.disabled = false;
if (selectedIndex < 0 || selectedIndex >= cards.length) {
selectedIndex = 0;
}
cards.forEach(function (card, index) {
var option = document.createElement('option');
option.value = String(index);
option.textContent = getRegionName(card) || ('Region ' + (index + 1));
if (index === selectedIndex) {
option.selected = true;
}
regionSelect.appendChild(option);
card.hidden = index !== selectedIndex;
});
regionSelect.value = String(selectedIndex);
updateCanvasSizeLock();
validateRegionNames();
}
function renderOverlay() {
var cards = getCards();
var selectedCard = selectedIndex >= 0 ? cards[selectedIndex] : null;
var selectedNow = selectedCard ? cards.indexOf(selectedCard) : -1;
var regions = cards.map(readCard);
regionsJsonInput.value = JSON.stringify(regions);
overlay.innerHTML = regions.map(function (region, index) {
var box = canvasRectToPixels(region);
var selected = index === selectedNow ? ' selected' : '';
return '<div class="designer-rect' + selected + '" data-index="' + index + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;"><div class="designer-rect-label">' + escapeHtml(region.label || region.region_key || 'Region') + '</div><span class="resize-handle nw" data-dir="nw"></span><span class="resize-handle ne" data-dir="ne"></span><span class="resize-handle sw" data-dir="sw"></span><span class="resize-handle se" data-dir="se"></span></div>';
}).join('');
if (draft) {
var rect = getOverlayRect();
var size = getCanvasSize();
var draftBox = { x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: Math.abs(draft.end.x - draft.start.x), height: Math.abs(draft.end.y - draft.start.y) };
overlay.innerHTML += '<div class="designer-rect designer-draft" style="left:' + ((draftBox.x / size.width) * rect.width) + 'px;top:' + ((draftBox.y / size.height) * rect.height) + 'px;width:' + ((draftBox.width / size.width) * rect.width) + 'px;height:' + ((draftBox.height / size.height) * rect.height) + 'px;"></div>';
}
}
function requestOverlayRender() {
if (overlayRenderFrame) {
return;
}
overlayRenderFrame = window.requestAnimationFrame(function () {
overlayRenderFrame = 0;
renderOverlay();
});
}
function render() {
updateAspectRatio();
renderRegionSidebar();
renderOverlay();
}
function setSelected(index) {
var cards = getCards();
if (!cards.length) {
selectedIndex = -1;
} else if (index < 0) {
selectedIndex = 0;
} else {
selectedIndex = clamp(index, 0, cards.length - 1);
}
renderRegionSidebar();
renderOverlay();
}
function addRegion(region) {
var hint = regionList.querySelector('.muted');
if (hint) {
hint.remove();
}
regionList.appendChild(makeRegionCard(region));
setSelected(getCards().length - 1);
}
function openAddRegionModal() {
if (!regionAddModal || !window.bootstrap || !window.bootstrap.Modal) {
return;
}
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).show();
}
function createDefaultRegion(type) {
var count = getCards().length + 1;
var name = 'region_' + count;
return {
region_key: name,
label: name,
region_type: type,
font_family: type === 'text' || type === 'html' ? 'Arial' : '',
x: 80,
y: 80,
width: type === 'image' || type === 'webpage' || type === 'html' ? 420 : 300,
height: type === 'image' || type === 'webpage' || type === 'html' ? 240 : 120,
z_index: 1
};
}
function clampRegion(region) {
var size = getCanvasSize();
var minSize = 12;
var x = clamp(region.x, 0, size.width - minSize);
var y = clamp(region.y, 0, size.height - minSize);
var width = Math.max(minSize, region.width);
var height = Math.max(minSize, region.height);
if (x + width > size.width) {
width = size.width - x;
}
if (y + height > size.height) {
height = size.height - y;
}
return { x: Math.round(x), y: Math.round(y), width: Math.round(Math.max(minSize, width)), height: Math.round(Math.max(minSize, height)) };
}
function startDraw(event) {
var start = toCanvasPoint(event);
draft = { start: start, end: start };
renderOverlay();
function moveHandler(moveEvent) {
draft.end = toCanvasPoint(moveEvent);
requestOverlayRender();
}
function upHandler(upEvent) {
draft.end = toCanvasPoint(upEvent);
var width = Math.abs(draft.end.x - draft.start.x);
var height = Math.abs(draft.end.y - draft.start.y);
if (width >= 8 && height >= 8) {
var region = clampRegion({ x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: width, height: height });
addRegion({ region_key: 'region_' + (getCards().length + 1), label: 'Region ' + (getCards().length + 1), region_type: 'text', x: region.x, y: region.y, width: region.width, height: region.height, z_index: 1 });
}
draft = null;
requestOverlayRender();
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
function startMove(index, event) {
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
function moveHandler(moveEvent) {
var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y;
var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height });
writeCard(cardAt(index), { x: next.x, y: next.y });
requestOverlayRender();
}
function upHandler() {
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
function resizeFromHandle(index, dir, event) {
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
function moveHandler(moveEvent) {
var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y;
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
if (dir.indexOf('w') !== -1) { next.x = startRegion.x + dx; next.width = startRegion.width - dx; }
if (dir.indexOf('e') !== -1) { next.width = startRegion.width + dx; }
if (dir.indexOf('n') !== -1) { next.y = startRegion.y + dy; next.height = startRegion.height - dy; }
if (dir.indexOf('s') !== -1) { next.height = startRegion.height + dy; }
if (next.width < 12) { if (dir.indexOf('w') !== -1) { next.x -= 12 - next.width; } next.width = 12; }
if (next.height < 12) { if (dir.indexOf('n') !== -1) { next.y -= 12 - next.height; } next.height = 12; }
next = clampRegion(next);
writeCard(cardAt(index), { x: next.x, y: next.y, width: next.width, height: next.height });
requestOverlayRender();
}
function upHandler() {
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
if (addRegionButton && regionAddModal) {
var addRegionTypeButtons = regionAddModal.querySelectorAll('[data-add-region-type]');
addRegionButton.addEventListener('click', function () {
openAddRegionModal();
});
Array.prototype.forEach.call(addRegionTypeButtons, function (button) {
button.addEventListener('click', function () {
var regionType = button.getAttribute('data-add-region-type');
addRegion(createDefaultRegion(regionType));
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).hide();
}
});
});
}
backgroundInput.addEventListener('change', function () {
var file = backgroundInput.files && backgroundInput.files[0];
if (file) {
updateBackgroundPreview(file);
}
});
if (removeBackgroundButton && removeBackgroundFlag) {
removeBackgroundButton.addEventListener('click', function () {
removeBackgroundFlag.checked = true;
backgroundInput.value = '';
backgroundPreview.removeAttribute('src');
backgroundPreview.style.display = 'none';
backgroundEmpty.style.display = 'block';
});
}
if (backgroundColorInput) {
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
}
canvasSizeSelect.addEventListener('change', function () { syncCanvasSizeSelection(); render(); });
canvasWidthInput.addEventListener('input', render);
canvasHeightInput.addEventListener('input', render);
regionSelect.addEventListener('change', function () { setSelected(Number(regionSelect.value || 0)); });
overlay.addEventListener('mousedown', function (event) {
var rect = event.target.closest('.designer-rect');
if (rect) {
var index = Number(rect.getAttribute('data-index'));
var handle = event.target.closest('.resize-handle');
event.preventDefault();
setSelected(index);
if (handle) {
resizeFromHandle(index, handle.getAttribute('data-dir'), event);
} else {
startMove(index, event);
}
return;
}
if (event.target !== overlay && !event.target.classList.contains('designer-overlay')) {
return;
}
event.preventDefault();
setSelected(-1);
startDraw(event);
});
if (templateForm) {
templateForm.addEventListener('formdata', function (event) {
if (!validateRegionNames()) {
event.preventDefault();
return;
}
syncCanvasSizeSelection();
event.formData.set('regions_json', JSON.stringify(getCards().map(readCard)));
});
templateForm.addEventListener('submit', function () {
if (!validateRegionNames()) {
return;
}
syncCanvasSizeSelection();
regionsJsonInput.value = JSON.stringify(getCards().map(readCard));
});
}
renderRegionList(existingRegions);
syncCanvasSizeSelection();
updateStageBackgroundColor();
render();
})();
@@ -44,7 +44,7 @@
function getDefaultRegionSize(regionType, lockRatio) {
var ratio = parseLockRatio(lockRatio);
var locked = regionType === 'image' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss' || regionType === 'api';
var locked = regionType === 'image' || regionType === 'video' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss' || regionType === 'api';
if (ratio) {
if (ratio.ratio >= 1) {
@@ -36,10 +36,26 @@
var regionCardTemplate = document.getElementById('region-card-template');
var regionsJsonInput = document.getElementById('regions-json');
var templateForm = document.getElementById('template-form');
var templateRegionUsageElement = document.getElementById('template-region-usage');
var regionUsageSet = new Set();
var draft = null;
var selectedIndex = -1;
var overlayRenderFrame = 0;
try {
var regionUsageData = JSON.parse((templateRegionUsageElement && templateRegionUsageElement.textContent) || '[]');
if (Array.isArray(regionUsageData)) {
regionUsageData.forEach(function (regionKey) {
var normalized = String(regionKey || '').trim();
if (normalized) {
regionUsageSet.add(normalized);
}
});
}
} catch (_error) {
regionUsageSet = new Set();
}
function escapeHtml(value) {
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
}
@@ -312,7 +328,7 @@
}
function getRegionChipLabel(regionType) {
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
return regionType === 'image' ? 'Image' : regionType === 'video' ? 'Video' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
}
function populateRegionCard(card, region) {
@@ -334,7 +350,7 @@
nameInput.value = region.region_key || region.label || '';
}
if (fontFamilyInput) {
fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial');
fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'video' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial');
}
if (regionTypeInput) {
regionTypeInput.value = region.region_type || 'text';
@@ -357,6 +373,21 @@
updateRegionLockBadge(card);
}
function setRegionRemovalState(card) {
var removeButton = card.querySelector('[data-region-remove-button]');
if (!removeButton) {
return;
}
var regionKey = String(card.dataset.regionKey || getRegionName(card) || '').trim();
var regionDeletionLocked = regionUsageSet.has(regionKey);
removeButton.disabled = regionDeletionLocked;
if (regionDeletionLocked) {
removeButton.title = 'Regions cannot be deleted while this template is used by slides.';
} else {
removeButton.removeAttribute('title');
}
}
function updateRegionLabel(card) {
var label = getRegionName(card) || 'Region';
var cards = getCards();
@@ -379,6 +410,8 @@
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
}
populateRegionCard(card, region);
card.dataset.regionKey = String(region.region_key || region.label || getRegionName(card) || '').trim();
setRegionRemovalState(card);
var nameInput = card.querySelector('[name="region_name[]"]');
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
var widthInput = card.querySelector('[name="region_width[]"]');
@@ -415,12 +448,12 @@
});
}
card.addEventListener('click', function (event) {
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) {
if (event.target && event.target.closest && event.target.closest('[data-region-remove-button]')) {
return;
}
setSelected(getCards().indexOf(card));
});
card.querySelector('.remove-region').addEventListener('click', function (event) {
card.querySelector('[data-region-remove-button]').addEventListener('click', function (event) {
event.preventDefault();
card.remove();
if (!getCards().length) {
@@ -523,7 +556,9 @@
if (hint) {
hint.remove();
}
regionList.appendChild(makeRegionCard(region));
var card = makeRegionCard(region);
setRegionRemovalState(card);
regionList.appendChild(card);
setSelected(getCards().length - 1);
}
@@ -600,8 +635,12 @@
var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y;
var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height });
writeCard(cardAt(index), { x: next.x, y: next.y });
var size = getCanvasSize();
var maxX = Math.max(0, size.width - startRegion.width);
var maxY = Math.max(0, size.height - startRegion.height);
var nextX = clamp(startRegion.x + dx, 0, maxX);
var nextY = clamp(startRegion.y + dy, 0, maxY);
writeCard(cardAt(index), { x: nextX, y: nextY, width: startRegion.width, height: startRegion.height });
requestOverlayRender();
}
function upHandler() {
@@ -617,6 +656,24 @@
var startRegion = readCard(cardAt(index));
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
var canvasSize = getCanvasSize();
var minSize = 12;
function clampResizeDelta(dx, dy) {
if (dir.indexOf('w') !== -1) {
dx = clamp(dx, -startRegion.x, startRegion.width - minSize);
}
if (dir.indexOf('e') !== -1) {
dx = clamp(dx, minSize - startRegion.width, canvasSize.width - startRegion.x - startRegion.width);
}
if (dir.indexOf('n') !== -1) {
dy = clamp(dy, -startRegion.y, startRegion.height - minSize);
}
if (dir.indexOf('s') !== -1) {
dy = clamp(dy, minSize - startRegion.height, canvasSize.height - startRegion.y - startRegion.height);
}
return { dx: dx, dy: dy };
}
function fitFromWidth(width) {
var nextWidth = Math.max(12, Math.round(width));
@@ -638,6 +695,9 @@
var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y;
var constrainedDelta = clampResizeDelta(dx, dy);
dx = constrainedDelta.dx;
dy = constrainedDelta.dy;
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
if (aspect) {
if (dir === 'e') {
@@ -748,7 +808,6 @@
}
event.preventDefault();
setSelected(-1);
startDraw(event);
});
if (templateForm) {
templateForm.addEventListener('formdata', function (event) {
-118
View File
@@ -1,118 +0,0 @@
(function () {
var THEME_STORAGE_KEY = 'web-theme';
function getPreferredTheme() {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
function getStoredTheme() {
try {
var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
if (storedTheme === 'dark' || storedTheme === 'light') {
return storedTheme;
}
} catch (error) {
return null;
}
return null;
}
function setStoredTheme(theme) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch (error) {
return;
}
}
function applyTheme(theme) {
var normalizedTheme = theme === 'dark' ? 'dark' : 'light';
var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark';
var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode';
document.documentElement.dataset.bsTheme = normalizedTheme;
document.documentElement.style.colorScheme = normalizedTheme;
Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) {
var icon = toggleButton.querySelector('.theme-toggle__icon');
toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false');
toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase());
if (icon) {
icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun');
icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon');
}
});
return normalizedTheme;
}
function initThemeToggle() {
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
var storedTheme = getStoredTheme();
var theme = storedTheme || getPreferredTheme();
applyTheme(theme);
if (!toggleButtons.length) {
return;
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
var nextTheme = document.documentElement.dataset.bsTheme === 'dark' ? 'light' : 'dark';
setStoredTheme(nextTheme);
applyTheme(nextTheme);
});
});
}
function initSidebarToggle() {
var body = document.body;
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]'));
var backdrop = document.querySelector('[data-sidebar-backdrop]');
if (!toggleButtons.length || !backdrop) {
return;
}
function setSidebarOpen(isOpen) {
body.classList.toggle('is-sidebar-open', isOpen);
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
});
}
function toggleSidebar() {
setSidebarOpen(!body.classList.contains('is-sidebar-open'));
}
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
toggleButton.addEventListener('click', function () {
toggleSidebar();
});
});
backdrop.addEventListener('click', function () {
setSidebarOpen(false);
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
setSidebarOpen(false);
}
});
}
window.getPreferredTheme = getPreferredTheme;
window.getStoredTheme = getStoredTheme;
window.setStoredTheme = setStoredTheme;
window.applyTheme = applyTheme;
window.initThemeToggle = initThemeToggle;
window.initSidebarToggle = initSidebarToggle;
initThemeToggle();
initSidebarToggle();
}());
+94
View File
@@ -0,0 +1,94 @@
(function () {
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
var date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getClientDisplayName(client) {
if (client && client.client_name) {
return String(client.client_name).trim();
}
var clientId = String(client && client.clientId ? client.clientId : '').trim();
if (clientId) {
return clientId;
}
return '';
}
function setButtonVariant(button, classesToRemove, classToAdd) {
if (!button) {
return;
}
if (button.classList) {
classesToRemove.forEach(function (className) {
button.classList.remove(className);
});
if (classToAdd) {
button.classList.add(classToAdd);
}
return;
}
var className = String(button.className || '');
classesToRemove.forEach(function (removeClass) {
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
});
if (classToAdd) {
className += ' ' + classToAdd;
}
button.className = className.replace(/\s+/g, ' ').trim();
}
function normalizeDisplayIp(value) {
var ip = String(value || '').trim();
if (!ip) {
return '';
}
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
return ip.slice(7).trim();
}
return ip;
}
window.webUiHelpers = {
escapeHtml: escapeHtml,
formatDashboardDate: formatDashboardDate,
getClientRowKey: getClientRowKey,
getClientDisplayName: getClientDisplayName,
setButtonVariant: setButtonVariant,
normalizeDisplayIp: normalizeDisplayIp
};
}());
+184 -7
View File
@@ -1,3 +1,6 @@
const fs = require('fs');
const path = require('path');
module.exports = function registerAdminContentRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
@@ -8,6 +11,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
const collectUploadReferencesFromSlide = deps.collectUploadReferencesFromSlide;
const collectUploadReferencesFromTemplate = deps.collectUploadReferencesFromTemplate;
const collectUploadReferencesFromPayload = deps.collectUploadReferencesFromPayload;
const removeUnusedUploadFiles = deps.removeUnusedUploadFiles;
const syncPlaylistUploadsOnChange = deps.syncPlaylistUploadsOnChange;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
@@ -18,14 +22,137 @@ module.exports = function registerAdminContentRoutes(app, deps) {
const getTemplateDeleteBlockMessage = deps.getTemplateDeleteBlockMessage;
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
const requirePermission = deps.requirePermission;
const hasAnyPermission = deps.hasAnyPermission;
const { buildPagination } = require('../../lib/pagination');
const LIST_PAGE_SIZE = 10;
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function') {
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof removeUnusedUploadFiles !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function' || typeof hasAnyPermission !== 'function') {
throw new Error('registerAdminContentRoutes requires the content route dependencies.');
}
function requireSlideUploadPermission(req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
}
if (hasAnyPermission(req.currentUser, ['slides.create', 'slides.update'])) {
return next();
}
const error = new Error('You do not have permission to access this area.');
error.statusCode = 403;
error.expose = true;
next(error);
}
function getUploadedFileMediaType(file) {
const mimeType = String(file && file.mimetype || '').trim().toLowerCase();
const extension = path.extname(String(file && file.originalname || '')).toLowerCase();
if (mimeType.indexOf('video/') === 0 || ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.avi', '.mkv'].indexOf(extension) !== -1) {
return 'video';
}
if (mimeType.indexOf('image/') === 0 || ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.avif', '.tif', '.tiff'].indexOf(extension) !== -1) {
return 'image';
}
return null;
}
function getUploadedFileLimitBytes(file) {
return getUploadedFileMediaType(file) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
}
function getUploadedFileLimitLabel(file) {
return getUploadedFileMediaType(file) === 'video' ? '1 GB' : '100 MB';
}
async function removeUploadedFile(file) {
if (!file || !file.filename || !deps.uploadDir) {
return;
}
const filePath = path.join(deps.uploadDir, file.filename);
try {
await fs.promises.unlink(filePath);
} catch (_error) {
// Ignore cleanup failures.
}
}
async function validateUploadedFiles(files) {
const list = Array.isArray(files) ? files.filter(Boolean) : [];
for (let i = 0; i < list.length; i += 1) {
const file = list[i];
const mediaType = getUploadedFileMediaType(file);
if (!mediaType) {
await removeUploadedFile(file);
const error = new Error('Unsupported upload type.');
error.statusCode = 400;
error.expose = true;
throw error;
}
if (Number(file.size || 0) > getUploadedFileLimitBytes(file)) {
await removeUploadedFile(file);
const error = new Error('File must be ' + getUploadedFileLimitLabel(file) + ' or smaller.');
error.statusCode = 400;
error.expose = true;
throw error;
}
}
}
async function fetchTemplateSlideCount(templateId) {
const [rows] = await pool.query('SELECT COUNT(*) AS slide_count FROM slides WHERE template_id = ?', [templateId]);
return Number(rows[0] && rows[0].slide_count) || 0;
}
async function fetchTemplateRegionUsage(template) {
const [slides] = await pool.query('SELECT content_json FROM slides WHERE template_id = ?', [template.id]);
const usage = new Set();
const regionKeys = new Set((Array.isArray(template && template.regions) ? template.regions : [])
.map((region) => String(region && region.region_key || '').trim())
.filter(Boolean));
if (!regionKeys.size || !slides.length) {
return [];
}
slides.forEach((slide) => {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slide.content_json) : null;
if (!content || typeof content !== 'object') {
return;
}
regionKeys.forEach((regionKey) => {
if (Object.prototype.hasOwnProperty.call(content, regionKey)) {
usage.add(regionKey);
}
});
});
return Array.from(usage);
}
function getRemovedTemplateRegionKeys(existingRegions, nextRegions) {
const nextKeys = new Set((Array.isArray(nextRegions) ? nextRegions : []).map((region) => String(region && region.region_key || '').trim()).filter(Boolean));
return (Array.isArray(existingRegions) ? existingRegions : [])
.map((region) => String(region && region.region_key || '').trim())
.filter((regionKey) => regionKey && !nextKeys.has(regionKey));
}
async function getTemplateRegionDeleteBlockMessage(template, nextRegions) {
const removedRegionKeys = getRemovedTemplateRegionKeys(template && template.regions ? template.regions : [], nextRegions);
if (!removedRegionKeys.length) {
return '';
}
const usedRegionKeys = Array.isArray(template && template.region_usage) ? template.region_usage : [];
const blockedKeys = removedRegionKeys.filter((regionKey) => usedRegionKeys.includes(regionKey));
return blockedKeys.length > 0 ? 'This region is still used by one or more slides.' : '';
}
function queueSlideThumbnailRefresh(slideId, previousThumbnailPath) {
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
return Promise.resolve(null);
@@ -81,10 +208,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderSlidesPage({
slides: data.slides || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'slides', 'Slide pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'slides', 'Slide pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
@@ -145,8 +275,42 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/slides/uploads', requireSlideUploadPermission, upload.single('file'), async function (req, res, next) {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file was uploaded.' });
}
await validateUploadedFiles([req.file]);
res.json({
path: '/media/uploads/' + req.file.filename,
filename: req.file.filename,
originalName: req.file.originalname || ''
});
} catch (error) {
next(error);
}
});
app.post('/slides/uploads/cleanup', requireSlideUploadPermission, async function (req, res, next) {
try {
const uploadPaths = Array.isArray(req.body && req.body.uploadPaths)
? req.body.uploadPaths
: Array.isArray(req.body && req.body.paths)
? req.body.paths
: [];
await removeUnusedUploadFiles(pool, deps.uploadDir, uploadPaths);
res.json({ ok: true });
} catch (error) {
next(error);
}
});
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
try {
await validateUploadedFiles(req.files || []);
const payload = await common.buildSlidePayload(pool, req, null);
if (await common.fetchDuplicateName(pool, 'slides', payload.title, null, 'title')) {
return res.status(400).send('A slide with that title already exists.');
@@ -177,6 +341,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
try {
await validateUploadedFiles(req.files || []);
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
return res.status(404).send('Slide not found');
@@ -251,10 +416,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderTemplatesPage({
templates: data.templates || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'templates', 'Template pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'templates', 'Template pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
@@ -310,6 +478,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
if (!template) {
return res.status(404).send('Template not found');
}
template.region_usage = await fetchTemplateRegionUsage(template);
const sizeData = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser));
} catch (error) {
@@ -323,11 +492,16 @@ module.exports = function registerAdminContentRoutes(app, deps) {
if (!template) {
return res.status(404).send('Template not found');
}
template.region_usage = await fetchTemplateRegionUsage(template);
const existingUploadRefs = collectUploadReferencesFromTemplate(template);
const payload = await common.buildTemplatePayload(pool, req, template);
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name, template.id)) {
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent('A template with that name already exists.'));
}
const regionDeleteBlockMessage = await getTemplateRegionDeleteBlockMessage(template, payload.regions);
if (regionDeleteBlockMessage) {
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent(regionDeleteBlockMessage));
}
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
const actorId = getAuditUserId(req);
@@ -392,10 +566,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderCanvasSizesPage({
canvasSizes: data.canvasSizes || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
+10 -4
View File
@@ -199,7 +199,10 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const apiSources = (data.apiSources || []).map(function (apiSource) {
return Object.assign({}, apiSource, {
intervalLabel: apiSource.update_interval_unit === 'seconds'
@@ -211,7 +214,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
});
res.send(pages.renderApiSourcesPage({
apiSources: apiSources,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'API sources', 'API source pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'API sources', 'API source pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
} catch (error) {
next(error);
@@ -381,10 +384,13 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderRssFeedsPage({
rssFeeds: data.rssFeeds || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
+26 -8
View File
@@ -36,12 +36,15 @@ module.exports = function registerAdminManageRoutes(app, deps) {
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
? req.body.blackout
: req.query.blackout;
const pausedValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'paused')
? req.body.paused
: req.query.paused;
if (!command) {
return res.status(400).json({ error: 'Command is required' });
}
if (command !== 'reload' && command !== 'blackout') {
if (command !== 'reload' && command !== 'blackout' && command !== 'pause') {
return res.status(400).json({ error: 'Unsupported command' });
}
@@ -56,7 +59,12 @@ module.exports = function registerAdminManageRoutes(app, deps) {
command: 'blackout',
blackout: blackoutValue === true || blackoutValue === 'true' || blackoutValue === '1' ? true : false
}
: 'reload';
: command === 'pause'
? {
command: 'pause',
paused: pausedValue === true || pausedValue === 'true' || pausedValue === '1' ? true : false
}
: 'reload';
const sentCount = await notifyPlayerScreens(slugs, payload);
await broadcastDashboardState();
@@ -65,7 +73,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
ok: true,
command: command,
sent: sentCount,
blackout: command === 'blackout' ? Boolean(payload.blackout) : undefined
blackout: command === 'blackout' ? Boolean(payload.blackout) : undefined,
paused: command === 'pause' ? Boolean(payload.paused) : undefined
});
} catch (error) {
next(error);
@@ -116,6 +125,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']);
const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']);
const useVideoDurations = readArrayField(req.body, ['use_video_duration[]', 'use_video_duration']);
const scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']);
const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']);
const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']);
@@ -126,6 +136,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
if (durations.length && durations.length !== slideIds.length) {
return res.status(400).send('Playlist slide data is invalid.');
}
if (useVideoDurations.length && useVideoDurations.length !== slideIds.length) {
return res.status(400).send('Playlist slide data is invalid.');
}
if (scheduleModes.length && scheduleModes.length !== slideIds.length) {
return res.status(400).send('Playlist schedule data is invalid.');
}
@@ -143,7 +156,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
seenSlideIds.add(slideId);
const durationRaw = Number(durations[i]);
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(1, Math.trunc(durationRaw)) : 10;
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(0.001, Math.round(durationRaw * 1000) / 1000) : 10;
const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0;
const scheduleMode = normalizeScheduleMode(scheduleModes[i]);
let scheduleStartDatetime = null;
@@ -188,6 +202,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
slideId,
position: i,
durationSeconds,
useVideoDuration,
scheduleMode,
scheduleStartDatetime,
scheduleEndDatetime,
@@ -227,12 +242,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
for (let i = 0; i < normalizedSlides.length; i += 1) {
const item = normalizedSlides[i];
await connection.query(
'INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
playlist.id,
item.slideId,
item.position,
item.durationSeconds,
item.useVideoDuration,
item.scheduleMode,
item.scheduleStartDatetime,
item.scheduleEndDatetime,
@@ -303,11 +319,12 @@ module.exports = function registerAdminManageRoutes(app, deps) {
if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) {
return res.status(400).send('The slide canvas size must match the existing playlist items.');
}
const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10));
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM playlist_slides WHERE playlist_id = ?', [playlist.id]);
const nextPosition = Number(positionRows[0].max_position) + 1;
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, actorId, actorId]);
await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, actorId, actorId]);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
@@ -323,7 +340,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10));
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'UPDATE playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
+16 -3
View File
@@ -162,10 +162,13 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await rbacData.fetchRolesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await rbacData.fetchRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderRbacPage({
roles: data.roles || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'roles', 'Role pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'roles', 'Role pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
@@ -369,7 +372,17 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
}
await rbacData.syncRolePermissions(pool, roleId, normalizedPermissionKeys);
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
} catch (error) {
next(error);
+5 -2
View File
@@ -57,7 +57,10 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
app.get('/users', requirePermission('users.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const mappedUsers = (data.users || []).map(function (user) {
return Object.assign({}, user, {
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
@@ -68,7 +71,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
});
res.send(pages.renderUsersPage({
users: mappedUsers,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'users', 'Users pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'Users pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
+2 -1
View File
@@ -27,7 +27,8 @@ module.exports = {
renderCanvasSizesPage: require('./canvas-sizes/list'),
renderCanvasSizeFormPage: require('./canvas-sizes/add'),
renderCanvasSizeEditPage: require('./canvas-sizes/edit'),
renderBackgroundTasksPage: require('./settings/background-tasks-page'),
renderBackgroundTasksPage: require('./settings/background-tasks-page').renderBackgroundTasksPage,
renderBackgroundTasksScheduledPage: require('./settings/background-tasks-page').renderBackgroundTasksScheduledPage,
renderErrorPage: require('./error'),
renderRbacPage: require('./rbac/list'),
renderRbacAddPage: require('./rbac/add'),
+151 -117
View File
@@ -1,8 +1,11 @@
const { renderView } = require('../../view');
const { hasAnyPermission } = require('../../../rbac');
const { buildPagination, buildQueryString } = require('../../lib/pagination');
const { parsePageNumber, normalizeSortDirection, sortRows, createSearchMatcher } = require('../../lib/list-query');
const TASKS_PER_PAGE = 10;
const TASK_STATUSES = new Set(['queued', 'running', 'failed', 'completed', 'canceled']);
const QUEUE_PAGE_URL = '/settings/tasks-background';
const SCHEDULED_PAGE_URL = '/settings/tasks-scheduled';
function formatIntervalLabel(intervalMs) {
const value = Math.max(1, Number(intervalMs) || 0);
@@ -17,16 +20,6 @@ function formatIntervalLabel(intervalMs) {
return `${value} ms`;
}
function parsePageNumber(value) {
const pageNumber = Math.floor(Number(value) || 1);
return Math.max(1, pageNumber);
}
function normalizeStatusFilter(value) {
const status = String(value || '').trim().toLowerCase();
return TASK_STATUSES.has(status) ? status : '';
}
function normalizeSourceFilter(sourceType, sourceId) {
const normalizedSourceType = String(sourceType || '').trim();
const normalizedSourceId = Math.floor(Number(sourceId) || 0);
@@ -41,62 +34,7 @@ function normalizeSourceFilter(sourceType, sourceId) {
};
}
function buildQueryString(query) {
const searchParams = new URLSearchParams();
['page', 'recurringPage', 'status', 'sourceType', 'sourceId'].forEach(function (key) {
const value = query && query[key];
if (value === undefined || value === null || value === '') {
return;
}
searchParams.set(key, String(value));
});
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : '';
}
function buildPagination(totalItems, currentPage, pageParam, queryState) {
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
const totalPages = Math.max(1, Math.ceil(totalItems / TASKS_PER_PAGE));
const safeCurrentPage = Math.min(Math.max(1, currentPage), totalPages);
const startIndex = (safeCurrentPage - 1) * TASKS_PER_PAGE;
const endIndex = Math.min(totalItems, startIndex + TASKS_PER_PAGE);
const pages = [];
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: pageNumber });
pages.push({
number: pageNumber,
active: pageNumber === safeCurrentPage,
url: buildQueryString(nextQuery)
});
}
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
return {
currentPage: safeCurrentPage,
totalPages: totalPages,
totalItems: totalItems,
hasMultiplePages: totalPages > 1,
startItem: totalItems === 0 ? 0 : startIndex + 1,
endItem: endIndex,
hasPrevious: safeCurrentPage > 1,
hasNext: safeCurrentPage < totalPages,
previousUrl: buildQueryString(previousQuery),
nextUrl: buildQueryString(nextQuery),
pages: pages,
pageSize: TASKS_PER_PAGE,
pageParam: normalizedPageParam
};
}
function taskMatchesFilters(task, statusFilter, sourceFilter) {
if (statusFilter && task.status !== statusFilter) {
return false;
}
function taskMatchesFilters(task, sourceFilter) {
if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) {
return true;
}
@@ -105,13 +43,29 @@ function taskMatchesFilters(task, statusFilter, sourceFilter) {
return String(metadata.sourceType || '').trim() === sourceFilter.sourceType && Number(metadata.sourceId || 0) === sourceFilter.sourceId;
}
function formatStatusLabel(status) {
const normalizedStatus = String(status || '').trim().toLowerCase();
if (!normalizedStatus) {
return 'All tasks';
}
const taskMatchesSearch = createSearchMatcher;
return normalizedStatus.charAt(0).toUpperCase() + normalizedStatus.slice(1);
function formatStatusLabel(status) {
const normalized = String(status || '').trim().toLowerCase();
if (!normalized) {
return '';
}
if (normalized === 'queued') {
return 'Queued';
}
if (normalized === 'running') {
return 'Running';
}
if (normalized === 'completed') {
return 'Completed';
}
if (normalized === 'failed') {
return 'Failed';
}
if (normalized === 'canceled') {
return 'Canceled';
}
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
}
function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
@@ -133,40 +87,63 @@ function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
return buildQueryString(nextQuery);
}
function buildStatusFilterUrl(queryState, statusFilter) {
const nextQuery = Object.assign({}, queryState, {
page: 1,
status: statusFilter && statusFilter.status === statusFilter.targetStatus ? '' : statusFilter.targetStatus,
sourceType: statusFilter && statusFilter.sourceType ? statusFilter.sourceType : '',
sourceId: statusFilter && statusFilter.sourceId ? statusFilter.sourceId : ''
});
return buildQueryString(nextQuery);
}
module.exports = function renderBackgroundTasksPage(data, message, currentUser) {
function buildQueuePageViewModel(data, message, currentUser) {
const tasks = (data && data.tasks) || [];
const recurringTasks = (data && data.recurringTasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']);
const sort = String(data && data.sort || '').trim();
const direction = normalizeSortDirection(data && data.direction);
const queryState = {
page: parsePageNumber(data && data.page),
recurringPage: parsePageNumber(data && data.recurringPage),
status: normalizeStatusFilter(data && data.status),
sourceType: '',
sourceId: ''
sourceId: '',
search: String(data && data.search || '').trim(),
sort: sort,
direction: direction
};
const sourceFilter = normalizeSourceFilter(data && data.sourceType, data && data.sourceId);
queryState.status = normalizeStatusFilter(data && data.status);
queryState.sourceType = sourceFilter.sourceType;
queryState.sourceId = sourceFilter.sourceId;
const filteredTasks = tasks.filter(function (task) {
return taskMatchesFilters(task, queryState.status, sourceFilter);
return taskMatchesFilters(task, sourceFilter) && taskMatchesSearch(queryState.search, [
'title',
'key',
'category',
'status',
'errorMessage',
function (item) { return item && item.metadata && item.metadata.sourceName; },
function (item) { return item && item.metadata && item.metadata.sourceType; },
function (item) { return item && item.metadata && item.metadata.sourceId; }
])(task);
});
const pagination = buildPagination(filteredTasks.length, queryState.page, 'page', queryState);
const recurringPagination = buildPagination(recurringTasks.length, queryState.recurringPage, 'recurringPage', queryState);
const visibleTasks = filteredTasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE);
const visibleRecurringTasks = recurringTasks.slice((recurringPagination.currentPage - 1) * TASKS_PER_PAGE, recurringPagination.currentPage * TASKS_PER_PAGE);
const sortedTasks = sortRows(filteredTasks, function (task) {
if (sort === 'task') {
return task && task.title;
}
if (sort === 'category') {
return task && task.category;
}
if (sort === 'status') {
return task && task.status;
}
if (sort === 'queued') {
return task && task.createdAt;
}
if (sort === 'started') {
return task && task.startedAt;
}
if (sort === 'finished') {
return task && task.finishedAt;
}
if (sort === 'source') {
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) {
return String(value || '').trim();
}).join(' ');
}
return task && task.createdAt;
}, direction);
const pagination = buildPagination(sortedTasks.length, queryState.page, 'page', queryState, TASKS_PER_PAGE, 'tasks', 'Background task pages');
const visibleTasks = sortedTasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE);
const statusCards = [
{
status: 'queued',
@@ -193,18 +170,8 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
variant: 'success'
}
].map(function (card) {
const isActive = queryState.status === card.status;
const statusFilter = {
status: queryState.status,
sourceType: queryState.sourceType,
sourceId: queryState.sourceId,
targetStatus: card.status
};
return Object.assign({}, card, {
active: isActive,
href: buildStatusFilterUrl(queryState, statusFilter),
ariaLabel: isActive ? `Clear ${card.label.toLowerCase()} filter` : `Filter tasks by ${card.label.toLowerCase()}`
active: false
});
});
@@ -216,32 +183,99 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
sourceIdLabel: task.metadata && task.metadata.sourceId ? Number(task.metadata.sourceId) : ''
});
});
const hasActiveFilters = Boolean(queryState.status || queryState.sourceType || queryState.sourceId);
const hasActiveFilters = Boolean(queryState.sourceType || queryState.sourceId);
return renderView('settings/background-tasks/index', {
return renderView('settings/tasks-background/index', {
title: 'Background tasks',
active: 'background-tasks',
message: message,
currentUser: currentUser || null,
scripts: ['js/settings/background-tasks.js'],
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
backgroundTasksMenuOpen: true,
tasks: visibleTasksWithSources,
recurringTasks: visibleRecurringTasks.map(function (task) {
return Object.assign({}, task, {
intervalLabel: formatIntervalLabel(task.intervalMs)
});
}),
summary: summary,
statusCards: statusCards,
activeStatusLabel: formatStatusLabel(queryState.status),
activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '',
clearFiltersUrl: buildQueryString({ recurringPage: queryState.recurringPage }),
clearFiltersUrl: QUEUE_PAGE_URL,
hasActiveFilters: hasActiveFilters,
pagination: pagination,
recurringPagination: recurringPagination,
canManage: canManage,
canClearFinished: tasks.some(function (task) {
return task.status !== 'queued' && task.status !== 'running';
})
});
}
function buildScheduledPageViewModel(data, message, currentUser) {
const recurringTasks = (data && data.recurringTasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0, scheduledCount: 0 };
const canManage = hasAnyPermission(currentUser, ['scheduled-tasks.allow']);
const search = String(data && data.search || '').trim();
const sort = String(data && data.sort || '').trim();
const direction = normalizeSortDirection(data && data.direction);
const filteredRecurringTasks = recurringTasks.filter(function (task) {
return taskMatchesSearch(search, [
'title',
'intervalLabel',
'nextRunAt',
'lastRunAt',
'lastStatus',
'lastError',
function (item) { return item && item.metadata && item.metadata.sourceName; },
function (item) { return item && item.metadata && item.metadata.sourceType; },
function (item) { return item && item.metadata && item.metadata.sourceId; }
])(task);
});
const sortedRecurringTasks = sortRows(filteredRecurringTasks, function (task) {
if (sort === 'task') {
return task && task.title;
}
if (sort === 'interval') {
return task && task.intervalMs;
}
if (sort === 'next_run') {
return task && task.nextRunAt;
}
if (sort === 'last_run') {
return task && task.lastRunAt;
}
if (sort === 'last_result') {
return [task && task.lastStatus, task && task.lastError].map(function (value) {
return String(value || '').trim();
}).join(' ');
}
if (sort === 'source') {
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) {
return String(value || '').trim();
}).join(' ');
}
return task && task.nextRunAt;
}, direction);
return renderView('settings/tasks-scheduled/index', {
title: 'Scheduled tasks',
active: 'background-tasks-scheduled',
message: message,
currentUser: currentUser || null,
scripts: ['js/settings/background-tasks.js'],
backgroundTasksMenuOpen: true,
recurringTasks: sortedRecurringTasks.map(function (task) {
return Object.assign({}, task, {
intervalLabel: formatIntervalLabel(task.intervalMs)
});
}),
summary: summary,
canManage: canManage,
scheduledCount: Number(summary.scheduledCount || filteredRecurringTasks.length || 0),
recurringPagination: buildPagination(sortedRecurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage', { recurringPage: parsePageNumber(data && data.recurringPage), search: search, sort: sort, direction: direction })
});
}
module.exports = {
renderBackgroundTasksPage: function (data, message, currentUser) {
return buildQueuePageViewModel(data, message, currentUser);
},
renderBackgroundTasksScheduledPage: function (data, message, currentUser) {
return buildScheduledPageViewModel(data, message, currentUser);
}
};
+48 -37
View File
@@ -17,6 +17,23 @@ function requireSettingsAccess() {
};
}
function requireScheduledSettingsAccess() {
return function (req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
}
if (hasAnyPermission(req.currentUser, ['scheduled-tasks.read', 'scheduled-tasks.allow'])) {
return next();
}
const error = new Error('You do not have permission to access this area.');
error.statusCode = 403;
error.expose = true;
next(error);
};
}
module.exports = function registerAdminSettingsRoutes(app, deps) {
const pages = deps.pages;
const backgroundTaskQueue = deps.backgroundTaskQueue;
@@ -25,44 +42,18 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
throw new Error('registerAdminSettingsRoutes requires pages and backgroundTaskQueue.');
}
function buildQueueVersion(tasks, recurringTasks, summary) {
return [
'q:' + Number(summary && summary.counts && summary.counts.queued || 0),
'r:' + Number(summary && summary.counts && summary.counts.running || 0),
'f:' + Number(summary && summary.counts && summary.counts.failed || 0),
'c:' + Number(summary && summary.counts && summary.counts.completed || 0),
'a:' + Number(summary && summary.activeCount || 0),
't:' + Number(summary && summary.total || 0),
'tasks:' + (tasks || []).map(function (task) {
return [task.id, task.status, task.startedAt || '', task.finishedAt || '', task.errorMessage || '', task.attempts || 0].join(':');
}).join('|'),
'recurring:' + (recurringTasks || []).map(function (task) {
return [task.key, task.nextRunAt || '', task.lastRunAt || '', task.lastStatus || '', task.lastError || ''].join(':');
}).join('|')
].join('~');
}
app.get('/settings/background-tasks', requireSettingsAccess(), function (req, res) {
app.get('/settings/tasks-background', requireSettingsAccess(), function (req, res) {
const tasks = backgroundTaskQueue.listTasks();
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
const summary = backgroundTaskQueue.getSummary();
const version = buildQueueVersion(tasks, recurringTasks, summary);
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser));
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/settings/background-tasks/state', requireSettingsAccess(), function (req, res) {
app.get('/settings/tasks-scheduled', requireScheduledSettingsAccess(), function (req, res) {
const tasks = backgroundTaskQueue.listTasks();
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
const summary = backgroundTaskQueue.getSummary();
const version = buildQueueVersion(tasks, recurringTasks, summary);
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
res.json({
version: version,
summary: summary,
taskCount: tasks.length,
recurringCount: recurringTasks.length
});
res.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
});
function requireSettingsManageAccess() {
@@ -82,18 +73,38 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
};
}
app.post('/settings/background-tasks/clear-finished', requireSettingsManageAccess(), function (req, res) {
function requireRecurringTaskAccess(req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
}
if (!hasAnyPermission(req.currentUser, ['scheduled-tasks.allow'])) {
const error = new Error('You do not have permission to access this area.');
error.statusCode = 403;
error.expose = true;
return next(error);
}
return next();
}
app.post('/settings/tasks-background/clear-finished', requireSettingsManageAccess(), function (req, res) {
const removedCount = backgroundTaskQueue.clearFinishedTasks();
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(removedCount ? 'Cleared ' + removedCount + ' finished task' + (removedCount === 1 ? '' : 's') + '.' : 'No finished tasks to clear.'));
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(removedCount ? 'Cleared ' + removedCount + ' finished task' + (removedCount === 1 ? '' : 's') + '.' : 'No finished tasks to clear.'));
});
app.post('/settings/background-tasks/:id/cancel', requireSettingsManageAccess(), function (req, res) {
app.post('/settings/tasks-background/:id/cancel', requireSettingsManageAccess(), function (req, res) {
const canceled = backgroundTaskQueue.cancelTask(Number(req.params.id));
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(canceled ? 'Task canceled.' : 'Unable to cancel that task.'));
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(canceled ? 'Task canceled.' : 'Unable to cancel that task.'));
});
app.post('/settings/background-tasks/:id/retry', requireSettingsManageAccess(), function (req, res) {
const retryTask = backgroundTaskQueue.retryTask(Number(req.params.id));
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(retryTask ? 'Task requeued.' : 'Unable to retry that task.'));
app.post('/settings/tasks-background/:id/retry', requireSettingsManageAccess(), function (req, res) {
const retriedTask = backgroundTaskQueue.retryTask(Number(req.params.id));
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(retriedTask ? 'Task requeued.' : 'Unable to retry that task.'));
});
app.post('/settings/tasks-scheduled/recurring/:key/run', requireRecurringTaskAccess, function (req, res) {
const ran = backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim());
res.redirect('/settings/tasks-scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.'));
});
};
@@ -10,10 +10,13 @@ module.exports = function registerCanvasSizeRoutes(app, deps) {
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderCanvasSizesPage({
canvasSizes: data.canvasSizes || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
+46 -3
View File
@@ -1,22 +1,65 @@
module.exports = function registerClientsRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const buildDashboardState = deps.buildDashboardState;
const { buildPagination } = require('../../../lib/pagination');
const { sortRows, createSearchMatcher } = require('../../../lib/list-query');
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 10;
function sortClients(clients, sortKey, sortDirection) {
const normalizedSortKey = String(sortKey || '').trim();
const normalizedDirection = String(sortDirection || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
const accessors = {
client: function (client) { return String(client && (client.client_name || client.name || client.clientId) || '').trim(); },
screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); },
slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); },
ip: function (client) { return String(client && client.clientIp || '').trim(); },
viewport: function (client) {
const viewport = client && client.viewport;
if (!viewport || !viewport.width || !viewport.height) {
return '';
}
return `${Number(viewport.width) || 0}x${Number(viewport.height) || 0}`;
},
connected: function (client) { return String(client && (client.connectedAt || client.lastSeenAt) || '').trim(); }
};
if (!accessors[normalizedSortKey]) {
return Array.isArray(clients) ? clients.slice() : [];
}
return sortRows(clients, function (client) {
return accessors[normalizedSortKey](client);
}, normalizedDirection);
}
app.get('/clients', requirePermission('clients.read'), async function (req, res, next) {
try {
const data = await buildDashboardState(pool);
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const totalItems = Array.isArray(data.clients) ? data.clients.length : 0;
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const matchesSearch = createSearchMatcher(search, [
'name',
'client_name',
'clientId',
'deviceId',
'slug',
'screen_slug',
'ipAddress',
'status'
]);
const filteredClients = (data.clients || []).filter(matchesSearch);
const sortedClients = sortClients(filteredClients, sort, direction);
const startIndex = (page - 1) * LIST_PAGE_SIZE;
const clients = (data.clients || []).slice(startIndex, startIndex + LIST_PAGE_SIZE);
const clients = sortedClients.slice(startIndex, startIndex + LIST_PAGE_SIZE);
res.send(pages.renderConnectedClientsPage({
clients: clients,
pagination: buildPagination(totalItems, page, 'page', {}, LIST_PAGE_SIZE, 'clients', 'Client pages')
pagination: buildPagination(sortedClients.length, page, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'clients', 'Client pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
+92 -2
View File
@@ -61,6 +61,86 @@ function getCanvasSignature(width, height) {
return normalizedWidth + 'x' + normalizedHeight;
}
function hasVideoRegion(contentJson) {
if (!contentJson) {
return false;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
const region = parsed[key];
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video';
}));
} catch (_error) {
return false;
}
}
function getVideoSourcePath(slide) {
if (!slide) {
return '';
}
const directMediaPath = String(slide.media_path || '').trim();
if (String(slide.media_type || '').trim().toLowerCase() === 'video' && directMediaPath) {
return directMediaPath;
}
const contentJson = slide.content_json;
if (!contentJson) {
return directMediaPath;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return directMediaPath;
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && String(region.value || '').trim();
});
return videoRegion ? String(videoRegion.value || '').trim() : directMediaPath;
} catch (_error) {
return directMediaPath;
}
}
function getVideoDurationSeconds(slide) {
if (!slide) {
return null;
}
const contentJson = slide.content_json;
if (!contentJson) {
return null;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return null;
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
});
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
return Number.isFinite(duration) && duration > 0 ? duration : null;
} catch (_error) {
return null;
}
}
function getVideoDurationValue(slide) {
const storedDuration = Number(slide && slide.duration_seconds || 0);
const videoDuration = getVideoDurationSeconds(slide);
return Boolean(slide && slide.use_video_duration) && videoDuration ? videoDuration : storedDuration;
}
module.exports = function renderPlaylistEditPage(playlist, data, message, currentUser) {
const playlistSlides = (data.playlistSlides || [])
.filter((item) => item.playlist_id === playlist.id)
@@ -75,6 +155,11 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
isFirst: index === 0,
isLast: index === items.length - 1,
canvasSignature: getCanvasSignature(item.canvas_width, item.canvas_height) || '',
showVideoDurationButton: String(item.media_type || '').trim().toLowerCase() === 'video' || hasVideoRegion(item.content_json),
videoSourcePath: getVideoSourcePath(item),
videoDurationSeconds: getVideoDurationSeconds(item),
useVideoDuration: Boolean(item.use_video_duration),
durationSeconds: getVideoDurationValue(item),
scheduleSummary: scheduleSummary(item)
}));
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
@@ -91,7 +176,12 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
return getCanvasSignature(slide.canvas_width, slide.canvas_height) === playlistCanvasSignature;
}).map((slide) => Object.assign({}, slide, {
canvasSignature: getCanvasSignature(slide.canvas_width, slide.canvas_height) || '',
isAssigned: assignedSlideIds.has(slide.id)
isAssigned: assignedSlideIds.has(slide.id),
showVideoDurationButton: String(slide.media_type || '').trim().toLowerCase() === 'video' || hasVideoRegion(slide.content_json),
videoSourcePath: getVideoSourcePath(slide),
videoDurationSeconds: getVideoDurationSeconds(slide),
useVideoDuration: Boolean(slide.use_video_duration),
durationSeconds: getVideoDurationValue(slide)
}));
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned);
@@ -111,6 +201,6 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
selectableSlides: selectableSlides,
playlistCanvasSignature: playlistCanvasSignature,
playlistCanvasMismatch: playlistCanvasMismatch,
scripts: ['js/lib/modal.js', 'js/vendor/sortable.min.js', 'js/playlists/playlist-schedule.js']
scripts: ['js/lib/modal.js?v=20260726.7', 'js/vendor/sortable.min.js?v=20260726.7', 'js/playlists/playlist-schedule.js?v=20260726.7']
});
};
+5 -2
View File
@@ -17,6 +17,9 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
app.get('/playlists', requireQueryPermission('playlists.read', 'playlists.update'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
if (req.query.edit) {
const data = await common.fetchAdminData(pool);
const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit));
@@ -25,10 +28,10 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
}
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
}
const data = await common.fetchPlaylistsPage(pool, page, LIST_PAGE_SIZE);
const data = await common.fetchPlaylistsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderPlaylistsPage({
playlists: data.playlists || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'playlists', 'Playlist pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'playlists', 'Playlist pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
+37 -4
View File
@@ -15,10 +15,39 @@ module.exports = function registerScreensRoutes(app, deps) {
};
}
function applyConnectionCounts(screens, dashboardScreens) {
const countsById = new Map();
const countsBySlug = new Map();
(dashboardScreens || []).forEach(function (screen) {
const count = Number(screen && screen.player_connection_count || 0);
const id = screen && screen.id !== undefined && screen.id !== null ? String(screen.id) : '';
const slug = String(screen && screen.slug || '').trim();
if (id) {
countsById.set(id, count);
}
if (slug) {
countsBySlug.set(slug, count);
}
});
return (screens || []).map(function (screen) {
const slug = String(screen && screen.slug || '').trim();
const id = screen && screen.id !== undefined && screen.id !== null ? String(screen.id) : '';
const playerConnectionCount = countsBySlug.has(slug)
? countsBySlug.get(slug)
: countsById.get(id) || 0;
return Object.assign({}, screen, {
player_connection_count: playerConnectionCount
});
});
}
app.get('/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) {
try {
if (req.query.edit) {
const data = await buildDashboardState(pool);
const screen = await common.fetchScreenById(pool, Number(req.query.edit));
if (!screen) {
return res.status(404).send('Screen not found');
@@ -27,10 +56,14 @@ module.exports = function registerScreensRoutes(app, deps) {
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
}
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const dashboardState = await buildDashboardState(pool);
const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderScreensPage({
screens: data.screens || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'screens', 'Screen pages')
screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []),
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'screens', 'Screen pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
+5 -2
View File
@@ -10,10 +10,13 @@ module.exports = function registerSlidesRoutes(app, deps) {
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderSlidesPage({
slides: data.slides || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'slides', 'Slide pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'slides', 'Slide pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
+1
View File
@@ -9,6 +9,7 @@ function buildDefaultTemplate() {
canvas_size_height: 1080,
background_color: '#111111',
background_image_path: '',
region_usage: [],
regions: []
};
}
+3
View File
@@ -6,6 +6,9 @@ module.exports = function renderTemplatesPage(data, message, currentUser) {
name: template.name,
canvas_size_width: template.canvas_size_width,
canvas_size_height: template.canvas_size_height,
slideCount: template.slide_count !== undefined
? Number(template.slide_count) || 0
: 0,
regionCount: template.region_count !== undefined
? Number(template.region_count) || 0
: (data.templateRegions || []).filter((region) => region.template_id === template.id).length
+5 -2
View File
@@ -10,10 +10,13 @@ module.exports = function registerTemplatesRoutes(app, deps) {
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE);
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderTemplatesPage({
templates: data.templates || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'templates', 'Template pages')
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'templates', 'Template pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
+1 -1
View File
@@ -110,7 +110,7 @@ Handlebars.registerHelper('saveActionButtons', function (options) {
});
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'modal-shell.hbs'), 'utf8'));
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'table-pagination.hbs'), 'utf8'));
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'table', 'table-pagination.hbs'), 'utf8'));
function resolveTemplatePath(relativePath) {
const firstSegment = String(relativePath || '').split(/[\\/]/)[0];
@@ -22,11 +22,11 @@
<table class="table table-striped w-100 mb-0" data-table-searchable>
<thead>
<tr>
<th>Name</th>
<th>API URL</th>
<th>Refresh interval</th>
<th>Last pulled</th>
<th>Last response</th>
<th data-table-sort-key="name">Name</th>
<th data-table-sort-key="url">API URL</th>
<th data-table-sort-key="interval">Refresh interval</th>
<th data-table-sort-key="last_pulled">Last pulled</th>
<th data-table-sort-key="last_response">Last response</th>
<th>Actions</th>
</tr>
</thead>
@@ -22,10 +22,10 @@
<table class="table table-striped w-100 mb-0" data-table-searchable>
<thead>
<tr>
<th>Name</th>
<th>Feed URL</th>
<th>Refresh interval</th>
<th>Items pulled</th>
<th data-table-sort-key="name">Name</th>
<th data-table-sort-key="url">Feed URL</th>
<th data-table-sort-key="interval">Refresh interval</th>
<th data-table-sort-key="items">Items pulled</th>
<th>Actions</th>
</tr>
</thead>
@@ -1,264 +0,0 @@
<div id="background-tasks-state" data-state-version="{{stateVersion}}" hidden></div>
<div class="page-header">
<div>
<h2>Background tasks</h2>
<p>Queued data refreshes run in the web process. Finished items stay here until you clear them. Click a status box or source name to filter the results.</p>
</div>
</div>
<div id="background-tasks-filters">
{{#if hasActiveFilters}}
<div class="alert alert-light border d-flex flex-wrap align-items-center justify-content-between gap-2 py-2 mb-3">
<div class="small text-muted">
Showing results for {{#if activeStatusLabel}}{{activeStatusLabel}}{{else}}all tasks{{/if}}{{#if activeSourceLabel}} from {{activeSourceLabel}}{{/if}}.
</div>
<a class="btn btn-outline-secondary btn-sm" href="{{clearFiltersUrl}}" data-background-tasks-nav="clear">Clear filters</a>
</div>
{{/if}}
</div>
<div id="background-tasks-summary" class="row g-3 mb-4">
{{#each statusCards}}
<div class="col-6 col-xl-3">
<a href="{{href}}" class="d-block h-100 text-decoration-none text-body" data-background-tasks-nav="status" aria-label="{{ariaLabel}}"{{#if active}} aria-current="page"{{/if}}>
<div class="card card-outline card-{{variant}} h-100{{#if active}} border-2 border-{{variant}}{{/if}}">
<div class="card-body">
<div class="text-muted small text-uppercase">{{label}}</div>
<div class="fs-3 fw-semibold">{{count}}</div>
</div>
</div>
</a>
</div>
{{/each}}
</div>
<div id="background-tasks-task-card" class="card card-outline card-primary mb-4" data-pagination-card="tasks" data-table-search-container>
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Task queue</h3>
<div class="ms-auto d-flex flex-nowrap align-items-center gap-2 background-tasks-task-tools">
<div class="input-group input-group-sm background-tasks-task-search" style="width: min(12rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search tasks" aria-label="Search tasks" data-table-search />
</div>
{{#if canManage}}
{{#if canClearFinished}}
<form method="post" action="/settings/background-tasks/clear-finished">
<button type="submit" class="btn btn-outline-secondary btn-sm" data-confirm-message="Clear finished background tasks?">Clear finished</button>
</form>
{{/if}}
{{/if}}
</div>
</div>
<div class="card-body table-responsive p-0">
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
<thead>
<tr>
<th>Task</th>
<th>Category</th>
<th>Status</th>
<th>Queued</th>
<th>Started</th>
<th>Finished</th>
<th>Source</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{#if tasks.length}}
{{#each tasks}}
<tr data-table-search-row>
<td data-label="Task">
<div class="fw-semibold">{{title}}</div>
{{#if key}}
<div class="text-muted small text-break">{{key}}</div>
{{/if}}
{{#if errorMessage}}
<div class="text-danger small mt-1 text-break">{{errorMessage}}</div>
{{/if}}
</td>
<td data-label="Category">{{category}}</td>
<td data-label="Status">
{{#if (eq status 'queued')}}
<span class="badge text-bg-secondary">Queued</span>
{{else if (eq status 'running')}}
<span class="badge text-bg-primary">Running</span>
{{else if (eq status 'completed')}}
<span class="badge text-bg-success">Completed</span>
{{else if (eq status 'failed')}}
<span class="badge text-bg-danger">Failed</span>
{{else if (eq status 'canceled')}}
<span class="badge text-bg-warning">Canceled</span>
{{else}}
<span class="badge text-bg-light text-dark">{{status}}</span>
{{/if}}
</td>
<td data-label="Queued">
{{#if createdAt}}
<time data-local-datetime datetime="{{createdAt}}">{{createdAt}}</time>
{{else}}
-
{{/if}}
</td>
<td data-label="Started">
{{#if startedAt}}
<time data-local-datetime datetime="{{startedAt}}">{{startedAt}}</time>
{{else}}
-
{{/if}}
</td>
<td data-label="Finished">
{{#if finishedAt}}
<time data-local-datetime datetime="{{finishedAt}}">{{finishedAt}}</time>
{{else}}
-
{{/if}}
</td>
<td data-label="Source">
{{#if metadata.sourceName}}
<div>{{metadata.sourceName}}</div>
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
{{else}}
<span class="empty">-</span>
{{/if}}
</td>
<td data-label="Actions">
<div class="d-flex flex-wrap gap-2">
{{#if canManage}}
{{#if (eq status 'queued')}}
<form method="post" action="/settings/background-tasks/{{id}}/cancel">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm-message="Cancel this queued task?">Cancel</button>
</form>
{{/if}}
{{#if (eq status 'failed')}}
<form method="post" action="/settings/background-tasks/{{id}}/retry">
<button type="submit" class="btn btn-outline-primary btn-sm">Retry</button>
</form>
{{/if}}
{{/if}}
</div>
</td>
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default>
<td colspan="8" class="empty">No background tasks are queued right now.</td>
</tr>
{{/if}}
</tbody>
</table>
</div>
{{#if pagination.hasMultiplePages}}
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
<div class="text-muted small">
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} tasks
</div>
<nav aria-label="Background task pages">
<ul class="pagination pagination-sm mb-0">
<li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}">
<a class="page-link" href="{{#if pagination.hasPrevious}}/settings/background-tasks{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
</li>
{{#each pagination.pages}}
<li class="page-item {{#if active}}active{{/if}}">
<a class="page-link" href="/settings/background-tasks{{url}}">{{number}}</a>
</li>
{{/each}}
<li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}">
<a class="page-link" href="{{#if pagination.hasNext}}/settings/background-tasks{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
</li>
</ul>
</nav>
</div>
{{/if}}
</div>
<div id="background-tasks-recurring-card" class="card card-outline card-secondary" data-pagination-card="recurring" data-table-search-container>
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Scheduled refreshes</h3>
<div class="ms-auto d-flex flex-nowrap align-items-center gap-2 background-tasks-recurring-tools">
<div class="input-group input-group-sm background-tasks-recurring-search" style="width: min(12rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search scheduled" aria-label="Search scheduled refreshes" data-table-search />
</div>
</div>
</div>
<div class="card-body table-responsive p-0">
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
<thead>
<tr>
<th>Task</th>
<th>Interval</th>
<th>Next run</th>
<th>Last run</th>
<th>Last result</th>
<th>Source</th>
</tr>
</thead>
<tbody>
{{#if recurringTasks.length}}
{{#each recurringTasks}}
<tr data-table-search-row>
<td data-label="Task">
<div class="fw-semibold">{{title}}</div>
<div class="text-muted small text-break">{{key}}</div>
</td>
<td data-label="Interval">{{intervalLabel}}</td>
<td data-label="Next run">
{{#if nextRunAt}}
<time data-local-datetime datetime="{{nextRunAt}}">{{nextRunAt}}</time>
{{else}}-{{/if}}
</td>
<td data-label="Last run">
{{#if lastRunAt}}
<time data-local-datetime datetime="{{lastRunAt}}">{{lastRunAt}}</time>
{{else}}-{{/if}}
</td>
<td data-label="Last result">
{{#if lastStatus}}
<span class="badge {{#if (eq lastStatus 'failed')}}text-bg-danger{{else if (eq lastStatus 'completed')}}text-bg-success{{else}}text-bg-secondary{{/if}}">{{lastStatus}}</span>
{{#if lastError}}<div class="text-danger small mt-1 text-break">{{lastError}}</div>{{/if}}
{{else}}
<span class="empty">-</span>
{{/if}}
</td>
<td data-label="Source">
{{#if metadata.sourceName}}
<div>{{metadata.sourceName}}</div>
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
{{else}}
<span class="empty">-</span>
{{/if}}
</td>
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default>
<td colspan="6" class="empty">No scheduled refreshes are registered yet.</td>
</tr>
{{/if}}
</tbody>
</table>
</div>
{{#if recurringPagination.hasMultiplePages}}
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
<div class="text-muted small">
Showing {{recurringPagination.startItem}}-{{recurringPagination.endItem}} of {{recurringPagination.totalItems}} scheduled refreshes
</div>
<nav aria-label="Scheduled refresh pages">
<ul class="pagination pagination-sm mb-0">
<li class="page-item {{#unless recurringPagination.hasPrevious}}disabled{{/unless}}">
<a class="page-link" href="{{#if recurringPagination.hasPrevious}}/settings/background-tasks{{recurringPagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
</li>
{{#each recurringPagination.pages}}
<li class="page-item {{#if active}}active{{/if}}">
<a class="page-link" href="/settings/background-tasks{{url}}">{{number}}</a>
</li>
{{/each}}
<li class="page-item {{#unless recurringPagination.hasNext}}disabled{{/unless}}">
<a class="page-link" href="{{#if recurringPagination.hasNext}}/settings/background-tasks{{recurringPagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
</li>
</ul>
</nav>
</div>
{{/if}}
</div>
+5 -5
View File
@@ -24,10 +24,10 @@
<table class="table table-striped w-100 mb-0 users-table" data-table-searchable>
<thead>
<tr>
<th>Role</th>
<th>Description</th>
<th>Users</th>
<th>Permissions</th>
<th data-table-sort-key="role">Role</th>
<th data-table-sort-key="description">Description</th>
<th data-table-sort-key="users">Users</th>
<th data-table-sort-key="permissions">Permissions</th>
<th>Actions</th>
</tr>
</thead>
@@ -38,7 +38,7 @@
<td data-label="Role">
<div class="user-cell">
<div>
<strong>{{name}}</strong>
{{name}}
</div>
</div>
</td>
@@ -0,0 +1,132 @@
<div class="page-header">
<div>
<h2>Background tasks</h2>
<p>Queued data refreshes run in the web process. Finished items stay here until you clear them.</p>
</div>
</div>
<div id="background-tasks-filters">
{{#if hasActiveFilters}}
<div class="alert alert-light border d-flex flex-wrap align-items-center justify-content-between gap-2 py-2 mb-3">
<div class="small text-muted">
{{#if activeSourceLabel}}Showing results from {{activeSourceLabel}}.{{/if}}
</div>
<a class="btn btn-outline-secondary btn-sm" href="{{clearFiltersUrl}}" data-background-tasks-nav="clear">Clear filters</a>
</div>
{{/if}}
</div>
<div id="background-tasks-summary" class="row g-3 mb-4">
{{#each statusCards}}
<div class="col-6 col-xl-3">
<div class="card card-outline card-{{variant}} h-100{{#if active}} border-2 border-{{variant}}{{/if}}">
<div class="card-body">
<div class="text-muted small text-uppercase">{{label}}</div>
<div class="fs-3 fw-semibold">{{count}}</div>
</div>
</div>
</div>
{{/each}}
</div>
<div id="background-tasks-task-card" class="card card-outline card-primary mb-4" data-pagination-card="tasks" data-table-search-container>
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Task Queue</h3>
<div class="ms-auto d-flex flex-nowrap align-items-center gap-2 background-tasks-task-tools">
<div class="input-group input-group-sm background-tasks-task-search" style="width: min(12rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search tasks" aria-label="Search tasks" data-table-search />
</div>
{{#if canManage}}
{{#if canClearFinished}}
<form method="post" action="/settings/tasks-background/clear-finished">
<button type="submit" class="btn btn-outline-secondary btn-sm" data-confirm-message="Clear finished background tasks?">Clear finished</button>
</form>
{{/if}}
{{/if}}
</div>
</div>
<div class="card-body table-responsive p-0">
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
<thead>
<tr>
<th data-table-sort-key="task">Task</th>
<th data-table-sort-key="category">Category</th>
<th data-table-sort-key="status">Status</th>
<th data-table-sort-key="queued">Queued</th>
<th data-table-sort-key="started">Started</th>
<th data-table-sort-key="finished">Finished</th>
<th data-table-sort-key="source">Source</th>
</tr>
</thead>
<tbody>
{{#if tasks.length}}
{{#each tasks}}
<tr data-table-search-row>
<td data-label="Task">
<div class="fw-semibold">{{title}}</div>
{{#if key}}
<div class="text-muted small text-break">{{key}}</div>
{{/if}}
{{#if errorMessage}}
<div class="text-danger small mt-1 text-break">{{errorMessage}}</div>
{{/if}}
</td>
<td data-label="Category">{{category}}</td>
<td data-label="Status">
{{#if (eq status 'queued')}}
<span class="badge text-bg-secondary">Queued</span>
{{else if (eq status 'running')}}
<span class="badge text-bg-primary">Running</span>
{{else if (eq status 'completed')}}
<span class="badge text-bg-success">Completed</span>
{{else if (eq status 'failed')}}
<span class="badge text-bg-danger">Failed</span>
{{else if (eq status 'canceled')}}
<span class="badge text-bg-warning">Canceled</span>
{{else}}
<span class="badge text-bg-light text-dark">{{status}}</span>
{{/if}}
</td>
<td data-label="Queued">
{{#if createdAt}}
<time data-local-datetime datetime="{{createdAt}}">{{createdAt}}</time>
{{else}}
-
{{/if}}
</td>
<td data-label="Started">
{{#if startedAt}}
<time data-local-datetime datetime="{{startedAt}}">{{startedAt}}</time>
{{else}}
-
{{/if}}
</td>
<td data-label="Finished">
{{#if finishedAt}}
<time data-local-datetime datetime="{{finishedAt}}">{{finishedAt}}</time>
{{else}}
-
{{/if}}
</td>
<td data-label="Source">
{{#if metadata.sourceName}}
<div>{{metadata.sourceName}}</div>
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
{{else}}
<span class="empty">-</span>
{{/if}}
</td>
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default>
<td colspan="7" class="empty">No background tasks are queued right now.</td>
</tr>
{{/if}}
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/settings/tasks-background"}}
</div>
</div>
@@ -0,0 +1,85 @@
<div class="page-header">
<div>
<h2>Scheduled tasks</h2>
<p>Use this page to view and manage recurring tasks.</p>
</div>
</div>
<div id="background-tasks-recurring-card" class="card card-outline card-secondary" data-pagination-card="recurring" data-table-search-container>
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Scheduled Tasks</h3>
<div class="ms-auto d-flex flex-nowrap align-items-center background-tasks-recurring-tools">
<div class="input-group input-group-sm background-tasks-recurring-search">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search scheduled" aria-label="Search scheduled refreshes" data-table-search />
</div>
</div>
</div>
<div class="card-body table-responsive p-0">
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
<thead>
<tr>
<th data-table-sort-key="task">Task</th>
<th data-table-sort-key="interval">Interval</th>
<th data-table-sort-key="next_run">Next run</th>
<th data-table-sort-key="last_run">Last run</th>
<th data-table-sort-key="last_result">Last result</th>
<th data-table-sort-key="source">Source</th>
{{#if canManage}}<th>Actions</th>{{/if}}
</tr>
</thead>
<tbody>
{{#if recurringTasks.length}}
{{#each recurringTasks}}
<tr data-table-search-row>
<td data-label="Task">
<div class="fw-semibold">{{title}}</div>
<div class="text-muted small text-break">{{key}}</div>
</td>
<td data-label="Interval">{{intervalLabel}}</td>
<td data-label="Next run">
{{#if nextRunAt}}
<time data-local-datetime datetime="{{nextRunAt}}">{{nextRunAt}}</time>
{{else}}-{{/if}}
</td>
<td data-label="Last run">
{{#if lastRunAt}}
<time data-local-datetime datetime="{{lastRunAt}}">{{lastRunAt}}</time>
{{else}}-{{/if}}
</td>
<td data-label="Last result">
{{#if lastStatus}}
<span class="badge text-capitalize {{#if (eq lastStatus 'failed')}}text-bg-danger{{else if (eq lastStatus 'completed')}}text-bg-success{{else}}text-bg-secondary{{/if}}">{{lastStatus}}</span>
{{#if lastError}}<div class="text-danger small mt-1 text-break">{{lastError}}</div>{{/if}}
{{else}}
<span class="empty">-</span>
{{/if}}
</td>
<td data-label="Source">
{{#if metadata.sourceName}}
<div>{{metadata.sourceName}}</div>
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
{{else}}
<span class="empty">-</span>
{{/if}}
</td>
{{#if ../canManage}}
<td data-label="Actions">
<div class="d-flex flex-wrap gap-2">
<form method="post" action="/settings/tasks-scheduled/recurring/{{key}}/run">
<button type="submit" class="btn btn-outline-primary btn-sm">Run now</button>
</form>
</div>
</td>
{{/if}}
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default>
<td colspan="{{#if canManage}}7{{else}}6{{/if}}" class="empty">No scheduled refreshes are registered yet.</td>
</tr>
{{/if}}
</tbody>
</table>
</div>
</div>
+59 -59
View File
@@ -5,70 +5,70 @@
</div>
</div>
<div class="row g-4">
<div class="col-12 col-xl-4">
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">User details</h3>
<form id="user-form" method="post" action="/users">
<div class="row g-4">
<div class="col-12 col-xl-4">
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">User details</h3>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="user-username" class="form-label">Username</label>
<input id="user-username" type="text" name="username" class="form-control" autocomplete="username" value="{{formValues.username}}" required />
</div>
<div class="col-12 col-md-6">
<label for="user-name" class="form-label">Name</label>
<input id="user-name" type="text" name="name" class="form-control" autocomplete="name" value="{{formValues.name}}" required />
</div>
<div class="col-12 col-md-6">
<label for="user-password" class="form-label">Password</label>
<input id="user-password" type="password" name="password" class="form-control" autocomplete="new-password" minlength="8" required />
</div>
<div class="col-12 col-md-6">
<label for="user-confirm-password" class="form-label">Confirm password</label>
<input id="user-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required />
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="User actions">
{{{saveActionButtons formId="user-form" saveLabel="Save" showSaveAndClose=false showSaveAndNew=false}}}
<a class="btn btn-warning" href="/users" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
</div>
</div>
</div>
<form id="user-form" method="post" action="/users">
</div>
<div class="col-12 col-xl-8">
<div class="card card-outline card-secondary admin-form-card">
<div class="card-header">
<h3 class="card-title">Roles</h3>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="user-username" class="form-label">Username</label>
<input id="user-username" type="text" name="username" class="form-control" autocomplete="username" value="{{formValues.username}}" required />
</div>
<div class="col-12 col-md-6">
<label for="user-name" class="form-label">Name</label>
<input id="user-name" type="text" name="name" class="form-control" autocomplete="name" value="{{formValues.name}}" required />
</div>
<div class="col-12 col-md-6">
<label for="user-password" class="form-label">Password</label>
<input id="user-password" type="password" name="password" class="form-control" autocomplete="new-password" minlength="8" required />
</div>
<div class="col-12 col-md-6">
<label for="user-confirm-password" class="form-label">Confirm password</label>
<input id="user-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required />
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="User actions">
{{{saveActionButtons formId="user-form" saveLabel="Save" showSaveAndClose=false showSaveAndNew=false}}}
<a class="btn btn-warning" href="/users" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
</div>
</div>
</form>
</div>
</div>
<div class="col-12 col-xl-8">
<div class="card card-outline card-secondary admin-form-card">
<div class="card-header">
<h3 class="card-title">Roles</h3>
</div>
<div class="card-body">
<div class="row g-3">
{{#if roles.length}}
{{#each roles}}
<div class="col-4">
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
<span class="form-check-label pe-4">
<strong>{{name}}</strong>
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
</span>
</label>
{{#if roles.length}}
{{#each roles}}
<div class="col-4">
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
<span class="form-check-label pe-4">
<strong>{{name}}</strong>
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
</span>
</label>
</div>
{{/each}}
{{else}}
<div class="col-12">
<div class="alert alert-warning mb-0">Create at least one role before adding users.</div>
</div>
{{/each}}
{{else}}
<div class="col-12">
<div class="alert alert-warning mb-0">Create at least one role before adding users.</div>
</div>
{{/if}}
{{/if}}
</div>
<div class="form-text mt-2">Select at least one role so the new user can sign in with access.</div>
</div>
<div class="form-text mt-2">Select at least one role so the new user can sign in with access.</div>
</div>
</div>
</div>
</div>
</form>
+3 -3
View File
@@ -22,9 +22,9 @@
<table class="table table-striped w-100 mb-0 users-table" data-table-searchable>
<thead>
<tr>
<th>User</th>
<th>Name</th>
<th>Roles</th>
<th data-table-sort-key="user">User</th>
<th data-table-sort-key="name">Name</th>
<th data-table-sort-key="roles">Roles</th>
<th>Actions</th>
</tr>
</thead>
+6 -3
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html>
<html data-bs-theme="dark" style="color-scheme: dark;">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -82,9 +82,12 @@
{{/if}}
<script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script>
<script src="/assets/adminlte/js/adminlte.min.js"></script>
<script src="/assets/js/admin/toast.js"></script>
<script src="/assets/js/web-ui-helpers.js"></script>
<script src="/assets/js/toast.js"></script>
<script src="/assets/js/table/table-sort.js"></script>
<script src="/assets/js/table/table-search.js"></script>
<script src="/assets/js/admin/admin-page.js"></script>
<script src="/assets/js/admin/system-status.js"></script>
<script src="/assets/js/system-status.js"></script>
{{#if scripts.length}}
{{#each scripts}}
<script src="/assets/{{this}}"></script>
+36 -13
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html>
<html data-bs-theme="dark" style="color-scheme: dark;">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
@@ -17,13 +17,13 @@
{{/each}}
{{/if}}
</head>
<body class="hold-transition layout-fixed sidebar-expand-lg bg-body-tertiary {{#if authShell}}login-page{{/if}} {{bodyClass}}">
<body class="hold-transition layout-fixed sidebar-expand-lg bg-body-tertiary {{#if authShell}}login-page{{/if}} {{bodyClass}}"{{#if authShell}} data-bs-theme="dark" style="color-scheme: dark;"{{else if errorShell}} data-bs-theme="dark" style="color-scheme: dark;"{{/if}}>
{{#if errorShell}}
<main class="d-flex align-items-center justify-content-center min-vh-100 p-3 {{bodyClass}}">
{{{body}}}
</main>
<script src="/assets/js/admin/admin-page.js"></script>
<script src="/assets/js/admin/system-status.js"></script>
<script src="/assets/js/system-status.js"></script>
{{else if authShell}}
<main class="login-page d-flex align-items-center justify-content-center min-vh-100 p-3">
<div class="login-box">
@@ -31,7 +31,7 @@
</div>
</main>
<script src="/assets/js/admin/admin-page.js"></script>
<script src="/assets/js/admin/system-status.js"></script>
<script src="/assets/js/system-status.js"></script>
{{else}}
<div id="app-toast-container" class="toast-container position-fixed top-0 end-0 p-3">
{{#if message}}
@@ -213,7 +213,7 @@
</li>
{{/if}}
{{/if}}
{{#if (anyPermission currentUser 'background-tasks.read' 'users.read' 'rbac.read')}}
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read')}}
<li class="nav-header">SETTINGS</li>
{{#if (hasPermission currentUser 'users.read')}}
<li class="nav-item">
@@ -231,12 +231,33 @@
</a>
</li>
{{/if}}
{{#if (anyPermission currentUser 'background-tasks.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/background-tasks">
<i class="nav-icon bi bi-list-task"></i>
<p>Background tasks</p>
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read')}}
<li class="nav-item has-treeview{{#if backgroundTasksMenuOpen}} menu-open{{/if}}">
<a class="nav-link{{#if backgroundTasksMenuOpen}} active{{/if}}" href="#">
<i class="nav-icon bi bi-pen"></i>
<p>
Tasks
<i class="nav-arrow bi bi-chevron-right"></i>
</p>
</a>
{{#if (anyPermission currentUser 'background-tasks.read')}}
<ul class="nav nav-treeview">
<li class="nav-item">
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/tasks-background">
<i class="nav-icon bi bi-list-ol"></i>
<p>Task queue</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'scheduled-tasks.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'background-tasks-scheduled')}}active{{/if}}" href="/settings/tasks-scheduled">
<i class="nav-icon bi bi-calendar-event"></i>
<p>Scheduled tasks</p>
</a>
</li>
{{/if}}
</ul>
</li>
{{/if}}
{{/if}}
@@ -263,8 +284,10 @@
</dialog>
<script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script>
<script src="/assets/adminlte/js/adminlte.min.js"></script>
<script src="/assets/js/admin/toast.js"></script>
<script src="/assets/js/table-sort.js"></script>
<script src="/assets/js/web-ui-helpers.js"></script>
<script src="/assets/js/toast.js"></script>
<script src="/assets/js/table/table-sort.js"></script>
<script src="/assets/js/table/table-search.js"></script>
<script src="/assets/js/admin/admin-page.js"></script>
{{#if scripts.length}}
{{#each scripts}}
@@ -275,7 +298,7 @@
{{/if}}
{{/each}}
{{/if}}
<script src="/assets/js/admin/system-status.js"></script>
<script src="/assets/js/system-status.js"></script>
{{/if}}
</body>
</html>
@@ -1,17 +1,23 @@
{{#if pagination.hasMultiplePages}}
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
<div class="card-footer d-flex flex-wrap align-items-center">
<div class="text-muted small">
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} {{pagination.itemLabel}}
</div>
<nav aria-label="{{pagination.ariaLabel}}">
<nav class="ms-auto" aria-label="{{pagination.ariaLabel}}">
<ul class="pagination pagination-sm mb-0">
<li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}">
<a class="page-link" href="{{#if pagination.hasPrevious}}{{basePath}}{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
</li>
{{#each pagination.pages}}
<li class="page-item {{#if active}}active{{/if}}">
<a class="page-link" href="{{../basePath}}{{url}}">{{number}}</a>
</li>
{{#if ellipsis}}
<li class="page-item disabled" aria-hidden="true">
<span class="page-link">...</span>
</li>
{{else}}
<li class="page-item {{#if active}}active{{/if}}">
<a class="page-link" href="{{../basePath}}{{url}}">{{number}}</a>
</li>
{{/if}}
{{/each}}
<li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}">
<a class="page-link" href="{{#if pagination.hasNext}}{{basePath}}{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
+3 -3
View File
@@ -22,9 +22,9 @@
<table class="table table-striped w-100 mb-0" data-table-searchable>
<thead>
<tr>
<th>Name</th>
<th>Dimensions</th>
<th>Templates</th>
<th data-table-sort-key="name">Name</th>
<th data-table-sort-key="dimensions">Dimensions</th>
<th data-table-sort-key="templates">Templates</th>
<th>Actions</th>
</tr>
</thead>
+6 -6
View File
@@ -19,12 +19,12 @@
<table id="dashboard-clients-table" class="table table-striped w-100 mb-0" data-has-actions-column="{{#if (hasPermission currentUser 'clients.allow')}}true{{else}}false{{/if}}" data-table-searchable>
<thead>
<tr>
<th>Client</th>
<th>Current Screen</th>
<th>Current Slide</th>
<th>IP</th>
<th>Viewport</th>
<th>Connected/Updated</th>
<th data-table-sort-key="client">Client</th>
<th data-table-sort-key="screen">Current Screen</th>
<th data-table-sort-key="slide">Current Slide</th>
<th data-table-sort-key="ip">IP</th>
<th data-table-sort-key="viewport">Viewport</th>
<th data-table-sort-key="connected">Connected/Updated</th>
{{#if (hasPermission currentUser 'clients.allow')}}<th>Actions</th>{{/if}}
</tr>
</thead>
+53 -34
View File
@@ -10,7 +10,7 @@
<div>
<span class="dashboard-hero-kicker">Live overview</span>
<h3 class="dashboard-hero-title">Keep the control surface focused on live state and actions.</h3>
<p class="dashboard-hero-copy">Use the cards below for the current totals, then jump to the screens table when you need a deeper look at playback and connection state.</p>
<p class="dashboard-hero-copy">Use the cards below for the current totals, then open the screen snapshot when you want a quick read on playlist assignment and live connections.</p>
</div>
<div class="dashboard-hero-stats">
{{#if (hasPermission currentUser "playlists.read")}}
@@ -63,6 +63,22 @@
><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload all clients</button>
<span class="dashboard-action-help">Restart every connected player page.</span>
</form>
<form
method="post"
action="/commands"
class="dashboard-action-form"
data-confirm-message="Pause all connected clients?"
data-async-command
>
<input type="hidden" name="command" value="pause" />
<input type="hidden" name="paused" value="true" />
<button
type="submit"
class="btn btn-info dashboard-action-button"
id="dashboard-pause-all-button"
><i class="bi bi-pause-fill me-1" aria-hidden="true"></i>Pause all clients</button>
<span class="dashboard-action-help">Freeze playback on every connected player.</span>
</form>
<form
method="post"
action="/commands"
@@ -91,46 +107,49 @@
{{#if (hasPermission currentUser "screens.read")}}
<div class="row">
<div class="col-12">
<div class="card card-outline card-secondary dashboard-table-card">
<div class="card-header">
<div class="card card-outline card-secondary dashboard-screen-card-shell">
<div class="card-header dashboard-screen-card-header">
<div class="dashboard-card-heading">
<h3 class="card-title">Current screens</h3>
<h3 class="card-title">Screen snapshot</h3>
<p class="dashboard-card-subtitle">Player URL, playlist assignment,
and live connection count.</p>
and live connection count without the spreadsheet feel.</p>
</div>
<a class="btn btn-sm btn-outline-primary" href="/screens">Manage screens</a>
</div>
<div class="card-body table-responsive p-0">
<table
id="dashboard-screens-table"
class="table table-striped w-100 mb-0"
>
<thead><tr><th>Name</th><th>Player URL</th><th>Playlist</th><th
>Connected clients</th></tr></thead>
<tbody>
{{#if screens.length}}
{{#each screens}}
<tr data-client-key="{{id}}">
<td data-label="Name">{{name}}</td>
<td data-label="Player URL"><a
href="{{playerUrl slug}}"
target="_blank"
>{{playerUrl slug}}</a></td>
<td data-label="Playlist">{{playlist_name}}</td>
<td data-label="Connected clients">
<div class="card-body">
<div id="dashboard-screens-grid" class="dashboard-screen-grid">
{{#if screens.length}}
{{#each screens}}
<article class="dashboard-screen-tile" data-screen-key="{{id}}">
<div class="dashboard-screen-tile-top">
<div class="dashboard-screen-tile-text">
<h4 class="dashboard-screen-name">{{name}}</h4>
<a class="dashboard-screen-link" href="{{playerUrl slug}}" target="_blank">{{playerUrl slug}}</a>
</div>
<span class="dashboard-screen-pill {{#if player_connection_count}}is-live{{else}}is-idle{{/if}}">
{{#if player_connection_count}}
<div class="connection-count">{{player_connection_count}}
connected</div>
{{player_connection_count}} live
{{else}}
<span class="empty">No clients connected.</span>
No clients
{{/if}}
</td>
</tr>
{{/each}}
{{else}}
<tr><td colspan="4" class="empty">No screens yet.</td></tr>
{{/if}}
</tbody>
</table>
</span>
</div>
<dl class="dashboard-screen-meta">
<div>
<dt>Playlist</dt>
<dd>{{#if playlist_name}}{{playlist_name}}{{else}}Unassigned{{/if}}</dd>
</div>
<div>
<dt>Connections</dt>
<dd>{{#if player_connection_count}}{{player_connection_count}} connected{{else}}No clients connected{{/if}}</dd>
</div>
</dl>
</article>
{{/each}}
{{else}}
<div class="empty dashboard-screen-empty">No screens yet.</div>
{{/if}}
</div>
</div>
</div>
</div>
+22 -8
View File
@@ -83,7 +83,7 @@
<tbody id="playlist-items-body" data-playlist-id="{{playlist.id}}">
{{#if playlistSlides.length}}
{{#each playlistSlides}}
<tr data-playlist-slide-row data-row-key="existing-{{id}}" data-slide-id="{{slide_id}}" data-canvas-signature="{{canvasSignature}}">
<tr data-playlist-slide-row data-row-key="existing-{{id}}" data-slide-id="{{slide_id}}" data-canvas-signature="{{canvasSignature}}" data-media-type="{{media_type}}" data-media-path="{{media_path}}" data-video-source-path="{{videoSourcePath}}" data-video-duration-seconds="{{videoDurationSeconds}}" data-use-video-duration="{{#if useVideoDuration}}true{{else}}false{{/if}}">
<td class="playlist-order-cell" data-label="Order">
<div class="playlist-order-cell-inner">
<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder">
@@ -100,7 +100,7 @@
</td>
<td data-label="Slide">
<div class="playlist-slide-cell">
<div class="playlist-slide-thumb" aria-hidden="true">
<div class="playlist-slide-thumb" aria-hidden="true" {{#if canvas_width}}style="--playlist-slide-thumb-aspect-ratio: {{canvas_width}} / {{canvas_height}};"{{/if}}>
{{#if thumbnail_path}}
<img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" />
{{else}}
@@ -123,7 +123,16 @@
<input type="hidden" name="schedule_days_json[]" value="{{schedule_days_json}}" form="playlist-edit-form" />
</td>
<td data-label="Duration">
<input name="duration_seconds[]" type="number" min="1" value="{{duration_seconds}}" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" />
<div class="playlist-duration-field">
<input name="duration_seconds[]" type="text" inputmode="decimal" value="{{durationSeconds}}" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" {{#if useVideoDuration}}disabled{{/if}} />
<input type="hidden" name="use_video_duration[]" value="{{#if useVideoDuration}}1{{else}}0{{/if}}" form="playlist-edit-form" />
{{#if useVideoDuration}}
<input type="hidden" name="duration_seconds[]" value="{{durationSeconds}}" form="playlist-edit-form" data-video-duration-mirror />
{{/if}}
{{#if showVideoDurationButton}}
<button type="button" class="btn btn-outline-secondary btn-sm playlist-use-video-duration{{#if useVideoDuration}} active{{/if}}" data-use-video-duration aria-pressed="{{#if useVideoDuration}}true{{else}}false{{/if}}">{{#if useVideoDuration}}Use Video Duration{{else}}Use video duration{{/if}}</button>
{{/if}}
</div>
</td>
<td data-label="Actions">
<div class="actions playlist-item-actions">
@@ -153,11 +162,16 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body playlist-slide-picker-modal-body">
<div class="playlist-slide-picker-toolbar">
<label class="form-label mb-1" for="playlist-slide-picker-search">Search</label>
<div class="input-group">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" id="playlist-slide-picker-search" placeholder="Search slide titles" autocomplete="off" />
<div class="playlist-slide-picker-toolbar d-flex flex-column flex-lg-row gap-3 align-items-stretch align-items-lg-end">
<div class="flex-grow-1">
<label class="form-label mb-1" for="playlist-slide-picker-search">Search</label>
<div class="input-group">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" id="playlist-slide-picker-search" placeholder="Search slide titles" autocomplete="off" />
</div>
</div>
<div class="playlist-slide-picker-filter mb-0">
<button type="button" class="btn btn-outline-secondary playlist-slide-picker-filter-button" id="playlist-slide-picker-show-assigned" aria-pressed="false">Show already added slides</button>
</div>
</div>
<div class="playlist-slide-picker-empty empty is-hidden" id="playlist-slide-picker-empty">No slides match your search.</div>
+2 -2
View File
@@ -22,8 +22,8 @@
<table class="table table-striped w-100 mb-0" data-table-searchable>
<thead>
<tr>
<th>Name</th>
<th>Slides</th>
<th data-table-sort-key="name">Name</th>
<th data-table-sort-key="slides">Slides</th>
<th>Actions</th>
</tr>
</thead>
+3 -3
View File
@@ -22,9 +22,9 @@
<table class="table table-striped w-100 mb-0" data-table-searchable>
<thead>
<tr>
<th>Name</th>
<th>Player URL</th>
<th>Playlist</th>
<th data-table-sort-key="name">Name</th>
<th data-table-sort-key="url">Player URL</th>
<th data-table-sort-key="playlist">Playlist</th>
<th>Connected clients</th>
<th>Actions</th>
</tr>
+46 -4
View File
@@ -6,6 +6,7 @@
</div>
<div class="slide-editor-shell">
<link rel="stylesheet" href="/assets/vendor/cropperjs/cropper.min.css" />
<form method="post" action="{{action}}" enctype="multipart/form-data" id="slide-form" class="slide-form-stack" data-async-save-close-url="/slides" data-async-save-new-url="/slides/new">
<div class="card card-outline card-primary admin-form-card slide-template-card">
<div class="card-header">
@@ -35,7 +36,7 @@
<div class="slide-editor-sidebar">
<div class="card card-outline card-primary admin-form-card mb-3">
<div class="card-body d-grid gap-2">
<div class="card-body d-grid">
<div class="btn-group template-actions-group slide-actions-group" role="group" aria-label="Slide actions">
{{{saveActionButtons formId="slide-form" saveLabel=saveLabel saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
<a class="btn btn-warning" href="/slides" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
@@ -47,7 +48,7 @@
</div>
<aside class="card card-outline card-secondary admin-form-card slide-preview-card">
<div class="card-header d-flex align-items-center justify-content-between gap-2">
<div class="card-header d-flex align-items-center justify-content-between">
<div class="slide-preview-header">
<h3>Preview</h3>
</div>
@@ -56,7 +57,7 @@
<div class="card-body slide-preview-scroll">
<div class="slide-preview-stage" id="slide-preview-stage">
<div class="slide-preview-canvas" id="slide-preview-canvas">
<img id="slide-preview-background" class="slide-preview-background" alt="" style="display:none;" />
<img id="slide-preview-background" class="slide-preview-background d-none" alt="" />
<div class="slide-preview-empty" id="slide-preview-empty">Select a template to preview the slide.</div>
<div class="slide-preview-overlay" id="slide-preview-overlay"></div>
</div>
@@ -67,10 +68,51 @@
</div>
</div>
{{#> modal-shell modalId="slide-image-cropper-modal" modalLabelId="slide-image-cropper-modal-label" modalDialogClass="modal-dialog-centered modal-xl modal-dialog-scrollable" modalBackdropStatic=true modalKeyboardDisabled=true}}
<div class="modal-header">
<div>
<h2 class="modal-title fs-5" id="slide-image-cropper-modal-label">Edit image</h2>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body d-grid gap-3">
<div class="slide-image-cropper-frame">
<img id="slide-image-cropper-image" alt="Selected image to crop" />
</div>
<div class="btn-toolbar flex-wrap gap-2 slide-image-cropper-toolbar" role="toolbar" aria-label="Image editing controls">
<div class="btn-group btn-group-sm" role="group" aria-label="Rotate">
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="rotate-left"><i class="bi bi-arrow-counterclockwise" aria-hidden="true"></i> Rotate left</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="rotate-right"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i> Rotate right</button>
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Flip">
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="flip-horizontal"><i class="bi bi-symmetry-vertical" aria-hidden="true"></i> Flip horizontal</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="flip-vertical"><i class="bi bi-symmetry-horizontal" aria-hidden="true"></i> Flip vertical</button>
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Crop ratio presets">
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="free">Free</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="1:1">1:1</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="4:3">4:3</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="16:9">16:9</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="region">Region</button>
</div>
<button type="button" class="btn btn-outline-danger btn-sm" data-slide-image-cropper-action="reset"><i class="bi bi-arrow-repeat" aria-hidden="true"></i> Reset</button>
</div>
<p class="small text-body-secondary mb-0" id="slide-image-cropper-status"></p>
</div>
<div class="modal-footer justify-content-end">
<div class="btn-group" role="group" aria-label="Crop actions">
<button type="button" class="btn btn-primary" data-slide-image-cropper-action="apply">Apply crop</button>
<button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
{{/modal-shell}}
{{#if isEdit}}
<form id="delete-slide-form" method="post" action="/slides/{{slide.id}}/delete" data-confirm-message="Delete this slide?"></form>
{{/if}}
<textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea>
<script type="module" src="/assets/js/slides/slide-form.js?v=20260725"></script>
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
<script type="module" src="/assets/js/slides/slide-form.js?v=20260726.5"></script>
<script type="module" src="/assets/js/slides/slide-image-cropper.js?v=20260726.5"></script>
+4 -4
View File
@@ -22,9 +22,9 @@
<table class="table table-striped w-100 mb-0" data-table-searchable>
<thead>
<tr>
<th>Thumbnail</th>
<th>Title</th>
<th>Template</th>
<th data-sortable="false">Thumbnail</th>
<th data-table-sort-key="title">Title</th>
<th data-table-sort-key="template">Template</th>
<th>Actions</th>
</tr>
</thead>
@@ -33,7 +33,7 @@
{{#each slides}}
<tr data-table-search-row data-search-text="{{title}} {{template_name}}">
<td data-label="Thumbnail">
<div class="playlist-slide-thumb" aria-hidden="true">
<div class="playlist-slide-thumb" aria-hidden="true" {{#if canvas_width}}style="--playlist-slide-thumb-aspect-ratio: {{canvas_width}} / {{canvas_height}};"{{/if}}>
{{#if thumbnail_path}}
<img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" />
{{else}}
+8 -6
View File
@@ -5,13 +5,14 @@
</div>
</div>
<form method="post" action="/templates" enctype="multipart/form-data" id="template-form">
<form method="post" action="/templates" enctype="multipart/form-data" id="template-form" data-template-has-slides="{{#if template.slide_count}}true{{else}}false{{/if}}">
<input type="hidden" name="canvas_size_id" id="canvas-size-id" value="{{template.canvas_size_id}}" />
<input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" />
<input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" />
<input type="hidden" id="canvas-size-summary" />
<input type="hidden" name="existing_background_image_path" value="{{template.background_image_path}}" />
<input type="hidden" name="regions_json" id="regions-json" value="" />
<textarea id="template-region-usage" hidden>{{json template.region_usage}}</textarea>
<div class="template-designer-layout">
<div class="card card-outline card-secondary admin-form-card mb-3">
@@ -30,7 +31,7 @@
</div>
<div class="template-designer-sidebar">
<div class="card card-outline card-primary admin-form-card">
<div class="card-body d-grid gap-2">
<div class="card-body d-grid">
<div class="btn-group template-actions-group" role="group" aria-label="Template actions">
{{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" showSaveAndClose=false showSaveAndNew=false}}}
<a class="btn btn-warning" href="/templates" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
@@ -114,19 +115,20 @@
<div class="modal-body">
<p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="text">Text</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="video">Video</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="webpage">Webpage</button>
</div>
</div>
{{/modal-shell}}
<template id="region-card-template">
<div class="card card-outline card-secondary admin-form-card region-item mb-3">
<div class="card card-outline card-secondary admin-form-card region-item">
<div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div>
<div class="card-body p-3 d-grid gap-3">
<div class="region-field-grid region-field-grid--identity">
@@ -145,8 +147,8 @@
<label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label>
<label>Height<input class="form-control" type="number" name="region_height[]" value="120" required /></label>
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" placeholder="16:9 or 5:4" /></label>
<label>Remove<button type="button" class="btn btn-sm btn-outline-danger w-100" data-region-remove-button>Remove</button></label>
</div>
<div class="d-flex justify-content-end"><button type="button" class="btn btn-sm btn-outline-danger remove-region">Remove</button></div>
</div>
</div>
</template>
+8 -6
View File
@@ -5,13 +5,14 @@
</div>
</div>
<form method="post" action="/templates/{{template.id}}" enctype="multipart/form-data" id="template-form" data-async-save data-async-save-close-url="/templates" data-async-save-new-url="/templates/new">
<form method="post" action="/templates/{{template.id}}" enctype="multipart/form-data" id="template-form" data-template-has-slides="{{#if template.slide_count}}true{{else}}false{{/if}}" data-async-save data-async-save-close-url="/templates" data-async-save-new-url="/templates/new">
<input type="hidden" name="canvas_size_id" id="canvas-size-id" value="{{template.canvas_size_id}}" />
<input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" />
<input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" />
<input type="hidden" id="canvas-size-summary" />
<input type="hidden" name="existing_background_image_path" value="{{template.background_image_path}}" />
<input type="hidden" name="regions_json" id="regions-json" value="" />
<textarea id="template-region-usage" hidden>{{json template.region_usage}}</textarea>
<div class="template-designer-layout">
<div class="card card-outline card-secondary admin-form-card mb-3">
@@ -30,7 +31,7 @@
</div>
<div class="template-designer-sidebar">
<div class="card card-outline card-primary admin-form-card">
<div class="card-body d-grid gap-2">
<div class="card-body d-grid">
<div class="btn-group template-actions-group" role="group" aria-label="Template actions">
{{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
<a class="btn btn-warning" href="/templates" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
@@ -121,19 +122,20 @@
<div class="modal-body">
<p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="text">Text</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="video">Video</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="webpage">Webpage</button>
</div>
</div>
{{/modal-shell}}
<template id="region-card-template">
<div class="card card-outline card-secondary admin-form-card region-item mb-3">
<div class="card card-outline card-secondary admin-form-card region-item">
<div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div>
<div class="card-body p-3 d-grid gap-3">
<div class="region-field-grid region-field-grid--identity">
@@ -152,8 +154,8 @@
<label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label>
<label>Height<input class="form-control" type="number" name="region_height[]" value="120" required /></label>
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" placeholder="16:9 or 5:4" /></label>
<label>Remove<button type="button" class="btn btn-sm btn-outline-danger w-100" data-region-remove-button disabled>Remove</button></label>
</div>
<div class="d-flex justify-content-end"><button type="button" class="btn btn-sm btn-outline-danger remove-region">Remove</button></div>
</div>
</div>
</template>

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