Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07ec17ad91 | ||
|
|
b5fbf79af7 | ||
|
|
4ca4da473f | ||
|
|
f4de31269d | ||
|
|
235d2da6aa | ||
|
|
d24c9c4035 | ||
|
|
04ddec50ad | ||
|
|
47613e61a2 | ||
|
|
bd130a1070 | ||
|
|
d48f0e779f | ||
|
|
90ec3bb2df | ||
|
|
0ca20035cd | ||
|
|
c18f597068 |
@@ -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:
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -6,6 +6,70 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
- No unreleased changes recorded yet.
|
- No unreleased changes recorded yet.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- Slide thumbnail refresh now uses the player-rendered preview again and signs the preview GET request in the same way the player verifies it.
|
||||||
|
- Background tasks settings page and client script were repaired after a syntax break so the web app can boot cleanly.
|
||||||
|
|
||||||
|
## 1.5.7 - 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
|
## 1.5.3 - 2026-07-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+37
-91
@@ -1,20 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "1.5.3",
|
"version": "1.5.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "1.5.3",
|
"version": "1.5.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sparticuz/chromium": "^137.0.0",
|
"@sparticuz/chromium": "^137.0.0",
|
||||||
"bootstrap-icons": "1.11.3",
|
"bootstrap-icons": "1.11.3",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"hls.js": "^1.5.15",
|
"hls.js": "^1.5.15",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^2.2.0",
|
||||||
"mysql2": "^3.14.3",
|
"mysql2": "^3.14.3",
|
||||||
"puppeteer-core": "^24.16.0",
|
"puppeteer-core": "^24.16.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
@@ -1158,17 +1159,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/concat-stream": {
|
"node_modules/concat-stream": {
|
||||||
"version": "1.6.2",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
"engines": [
|
"engines": [
|
||||||
"node >= 0.8"
|
"node >= 6.0"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-from": "^1.0.0",
|
"buffer-from": "^1.0.0",
|
||||||
"inherits": "^2.0.3",
|
"inherits": "^2.0.3",
|
||||||
"readable-stream": "^2.2.2",
|
"readable-stream": "^3.0.2",
|
||||||
"typedarray": "^0.0.6"
|
"typedarray": "^0.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1208,10 +1209,10 @@
|
|||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/core-util-is": {
|
"node_modules/cropperjs": {
|
||||||
"version": "1.0.3",
|
"version": "1.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.2.tgz",
|
||||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
"integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/data-uri-to-buffer": {
|
"node_modules/data-uri-to-buffer": {
|
||||||
@@ -2074,12 +2075,6 @@
|
|||||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/locate-path": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
@@ -2213,18 +2208,6 @@
|
|||||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
@@ -2232,22 +2215,22 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "1.4.5-lts.2",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||||
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
|
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||||
"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.",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"append-field": "^1.0.0",
|
"append-field": "^1.0.0",
|
||||||
"busboy": "^1.0.0",
|
"busboy": "^1.6.0",
|
||||||
"concat-stream": "^1.5.2",
|
"concat-stream": "^2.0.0",
|
||||||
"mkdirp": "^0.5.4",
|
"type-is": "^1.6.18"
|
||||||
"object-assign": "^4.1.1",
|
|
||||||
"type-is": "^1.6.4",
|
|
||||||
"xtend": "^4.0.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6.0.0"
|
"node": ">= 10.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mysql2": {
|
"node_modules/mysql2": {
|
||||||
@@ -2388,15 +2371,6 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -2573,12 +2547,6 @@
|
|||||||
"node": ">=10.13.0"
|
"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": {
|
"node_modules/progress": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||||
@@ -2774,25 +2742,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readable-stream": {
|
"node_modules/readable-stream": {
|
||||||
"version": "2.3.8",
|
"version": "3.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-util-is": "~1.0.0",
|
"inherits": "^2.0.3",
|
||||||
"inherits": "~2.0.3",
|
"string_decoder": "^1.1.1",
|
||||||
"isarray": "~1.0.0",
|
"util-deprecate": "^1.0.1"
|
||||||
"process-nextick-args": "~2.0.0",
|
|
||||||
"safe-buffer": "~5.1.1",
|
|
||||||
"string_decoder": "~1.1.1",
|
|
||||||
"util-deprecate": "~1.0.1"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/readable-stream/node_modules/safe-buffer": {
|
"engines": {
|
||||||
"version": "5.1.2",
|
"node": ">= 6"
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
}
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
@@ -3165,20 +3126,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.1.1",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"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": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"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": {
|
"node_modules/y18n": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "1.5.3",
|
"version": "1.5.10",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -18,11 +18,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sparticuz/chromium": "^137.0.0",
|
"@sparticuz/chromium": "^137.0.0",
|
||||||
"bootstrap-icons": "1.11.3",
|
"bootstrap-icons": "1.11.3",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"hls.js": "^1.5.15",
|
"hls.js": "^1.5.15",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^2.2.0",
|
||||||
"mysql2": "^3.14.3",
|
"mysql2": "^3.14.3",
|
||||||
"puppeteer-core": "^24.16.0",
|
"puppeteer-core": "^24.16.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
|||||||
@@ -732,6 +732,15 @@ const migrations = [
|
|||||||
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
|
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: 'media-path-prefix-rename',
|
key: 'media-path-prefix-rename',
|
||||||
version: appVersion,
|
version: appVersion,
|
||||||
|
|||||||
+11
-1
@@ -109,10 +109,20 @@ const PERMISSION_SECTIONS = [
|
|||||||
name: 'Background tasks',
|
name: 'Background tasks',
|
||||||
sectionName: 'Settings',
|
sectionName: 'Settings',
|
||||||
actions: [
|
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: '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',
|
key: 'users',
|
||||||
order: 80,
|
order: 80,
|
||||||
|
|||||||
+46
-5
@@ -47,6 +47,19 @@ function canonicalize(value) {
|
|||||||
return value === undefined ? undefined : 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) {
|
function stableJson(value) {
|
||||||
return JSON.stringify(canonicalize(value));
|
return JSON.stringify(canonicalize(value));
|
||||||
}
|
}
|
||||||
@@ -56,10 +69,38 @@ function hashPayload(value) {
|
|||||||
return crypto.createHash('sha256').update(normalized).digest('hex');
|
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) {
|
function getRequestPath(req) {
|
||||||
const explicitPath = String(req && req.path ? req.path : '').trim();
|
const explicitPath = String(req && req.path ? req.path : '').trim();
|
||||||
if (explicitPath) {
|
if (explicitPath) {
|
||||||
return explicitPath;
|
return normalizeRequestPath(explicitPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawUrl = String(req && req.url ? req.url : '').trim();
|
const rawUrl = String(req && req.url ? req.url : '').trim();
|
||||||
@@ -68,9 +109,9 @@ function getRequestPath(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return new URL(rawUrl, 'http://localhost').pathname;
|
return normalizeRequestPath(new URL(rawUrl, 'http://localhost').pathname);
|
||||||
} catch (_error) {
|
} 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 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 timestamp = String(options && options.timestamp || Date.now()).trim();
|
||||||
const bodyDigest = hashPayload(options && Object.prototype.hasOwnProperty.call(options, 'body') ? options.body : null);
|
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}`);
|
const signature = signText(secret, `request\n${method}\n${pathname}\n${timestamp}\n${bodyDigest}`);
|
||||||
@@ -199,7 +240,7 @@ function verifyRequestAuth(req) {
|
|||||||
return false;
|
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);
|
return timingSafeEqualHex(expectedSignature, signature);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
-1
@@ -25,6 +25,7 @@ const rbacData = require('./web/lib/rbac-data');
|
|||||||
const { createPlayerActionService } = require('./web/lib/player-actions');
|
const { createPlayerActionService } = require('./web/lib/player-actions');
|
||||||
const { isClientNameAvailable, withClientNameReservation } = require('./data/client-name-check');
|
const { isClientNameAvailable, withClientNameReservation } = require('./data/client-name-check');
|
||||||
const { createSessionService } = require('./web/lib/session');
|
const { createSessionService } = require('./web/lib/session');
|
||||||
|
const { hasAnyPermission } = require('./rbac');
|
||||||
const {
|
const {
|
||||||
formatDashboardDate,
|
formatDashboardDate,
|
||||||
readArrayField,
|
readArrayField,
|
||||||
@@ -92,12 +93,15 @@ async function start() {
|
|||||||
dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000),
|
dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000),
|
||||||
formatDashboardDate: formatDashboardDate,
|
formatDashboardDate: formatDashboardDate,
|
||||||
notifyPlayerScreens: notifyPlayerScreens,
|
notifyPlayerScreens: notifyPlayerScreens,
|
||||||
backgroundTaskQueue: backgroundTaskQueue
|
backgroundTaskQueue: backgroundTaskQueue,
|
||||||
|
hasAnyPermission: hasAnyPermission,
|
||||||
});
|
});
|
||||||
const upload = webBootstrap.upload;
|
const upload = webBootstrap.upload;
|
||||||
const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide;
|
const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide;
|
||||||
const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate;
|
const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate;
|
||||||
const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload;
|
const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload;
|
||||||
|
const removeUnusedUploadFiles = webBootstrap.removeUnusedUploadFiles;
|
||||||
|
const collectUploadPathsFromDirectory = webBootstrap.collectUploadPathsFromDirectory;
|
||||||
const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange;
|
const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange;
|
||||||
const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer;
|
const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer;
|
||||||
const runMediaSyncTask = webBootstrap.runMediaSyncTask;
|
const runMediaSyncTask = webBootstrap.runMediaSyncTask;
|
||||||
@@ -163,6 +167,7 @@ async function start() {
|
|||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use('/assets', express.static(ASSET_DIR));
|
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));
|
app.use('/media', express.static(MEDIA_DIR));
|
||||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||||
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
|
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
|
||||||
@@ -285,12 +290,14 @@ async function start() {
|
|||||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||||
|
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||||
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||||
getAuditUserId: getAuditUserId,
|
getAuditUserId: getAuditUserId,
|
||||||
redirectAfterSave: redirectAfterSave,
|
redirectAfterSave: redirectAfterSave,
|
||||||
notifyPlayerScreens: notifyPlayerScreens,
|
notifyPlayerScreens: notifyPlayerScreens,
|
||||||
broadcastDashboardState: broadcastDashboardState,
|
broadcastDashboardState: broadcastDashboardState,
|
||||||
backgroundTaskQueue: backgroundTaskQueue,
|
backgroundTaskQueue: backgroundTaskQueue,
|
||||||
|
hasAnyPermission: hasAnyPermission,
|
||||||
getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage,
|
getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage,
|
||||||
getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage,
|
getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage,
|
||||||
getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage,
|
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() {
|
async function scheduleInitialDataSourceRefreshes() {
|
||||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||||
@@ -462,6 +491,7 @@ async function start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await syncRecurringRefreshes();
|
await syncRecurringRefreshes();
|
||||||
|
await registerRecurringMaintenanceTasks();
|
||||||
scheduleInitialDataSourceRefreshes().catch(function (error) {
|
scheduleInitialDataSourceRefreshes().catch(function (error) {
|
||||||
console.warn('Unable to schedule startup data source refreshes:', error);
|
console.warn('Unable to schedule startup data source refreshes:', error);
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+4
@@ -120,6 +120,8 @@ function createWebBootstrap(options) {
|
|||||||
const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide;
|
const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide;
|
||||||
const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate;
|
const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate;
|
||||||
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
|
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
|
||||||
|
const removeUnusedUploadFiles = uploadSyncService.removeUnusedUploadFiles;
|
||||||
|
const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory;
|
||||||
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
|
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
|
||||||
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
|
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
|
||||||
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
|
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
|
||||||
@@ -210,6 +212,8 @@ function createWebBootstrap(options) {
|
|||||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||||
|
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||||
|
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
|
||||||
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||||
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
|
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
|
||||||
runMediaSyncTask: runMediaSyncTask,
|
runMediaSyncTask: runMediaSyncTask,
|
||||||
|
|||||||
@@ -357,6 +357,16 @@ function createBackgroundTaskQueue(options) {
|
|||||||
scheduleRecurringRun(job, job.intervalMs);
|
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) {
|
function syncRecurringTaskState(task, status, errorMessage) {
|
||||||
const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey);
|
const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey);
|
||||||
if (!recurringKey) {
|
if (!recurringKey) {
|
||||||
@@ -737,6 +747,7 @@ function createBackgroundTaskQueue(options) {
|
|||||||
setTaskHandler: setTaskHandler,
|
setTaskHandler: setTaskHandler,
|
||||||
registerRecurringTask: registerRecurringTask,
|
registerRecurringTask: registerRecurringTask,
|
||||||
removeRecurringTask: removeRecurringTask,
|
removeRecurringTask: removeRecurringTask,
|
||||||
|
runRecurringTask: runRecurringTask,
|
||||||
listTasks: listTasks,
|
listTasks: listTasks,
|
||||||
listRecurringTasks: listRecurringTasks,
|
listRecurringTasks: listRecurringTasks,
|
||||||
getTaskById: getTaskById,
|
getTaskById: getTaskById,
|
||||||
|
|||||||
+25
-10
@@ -17,6 +17,29 @@ function buildQueryString(query) {
|
|||||||
return queryString ? `?${queryString}` : '';
|
return queryString ? `?${queryString}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPaginationPages(totalPages, currentPage, pageParam, queryState, maxVisiblePages) {
|
||||||
|
const pages = [];
|
||||||
|
const visiblePageCount = Math.max(1, Math.min(Number(maxVisiblePages) || 7, totalPages));
|
||||||
|
let startPage = Math.max(1, currentPage - Math.floor(visiblePageCount / 2));
|
||||||
|
let endPage = startPage + visiblePageCount - 1;
|
||||||
|
|
||||||
|
if (endPage > totalPages) {
|
||||||
|
endPage = totalPages;
|
||||||
|
startPage = Math.max(1, endPage - visiblePageCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let pageNumber = startPage; pageNumber <= endPage; pageNumber += 1) {
|
||||||
|
const nextQuery = Object.assign({}, queryState, { [pageParam]: pageNumber });
|
||||||
|
pages.push({
|
||||||
|
number: pageNumber,
|
||||||
|
active: pageNumber === currentPage,
|
||||||
|
url: buildQueryString(nextQuery)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
function buildPagination(totalItems, currentPage, pageParam, queryState, pageSize, itemLabel, ariaLabel) {
|
function buildPagination(totalItems, currentPage, pageParam, queryState, pageSize, itemLabel, ariaLabel) {
|
||||||
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
|
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
|
||||||
const normalizedPageSize = Math.max(1, Number(pageSize) || 10);
|
const normalizedPageSize = Math.max(1, Number(pageSize) || 10);
|
||||||
@@ -24,16 +47,7 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
|
|||||||
const safeCurrentPage = Math.min(normalizePageNumber(currentPage), totalPages);
|
const safeCurrentPage = Math.min(normalizePageNumber(currentPage), totalPages);
|
||||||
const startIndex = totalItems <= 0 ? 0 : (safeCurrentPage - 1) * normalizedPageSize;
|
const startIndex = totalItems <= 0 ? 0 : (safeCurrentPage - 1) * normalizedPageSize;
|
||||||
const endIndex = totalItems <= 0 ? 0 : Math.min(totalItems, startIndex + normalizedPageSize);
|
const endIndex = totalItems <= 0 ? 0 : Math.min(totalItems, startIndex + normalizedPageSize);
|
||||||
const pages = [];
|
const pages = buildPaginationPages(totalPages, safeCurrentPage, normalizedPageParam, queryState, 7);
|
||||||
|
|
||||||
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 previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
|
||||||
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
|
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
|
||||||
@@ -60,5 +74,6 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
normalizePageNumber,
|
normalizePageNumber,
|
||||||
buildQueryString,
|
buildQueryString,
|
||||||
|
buildPaginationPages,
|
||||||
buildPagination
|
buildPagination
|
||||||
};
|
};
|
||||||
+38
-121
@@ -120,124 +120,6 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
|||||||
return buildTextRegionMarkup(region, regionContent);
|
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() {
|
async function launchBrowser() {
|
||||||
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
||||||
return fs.existsSync(candidate);
|
return fs.existsSync(candidate);
|
||||||
@@ -304,6 +186,42 @@ async function captureSlideThumbnail(options) {
|
|||||||
|
|
||||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
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();
|
const browser = await launchBrowser();
|
||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
@@ -312,12 +230,11 @@ async function captureSlideThumbnail(options) {
|
|||||||
const previewUrl = baseUrl + previewPath;
|
const previewUrl = baseUrl + previewPath;
|
||||||
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
|
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
pathname: previewPath,
|
pathname: previewPath
|
||||||
body: {}
|
|
||||||
}));
|
}));
|
||||||
await page.setViewport(PLAYER_VIEWPORT);
|
await page.setViewport(PLAYER_VIEWPORT);
|
||||||
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
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');
|
const canvas = await page.$('.slide-canvas');
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
throw new Error('Player render did not produce a slide canvas.');
|
throw new Error('Player render did not produce a slide canvas.');
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function createUploadSyncService(options) {
|
|||||||
const playerSnapshotCache = options && options.playerSnapshotCache;
|
const playerSnapshotCache = options && options.playerSnapshotCache;
|
||||||
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
||||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||||
|
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
||||||
|
|
||||||
let playerUploadSyncMode = null;
|
let playerUploadSyncMode = null;
|
||||||
let playerUploadSyncModePromise = null;
|
let playerUploadSyncModePromise = null;
|
||||||
@@ -40,7 +41,12 @@ function createUploadSyncService(options) {
|
|||||||
cb(null, `${stamp}${safeExt}`);
|
cb(null, `${stamp}${safeExt}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return multer({ storage });
|
return multer({
|
||||||
|
storage: storage,
|
||||||
|
limits: {
|
||||||
|
fileSize: MAX_UPLOAD_BYTES
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUploadReference(uploadPath) {
|
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) {
|
async function getPlayerUploadSyncMode(localUploadDir) {
|
||||||
if (playerUploadSyncMode) {
|
if (playerUploadSyncMode) {
|
||||||
return playerUploadSyncMode;
|
return playerUploadSyncMode;
|
||||||
@@ -639,6 +690,7 @@ function createUploadSyncService(options) {
|
|||||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||||
countUploadReferences: countUploadReferences,
|
countUploadReferences: countUploadReferences,
|
||||||
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||||
|
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
|
||||||
getPlayerUploadSyncMode: getPlayerUploadSyncMode,
|
getPlayerUploadSyncMode: getPlayerUploadSyncMode,
|
||||||
shouldMirrorUploads: shouldMirrorUploads,
|
shouldMirrorUploads: shouldMirrorUploads,
|
||||||
queuePlayerUploadSync: queuePlayerUploadSync,
|
queuePlayerUploadSync: queuePlayerUploadSync,
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ module.exports = {
|
|||||||
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')),
|
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')),
|
||||||
renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')),
|
renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')),
|
||||||
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
|
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'),
|
renderErrorPage: require('./error'),
|
||||||
renderRbacPage: require(routePath('settings', 'rbac', 'list')),
|
renderRbacPage: require(routePath('settings', 'rbac', 'list')),
|
||||||
renderRbacAddPage: require(routePath('settings', 'rbac', 'add')),
|
renderRbacAddPage: require(routePath('settings', 'rbac', 'add')),
|
||||||
|
|||||||
@@ -787,6 +787,242 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
z-index: 0;
|
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-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 0;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.template-designer-layout {
|
.template-designer-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 20rem;
|
grid-template-columns: minmax(0, 1fr) 20rem;
|
||||||
@@ -1054,7 +1290,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0;
|
||||||
min-height: 3.25rem;
|
min-height: 3.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1265,6 +1501,19 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
box-shadow: none;
|
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 {
|
.playlist-slide-picker-card.is-hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -1322,6 +1571,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
padding: 0 0.9rem 0.6rem;
|
padding: 0 0.9rem 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-filter-button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.playlist-slide-picker-meta {
|
.playlist-slide-picker-meta {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -1335,7 +1588,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
background: rgba(var(--bs-body-bg-rgb), 0.88);
|
background: rgba(var(--bs-body-bg-rgb), 0.88);
|
||||||
border-radius: 999px;
|
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);
|
box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1345,6 +1598,17 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
|
|
||||||
.playlist-slide-picker-badge {
|
.playlist-slide-picker-badge {
|
||||||
display: none;
|
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 {
|
.playlist-slide-picker-empty.is-hidden {
|
||||||
@@ -1438,8 +1702,9 @@ table.table > :not(caption) > * > * {
|
|||||||
|
|
||||||
.playlist-slide-thumb {
|
.playlist-slide-thumb {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 5.5rem;
|
max-width: 5.5rem;
|
||||||
height: 3.5rem;
|
max-height: 5.5rem;
|
||||||
|
aspect-ratio: var(--playlist-slide-thumb-aspect-ratio, 16 / 9);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: rgba(15, 23, 42, 0.08);
|
background: rgba(15, 23, 42, 0.08);
|
||||||
@@ -1456,13 +1721,19 @@ table.table > :not(caption) > * > * {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.playlist-slide-thumb-placeholder {
|
.playlist-slide-thumb-placeholder {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-width: 5.5rem;
|
||||||
|
min-height: 3.5rem;
|
||||||
color: var(--bs-secondary-color, #6c757d);
|
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 {
|
.playlist-order-cell {
|
||||||
|
|||||||
@@ -308,6 +308,7 @@
|
|||||||
var addSlideModal = document.getElementById('playlist-add-slide-modal');
|
var addSlideModal = document.getElementById('playlist-add-slide-modal');
|
||||||
var addSlideGrid = document.getElementById('playlist-slide-picker-grid');
|
var addSlideGrid = document.getElementById('playlist-slide-picker-grid');
|
||||||
var addSlideSearch = document.getElementById('playlist-slide-picker-search');
|
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 addSlideEmpty = document.getElementById('playlist-slide-picker-empty');
|
||||||
var addSlideConfirm = document.getElementById('playlist-confirm-add-slides');
|
var addSlideConfirm = document.getElementById('playlist-confirm-add-slides');
|
||||||
var addSlideCount = document.getElementById('playlist-slide-picker-selected-count');
|
var addSlideCount = document.getElementById('playlist-slide-picker-selected-count');
|
||||||
@@ -408,6 +409,7 @@
|
|||||||
var media = document.createElement('div');
|
var media = document.createElement('div');
|
||||||
var title = document.createElement('div');
|
var title = document.createElement('div');
|
||||||
var check = document.createElement('span');
|
var check = document.createElement('span');
|
||||||
|
var badge = document.createElement('span');
|
||||||
var searchText = String(slide && slide.title ? slide.title : '').toLowerCase();
|
var searchText = String(slide && slide.title ? slide.title : '').toLowerCase();
|
||||||
|
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
@@ -415,6 +417,7 @@
|
|||||||
button.setAttribute('aria-pressed', 'false');
|
button.setAttribute('aria-pressed', 'false');
|
||||||
button.setAttribute('data-slide-id', String(slide.id || ''));
|
button.setAttribute('data-slide-id', String(slide.id || ''));
|
||||||
button.setAttribute('data-search-text', searchText);
|
button.setAttribute('data-search-text', searchText);
|
||||||
|
button.setAttribute('data-is-assigned', slide && slide.isAssigned ? 'true' : 'false');
|
||||||
|
|
||||||
if (slide && slide.isAssigned) {
|
if (slide && slide.isAssigned) {
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
@@ -427,6 +430,11 @@
|
|||||||
check.setAttribute('aria-hidden', 'true');
|
check.setAttribute('aria-hidden', 'true');
|
||||||
media.appendChild(check);
|
media.appendChild(check);
|
||||||
|
|
||||||
|
badge.className = 'playlist-slide-picker-badge badge text-bg-primary';
|
||||||
|
badge.textContent = 'Already in playlist';
|
||||||
|
badge.setAttribute('aria-hidden', 'true');
|
||||||
|
media.appendChild(badge);
|
||||||
|
|
||||||
title.className = 'playlist-slide-picker-title';
|
title.className = 'playlist-slide-picker-title';
|
||||||
title.textContent = String(slide && slide.title ? slide.title : 'Slide');
|
title.textContent = String(slide && slide.title ? slide.title : 'Slide');
|
||||||
|
|
||||||
@@ -436,6 +444,16 @@
|
|||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function renderSlidePicker() {
|
||||||
if (!addSlideGrid) {
|
if (!addSlideGrid) {
|
||||||
return;
|
return;
|
||||||
@@ -451,19 +469,31 @@
|
|||||||
|
|
||||||
function syncSlidePickerState() {
|
function syncSlidePickerState() {
|
||||||
var query = String(addSlideSearch && addSlideSearch.value ? addSlideSearch.value : '').trim().toLowerCase();
|
var query = String(addSlideSearch && addSlideSearch.value ? addSlideSearch.value : '').trim().toLowerCase();
|
||||||
|
var showAssignedSlides = Boolean(addSlideShowAssigned && addSlideShowAssigned.getAttribute('aria-pressed') === 'true');
|
||||||
var visibleCount = 0;
|
var visibleCount = 0;
|
||||||
|
var assignedCount = 0;
|
||||||
|
|
||||||
slidePickerCards.forEach(function (card) {
|
slidePickerCards.forEach(function (card) {
|
||||||
var searchText = String(card.getAttribute('data-search-text') || '');
|
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);
|
setCardVisible(card, matches);
|
||||||
|
if (isAssigned) {
|
||||||
|
assignedCount += 1;
|
||||||
|
}
|
||||||
if (matches) {
|
if (matches) {
|
||||||
visibleCount += 1;
|
visibleCount += 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (addSlideEmpty) {
|
if (addSlideEmpty) {
|
||||||
|
if (!showAssignedSlides && assignedCount && visibleCount === 0) {
|
||||||
|
addSlideEmpty.textContent = query ? 'No available slides match your search.' : 'No available slides are currently left for this playlist.';
|
||||||
|
} else {
|
||||||
addSlideEmpty.textContent = query ? 'No slides match your search.' : 'No slides are currently available for this playlist.';
|
addSlideEmpty.textContent = query ? 'No slides match your search.' : 'No slides are currently available for this playlist.';
|
||||||
|
}
|
||||||
addSlideEmpty.classList.toggle('is-hidden', visibleCount !== 0);
|
addSlideEmpty.classList.toggle('is-hidden', visibleCount !== 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,6 +504,8 @@
|
|||||||
if (addSlideConfirm) {
|
if (addSlideConfirm) {
|
||||||
addSlideConfirm.disabled = slidePickerSelection.size === 0;
|
addSlideConfirm.disabled = slidePickerSelection.size === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setShowAssignedState(showAssignedSlides);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCardSelection(card) {
|
function toggleCardSelection(card) {
|
||||||
@@ -512,6 +544,8 @@
|
|||||||
row_key: 'new-' + Date.now() + '-' + slide.id,
|
row_key: 'new-' + Date.now() + '-' + slide.id,
|
||||||
slide_id: String(slide.id),
|
slide_id: String(slide.id),
|
||||||
thumbnail_path: String(slide.thumbnail_path || ''),
|
thumbnail_path: String(slide.thumbnail_path || ''),
|
||||||
|
canvas_width: slide.canvas_width,
|
||||||
|
canvas_height: slide.canvas_height,
|
||||||
canvas_signature: String(slide.canvasSignature || ''),
|
canvas_signature: String(slide.canvasSignature || ''),
|
||||||
title: slide.title || 'Slide',
|
title: slide.title || 'Slide',
|
||||||
duration_seconds: 10,
|
duration_seconds: 10,
|
||||||
@@ -626,7 +660,10 @@
|
|||||||
var isAssigned = Boolean(activeSlideIds[slideId]);
|
var isAssigned = Boolean(activeSlideIds[slideId]);
|
||||||
var isCanvasMismatch = Boolean(allowedCanvasSignature && canvasSignature && canvasSignature !== allowedCanvasSignature);
|
var isCanvasMismatch = Boolean(allowedCanvasSignature && canvasSignature && canvasSignature !== allowedCanvasSignature);
|
||||||
|
|
||||||
|
slide.isAssigned = isAssigned;
|
||||||
|
|
||||||
if (card) {
|
if (card) {
|
||||||
|
card.setAttribute('data-is-assigned', isAssigned ? 'true' : 'false');
|
||||||
card.disabled = isAssigned || isCanvasMismatch;
|
card.disabled = isAssigned || isCanvasMismatch;
|
||||||
card.classList.toggle('is-assigned', isAssigned);
|
card.classList.toggle('is-assigned', isAssigned);
|
||||||
card.classList.toggle('is-mismatch', isCanvasMismatch);
|
card.classList.toggle('is-mismatch', isCanvasMismatch);
|
||||||
@@ -736,6 +773,12 @@
|
|||||||
var row = document.createElement('tr');
|
var row = document.createElement('tr');
|
||||||
var rowKey = values.row_key || ('new-' + Date.now() + '-' + Math.random().toString(36).slice(2));
|
var rowKey = values.row_key || ('new-' + Date.now() + '-' + Math.random().toString(36).slice(2));
|
||||||
var playlistId = tbody.getAttribute('data-playlist-id') || '';
|
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
|
var thumbnailMarkup = values.thumbnail_path
|
||||||
? '<img class="playlist-slide-thumb-image" src="' + values.thumbnail_path + '" alt="" loading="lazy" />'
|
? '<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>';
|
: '<span class="playlist-slide-thumb-placeholder"><i class="bi bi-image" aria-hidden="true"></i></span>';
|
||||||
@@ -752,7 +795,7 @@
|
|||||||
'</td>' +
|
'</td>' +
|
||||||
'<td data-label="Slide">' +
|
'<td data-label="Slide">' +
|
||||||
'<div class="playlist-slide-cell">' +
|
'<div class="playlist-slide-cell">' +
|
||||||
'<div class="playlist-slide-thumb" aria-hidden="true">' + thumbnailMarkup + '</div>' +
|
'<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 class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" /></td>' +
|
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" /></td>' +
|
||||||
@@ -839,6 +882,14 @@
|
|||||||
addSlideSearch.addEventListener('input', syncSlidePickerState);
|
addSlideSearch.addEventListener('input', syncSlidePickerState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (addSlideShowAssigned) {
|
||||||
|
addSlideShowAssigned.addEventListener('click', function () {
|
||||||
|
var nextState = addSlideShowAssigned.getAttribute('aria-pressed') !== 'true';
|
||||||
|
setShowAssignedState(nextState);
|
||||||
|
syncSlidePickerState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (addSlideConfirm) {
|
if (addSlideConfirm) {
|
||||||
addSlideConfirm.addEventListener('click', function () {
|
addSlideConfirm.addEventListener('click', function () {
|
||||||
addSelectedSlides();
|
addSelectedSlides();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
var stateNode = document.getElementById('background-tasks-state');
|
var stateNode = document.getElementById('background-tasks-state');
|
||||||
var currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : '';
|
var currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : '';
|
||||||
|
|
||||||
if (pathname !== '/settings/background-tasks') {
|
if (pathname !== '/settings/background-tasks' && pathname !== '/settings/background-tasks/scheduled') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,26 +174,6 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
function initPaginationNavigation() {
|
||||||
document.addEventListener('click', function (event) {
|
document.addEventListener('click', function (event) {
|
||||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||||
@@ -217,24 +197,6 @@
|
|||||||
loadPage(nextUrl.toString(), { sectionNames: [paginationLink.sectionName] });
|
loadPage(nextUrl.toString(), { sectionNames: [paginationLink.sectionName] });
|
||||||
return;
|
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);
|
}, true);
|
||||||
|
|
||||||
window.addEventListener('popstate', function () {
|
window.addEventListener('popstate', function () {
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
var openPreviewPopupButton = document.getElementById('open-preview-popup');
|
var openPreviewPopupButton = document.getElementById('open-preview-popup');
|
||||||
var editorInstances = new Map();
|
var editorInstances = new Map();
|
||||||
var submitting = false;
|
var submitting = false;
|
||||||
|
var uploadMaxBytes = 100 * 1024 * 1024;
|
||||||
|
var uploadMaxLabel = '100 MB';
|
||||||
var previewRenderFrame = 0;
|
var previewRenderFrame = 0;
|
||||||
var previewPopupWindow = null;
|
var previewPopupWindow = null;
|
||||||
var previewPopupRenderFrame = 0;
|
var previewPopupRenderFrame = 0;
|
||||||
@@ -773,6 +775,333 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRegionUploadZone(input) {
|
||||||
|
return input ? input.closest('[data-region-upload-zone]') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionUploadHiddenValue(input) {
|
||||||
|
var regionId = input && input.name ? input.name.replace(/^region_image_/, '') : '';
|
||||||
|
var card = regionId ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||||
|
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||||
|
return hidden ? hidden.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionUploadProgressNode(zone) {
|
||||||
|
return zone ? zone.querySelector('[data-region-upload-progress]') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionUploadProgressBar(zone) {
|
||||||
|
return zone ? zone.querySelector('[data-region-upload-progress-bar]') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionUploadProgressText(zone) {
|
||||||
|
return zone ? zone.querySelector('[data-region-upload-progress-text]') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRegionUploadProgress(zone, percent, text, isIndeterminate) {
|
||||||
|
var progress = getRegionUploadProgressNode(zone);
|
||||||
|
var bar = getRegionUploadProgressBar(zone);
|
||||||
|
var progressText = getRegionUploadProgressText(zone);
|
||||||
|
var normalizedPercent = Math.max(0, Math.min(100, Math.round(Number(percent || 0))));
|
||||||
|
|
||||||
|
if (!progress || !bar) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressText) {
|
||||||
|
progressText.textContent = text || 'Uploading...';
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.hidden = false;
|
||||||
|
bar.style.width = (isIndeterminate ? 100 : normalizedPercent) + '%';
|
||||||
|
bar.textContent = isIndeterminate ? 'Uploading...' : normalizedPercent + '%';
|
||||||
|
bar.setAttribute('aria-valuenow', String(isIndeterminate ? 100 : normalizedPercent));
|
||||||
|
bar.classList.toggle('progress-bar-striped', Boolean(isIndeterminate));
|
||||||
|
bar.classList.toggle('progress-bar-animated', Boolean(isIndeterminate));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRegionUploadState(input, isUploading, percent, text, isIndeterminate) {
|
||||||
|
var zone = getRegionUploadZone(input);
|
||||||
|
var progress = getRegionUploadProgressNode(zone);
|
||||||
|
|
||||||
|
if (!zone || !progress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
zone.classList.toggle('is-uploading', Boolean(isUploading));
|
||||||
|
zone.setAttribute('aria-busy', isUploading ? 'true' : 'false');
|
||||||
|
input.disabled = Boolean(isUploading);
|
||||||
|
|
||||||
|
if (isUploading) {
|
||||||
|
setRegionUploadProgress(zone, percent, text, isIndeterminate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.hidden = true;
|
||||||
|
if (input) {
|
||||||
|
input.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRegionImageFileWithinLimit(file) {
|
||||||
|
return Boolean(file) && Number(file.size || 0) <= uploadMaxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectOversizeRegionImage(input, file) {
|
||||||
|
var zone = getRegionUploadZone(input);
|
||||||
|
var hiddenValue = getRegionUploadHiddenValue(input);
|
||||||
|
|
||||||
|
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 = '';
|
||||||
|
if (input.dataset.previewUrl) {
|
||||||
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
|
delete input.dataset.previewUrl;
|
||||||
|
}
|
||||||
|
renderImageRegionPreview(zone ? zone.closest('[data-region-id]') : null, hiddenValue);
|
||||||
|
if (file) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRegionImageInputFile(input, file) {
|
||||||
|
if (!input || !file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRegionImageFileWithinLimit(file)) {
|
||||||
|
rejectOversizeRegionImage(input, file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.items.add(file);
|
||||||
|
input.files = dataTransfer.files;
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadRegionImageFile(input, file) {
|
||||||
|
if (!input || !file) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRegionImageFileWithinLimit(file)) {
|
||||||
|
rejectOversizeRegionImage(input, file);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var regionId = input.name.replace(/^region_image_/, '');
|
||||||
|
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||||
|
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||||
|
var zone = getRegionUploadZone(input);
|
||||||
|
var uploadToken = String(Date.now()) + ':' + Math.random().toString(16).slice(2);
|
||||||
|
var previousUploadedPath = String(input.dataset.uploadedPath || '').trim();
|
||||||
|
var previousNeedsCleanup = String(input.dataset.uploadedNeedsCleanup || '') === '1';
|
||||||
|
|
||||||
|
input.dataset.uploadToken = uploadToken;
|
||||||
|
setRegionUploadState(input, true, 0, 'Uploading...', true);
|
||||||
|
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('file', file, file.name || 'upload');
|
||||||
|
|
||||||
|
xhr.open('POST', '/slides/uploads', true);
|
||||||
|
xhr.responseType = 'text';
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
|
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
|
||||||
|
|
||||||
|
xhr.upload.onprogress = function (event) {
|
||||||
|
if (input.dataset.uploadToken !== uploadToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event || !event.lengthComputable || !event.total) {
|
||||||
|
setRegionUploadProgress(zone, 100, 'Uploading...', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var percent = Math.round((event.loaded / event.total) * 100);
|
||||||
|
setRegionUploadProgress(zone, percent, 'Uploading ' + percent + '%', false);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = function () {
|
||||||
|
if (input.dataset.uploadToken !== uploadToken) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = {
|
||||||
|
ok: xhr.status >= 200 && xhr.status < 300,
|
||||||
|
status: xhr.status,
|
||||||
|
statusText: xhr.statusText,
|
||||||
|
responseText: xhr.responseText || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
var error = new Error(response.responseText || 'Unable to upload image.');
|
||||||
|
error.status = response.status;
|
||||||
|
error.responseText = response.responseText;
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = {};
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(response.responseText || '{}') || {};
|
||||||
|
} catch (_error) {
|
||||||
|
payload = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
var uploadedPath = String(payload.path || '').trim();
|
||||||
|
if (!uploadedPath) {
|
||||||
|
reject(new Error('Unable to upload image.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.dataset.previewUrl) {
|
||||||
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
|
delete input.dataset.previewUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hidden) {
|
||||||
|
hidden.value = uploadedPath;
|
||||||
|
}
|
||||||
|
input.dataset.uploadedPath = uploadedPath;
|
||||||
|
input.dataset.uploadedNeedsCleanup = '1';
|
||||||
|
input.value = '';
|
||||||
|
input.setCustomValidity('');
|
||||||
|
renderImageRegionPreview(card, uploadedPath);
|
||||||
|
templateSelectorLock.markEdited();
|
||||||
|
requestPreviewRender();
|
||||||
|
if (previousNeedsCleanup && previousUploadedPath && previousUploadedPath !== uploadedPath) {
|
||||||
|
queueUploadCleanup([previousUploadedPath]);
|
||||||
|
}
|
||||||
|
setRegionUploadState(input, false);
|
||||||
|
resolve(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = function () {
|
||||||
|
if (input.dataset.uploadToken !== uploadToken) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error('Unable to upload image.'));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onabort = function () {
|
||||||
|
if (input.dataset.uploadToken !== uploadToken) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error('Unable to upload image.'));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onloadend = function () {
|
||||||
|
if (input.dataset.uploadToken === uploadToken) {
|
||||||
|
delete input.dataset.uploadToken;
|
||||||
|
setRegionUploadState(input, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
}).catch(function (error) {
|
||||||
|
if (input.dataset.uploadToken === uploadToken) {
|
||||||
|
delete input.dataset.uploadToken;
|
||||||
|
setRegionUploadState(input, false);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPendingUploadCleanupPaths() {
|
||||||
|
return getRegionImageInputs().map(function (input) {
|
||||||
|
if (!input || String(input.dataset.uploadedNeedsCleanup || '') !== '1') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(input.dataset.uploadedPath || '').trim();
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingUploadCleanupPaths() {
|
||||||
|
getRegionImageInputs().forEach(function (input) {
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete input.dataset.uploadedNeedsCleanup;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueUploadCleanup(uploadPaths) {
|
||||||
|
var normalizedPaths = Array.from(new Set((uploadPaths || []).map(function (value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}).filter(Boolean)));
|
||||||
|
|
||||||
|
if (!normalizedPaths.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = JSON.stringify({ uploadPaths: normalizedPaths });
|
||||||
|
var url = '/slides/uploads/cleanup';
|
||||||
|
|
||||||
|
if (navigator.sendBeacon) {
|
||||||
|
try {
|
||||||
|
navigator.sendBeacon(url, new Blob([payload], { type: 'application/json' }));
|
||||||
|
return;
|
||||||
|
} catch (_error) {
|
||||||
|
// Fall back to fetch below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
keepalive: true
|
||||||
|
}).catch(function () {
|
||||||
|
// Best-effort cleanup only.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImageRegionPreview(card, value) {
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previewBox = card.querySelector('.slide-image-region-preview-box');
|
||||||
|
if (!previewBox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewBox.innerHTML = value
|
||||||
|
? '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="Current image preview" />'
|
||||||
|
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionImageInputs() {
|
||||||
|
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPendingRegionImageUpload() {
|
||||||
|
return getRegionImageInputs().some(function (input) {
|
||||||
|
return Boolean(input && input.files && input.files.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getRegionBox(region, canvasWidth, canvasHeight) {
|
function getRegionBox(region, canvasWidth, canvasHeight) {
|
||||||
return {
|
return {
|
||||||
left: Math.max(0, Math.round(Number(region.x || 0))),
|
left: Math.max(0, Math.round(Number(region.x || 0))),
|
||||||
@@ -796,23 +1125,11 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'<meta charset="utf-8" />' +
|
'<meta charset="utf-8" />' +
|
||||||
'<meta name="viewport" content="width=device-width, initial-scale=1" />' +
|
'<meta name="viewport" content="width=device-width, initial-scale=1" />' +
|
||||||
'<title>Slide preview</title>' +
|
'<title>Slide preview</title>' +
|
||||||
'<style>' +
|
'<link rel="stylesheet" href="/assets/css/theme-custom.css" />' +
|
||||||
'html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#111;color:#fff;font-family:Arial,sans-serif;}' +
|
|
||||||
'#popup-preview-stage{position:relative;width:100%;height:100%;overflow:hidden;background:#111;}' +
|
|
||||||
'#popup-preview-canvas{position:absolute;top:0;left:0;transform-origin:top left;will-change:transform;}' +
|
|
||||||
'#popup-preview-canvas,#popup-preview-stage{box-sizing:border-box;}' +
|
|
||||||
'.slide-preview-background,.slide-preview-overlay,.slide-preview-empty{position:absolute;inset:0;}' +
|
|
||||||
'.slide-preview-background{width:100%;height:100%;object-fit:contain;display:block;z-index:1;}' +
|
|
||||||
'.slide-preview-overlay{z-index:2;}' +
|
|
||||||
'.slide-preview-empty{display:flex;align-items:center;justify-content:center;padding:1rem;text-align:center;color:rgba(255,255,255,0.75);z-index:0;}' +
|
|
||||||
'.slide-preview-region{position:absolute;overflow:hidden;box-sizing:border-box;}' +
|
|
||||||
'.slide-preview-image{width:100%;height:100%;object-fit:contain;display:block;}' +
|
|
||||||
'.slide-preview-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,0.65);background:rgba(255,255,255,0.06);font-size:0.9rem;}' +
|
|
||||||
'</style>' +
|
|
||||||
'</head>' +
|
'</head>' +
|
||||||
'<body>' +
|
'<body class="slide-preview-popup-body">' +
|
||||||
'<div id="popup-preview-stage">' +
|
'<div class="slide-preview-popup-stage" id="popup-preview-stage">' +
|
||||||
'<div id="popup-preview-canvas"></div>' +
|
'<div class="slide-preview-popup-canvas" id="popup-preview-canvas"></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</body>' +
|
'</body>' +
|
||||||
'</html>';
|
'</html>';
|
||||||
@@ -1118,7 +1435,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'<span class="chip">Text</span>' +
|
'<span class="chip">Text</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card-body p-3 d-grid gap-3">' +
|
'<div class="card-body p-3 d-grid">' +
|
||||||
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
||||||
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current) + '</textarea>' +
|
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current) + '</textarea>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -1148,7 +1465,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'<span class="chip">RSS</span>' +
|
'<span class="chip">RSS</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card-body p-3 d-grid gap-3">' +
|
'<div class="card-body p-3 d-grid">' +
|
||||||
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
||||||
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -1193,7 +1510,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'<span class="chip">API</span>' +
|
'<span class="chip">API</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card-body p-3 d-grid gap-3">' +
|
'<div class="card-body p-3 d-grid">' +
|
||||||
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
||||||
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -1226,7 +1543,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||||
'<span class="chip">Webpage</span>' +
|
'<span class="chip">Webpage</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card-body p-3 d-grid gap-3">' +
|
'<div class="card-body p-3 d-grid">' +
|
||||||
'<label style="display:block;">Webpage URL' +
|
'<label style="display:block;">Webpage URL' +
|
||||||
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' +
|
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' +
|
||||||
'</label>' +
|
'</label>' +
|
||||||
@@ -1245,7 +1562,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||||
'<span class="chip">RTMP</span>' +
|
'<span class="chip">RTMP</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card-body p-3 d-grid gap-3">' +
|
'<div class="card-body p-3 d-grid">' +
|
||||||
'<label style="display:block;">RTMP URL' +
|
'<label style="display:block;">RTMP URL' +
|
||||||
'<input type="url" name="region_rtmp_' + region.id + '" class="form-control" value="' + escapeHtml(url) + '" placeholder="rtmp://example.com/live/stream" />' +
|
'<input type="url" name="region_rtmp_' + region.id + '" class="form-control" value="' + escapeHtml(url) + '" placeholder="rtmp://example.com/live/stream" />' +
|
||||||
'</label>' +
|
'</label>' +
|
||||||
@@ -1266,7 +1583,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||||
'<span class="chip">HTML</span>' +
|
'<span class="chip">HTML</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card-body p-3 d-grid gap-3">' +
|
'<div class="card-body p-3 d-grid">' +
|
||||||
'<label style="display:block;">HTML content' +
|
'<label style="display:block;">HTML content' +
|
||||||
'<textarea name="region_html_' + region.id + '" class="form-control" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
|
'<textarea name="region_html_' + region.id + '" class="form-control" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
|
||||||
'</label>' +
|
'</label>' +
|
||||||
@@ -1275,22 +1592,62 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reduceAspectRatio(width, height) {
|
||||||
|
var a = Math.max(1, Math.round(Number(width || 0) || 1));
|
||||||
|
var b = Math.max(1, Math.round(Number(height || 0) || 1));
|
||||||
|
while (b !== 0) {
|
||||||
|
var remainder = a % b;
|
||||||
|
a = b;
|
||||||
|
b = remainder;
|
||||||
|
}
|
||||||
|
var divisor = Math.max(1, a);
|
||||||
|
return Math.max(1, Math.round(Number(width || 0) || 1) / divisor) + ':' + Math.max(1, Math.round(Number(height || 0) || 1) / divisor);
|
||||||
|
}
|
||||||
|
|
||||||
function renderImageRegion(region) {
|
function renderImageRegion(region) {
|
||||||
var current = getCurrentRegionValue(region);
|
var current = getCurrentRegionValue(region);
|
||||||
|
var regionWidth = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
|
||||||
|
var regionHeight = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
|
||||||
|
var regionRatio = reduceAspectRatio(regionWidth, regionHeight);
|
||||||
return '' +
|
return '' +
|
||||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||||
'<div class="card-header template-field-head">' +
|
'<div class="card-header template-field-head">' +
|
||||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||||
'<span class="chip">Image</span>' +
|
'<span class="chip">Image</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card-body p-3 d-grid gap-3">' +
|
'<div class="card-body p-3 d-grid">' +
|
||||||
'<label style="display:block;">Image file' +
|
'<div class="row g-3 align-items-start">' +
|
||||||
'<input type="file" name="region_image_' + region.id + '" class="form-control" accept="image/*" />' +
|
'<div class="col-12 col-md-8 d-flex flex-column">' +
|
||||||
|
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_image_' + region.id + '">' +
|
||||||
|
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="image/*" data-slide-image-cropper-region-ratio="' + escapeHtml(regionRatio) + '" data-slide-image-cropper-region-ratio-label="Region" />' +
|
||||||
|
'<span class="slide-image-region-upload-zone-content">' +
|
||||||
|
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
|
||||||
|
'<span class="slide-image-region-upload-zone-copy">' +
|
||||||
|
'<strong>Drop an image here or click to upload</strong>' +
|
||||||
|
'<span>PNG, JPG, GIF, or WebP</span>' +
|
||||||
|
'<span class="slide-image-region-upload-zone-limit">Max ' + uploadMaxLabel + ' per file</span>' +
|
||||||
|
'</span>' +
|
||||||
|
'</span>' +
|
||||||
|
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
|
||||||
|
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
|
||||||
|
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Image upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
|
||||||
|
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</span>' +
|
||||||
'</label>' +
|
'</label>' +
|
||||||
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
'</div>' +
|
||||||
|
'<div class="col-12 col-md-4">' +
|
||||||
|
'<div class="slide-image-region-preview-box">' +
|
||||||
(current
|
(current
|
||||||
? '<div class="muted slide-image-file">Current: <a href="' + escapeHtml(current) + '" target="_blank" rel="noreferrer">' + escapeHtml(current) + '</a></div>'
|
? '<div class="slide-image-region-preview-shell" data-remove-region-image="' + region.id + '" role="button" tabindex="0" aria-label="Remove image">' +
|
||||||
: '<div class="muted slide-image-file">No image selected.</div>') +
|
'<img class="slide-image-region-preview" src="' + escapeHtml(current) + '" alt="Current image preview" />' +
|
||||||
|
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||||
|
'</div>'
|
||||||
|
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
@@ -1437,15 +1794,151 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
|
|
||||||
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
|
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
|
||||||
input.addEventListener('change', function () {
|
input.addEventListener('change', function () {
|
||||||
|
var regionId = input.name.replace(/^region_image_/, '');
|
||||||
|
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||||
|
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||||
|
var file = input.files && input.files.length ? input.files[0] : null;
|
||||||
|
|
||||||
|
if (file && !isRegionImageFileWithinLimit(file)) {
|
||||||
if (input.dataset.previewUrl) {
|
if (input.dataset.previewUrl) {
|
||||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
delete input.dataset.previewUrl;
|
delete input.dataset.previewUrl;
|
||||||
}
|
}
|
||||||
if (input.files && input.files.length) {
|
input.value = '';
|
||||||
input.dataset.previewUrl = URL.createObjectURL(input.files[0]);
|
if (typeof window.showToast === 'function') {
|
||||||
|
window.showToast('File must be ' + uploadMaxLabel + ' or smaller.', 'warning');
|
||||||
|
} else {
|
||||||
|
window.alert('File must be ' + uploadMaxLabel + ' or smaller.');
|
||||||
}
|
}
|
||||||
|
renderImageRegionPreview(card, hidden ? hidden.value : '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.dataset.previewUrl) {
|
||||||
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
|
delete input.dataset.previewUrl;
|
||||||
|
}
|
||||||
|
if (file) {
|
||||||
|
input.dataset.previewUrl = URL.createObjectURL(file);
|
||||||
|
}
|
||||||
|
renderImageRegionPreview(card, input.dataset.previewUrl || (hidden ? hidden.value : ''));
|
||||||
templateSelectorLock.markEdited();
|
templateSelectorLock.markEdited();
|
||||||
requestPreviewRender();
|
requestPreviewRender();
|
||||||
|
if (file) {
|
||||||
|
uploadRegionImageFile(input, file).catch(function (error) {
|
||||||
|
if (input.dataset.previewUrl) {
|
||||||
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
|
delete input.dataset.previewUrl;
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(error && error.message ? error.message : 'Unable to upload image.', 'danger');
|
||||||
|
} else {
|
||||||
|
window.alert(error && error.message ? error.message : 'Unable to upload image.');
|
||||||
|
}
|
||||||
|
renderImageRegionPreview(card, hidden ? hidden.value : '');
|
||||||
|
requestPreviewRender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
templateFields.querySelectorAll('[data-region-upload-zone]').forEach(function (zone) {
|
||||||
|
var regionId = zone.getAttribute('data-region-upload-zone');
|
||||||
|
var input = zone.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
zone.addEventListener('dragover', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
zone.classList.add('is-dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
zone.addEventListener('dragleave', function () {
|
||||||
|
zone.classList.remove('is-dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
zone.addEventListener('drop', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
zone.classList.remove('is-dragover');
|
||||||
|
|
||||||
|
var files = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files : null;
|
||||||
|
var file = files && files.length ? files[0] : null;
|
||||||
|
if (!file || !/^image\//i.test(file.type || '')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRegionImageFileWithinLimit(file)) {
|
||||||
|
rejectOversizeRegionImage(input, file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRegionImageInputFile(input, file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
templateFields.querySelectorAll('button[data-remove-region-image]').forEach(function (button) {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
var regionId = button.getAttribute('data-remove-region-image');
|
||||||
|
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||||
|
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||||
|
var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
|
||||||
|
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
|
||||||
|
|
||||||
|
if (input && input.dataset.previewUrl) {
|
||||||
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
|
delete input.dataset.previewUrl;
|
||||||
|
}
|
||||||
|
if (input && input.dataset.uploadedNeedsCleanup === '1' && uploadPath) {
|
||||||
|
queueUploadCleanup([uploadPath]);
|
||||||
|
}
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
delete input.dataset.uploadedPath;
|
||||||
|
delete input.dataset.uploadedNeedsCleanup;
|
||||||
|
}
|
||||||
|
if (hidden) {
|
||||||
|
hidden.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderImageRegionPreview(card, '');
|
||||||
|
templateSelectorLock.markEdited();
|
||||||
|
requestPreviewRender();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
templateFields.querySelectorAll('.slide-image-region-preview-shell').forEach(function (shell) {
|
||||||
|
shell.addEventListener('click', function () {
|
||||||
|
var regionId = shell.getAttribute('data-remove-region-image');
|
||||||
|
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||||
|
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||||
|
var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
|
||||||
|
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
|
||||||
|
|
||||||
|
if (input && input.dataset.previewUrl) {
|
||||||
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
|
delete input.dataset.previewUrl;
|
||||||
|
}
|
||||||
|
if (input && input.dataset.uploadedNeedsCleanup === '1' && uploadPath) {
|
||||||
|
queueUploadCleanup([uploadPath]);
|
||||||
|
}
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
delete input.dataset.uploadedPath;
|
||||||
|
delete input.dataset.uploadedNeedsCleanup;
|
||||||
|
}
|
||||||
|
if (hidden) {
|
||||||
|
hidden.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderImageRegionPreview(card, '');
|
||||||
|
templateSelectorLock.markEdited();
|
||||||
|
requestPreviewRender();
|
||||||
|
});
|
||||||
|
|
||||||
|
shell.addEventListener('keydown', function (event) {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
shell.click();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_item_number_"]').forEach(function (input) {
|
templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_item_number_"]').forEach(function (input) {
|
||||||
@@ -1513,6 +2006,9 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
});
|
});
|
||||||
submitting = true;
|
submitting = true;
|
||||||
try {
|
try {
|
||||||
|
if (hasPendingRegionImageUpload()) {
|
||||||
|
throw new Error('Wait for the image upload to finish before saving.');
|
||||||
|
}
|
||||||
await Promise.all(saves);
|
await Promise.all(saves);
|
||||||
var formData = new FormData(slideForm);
|
var formData = new FormData(slideForm);
|
||||||
if (event.submitter && event.submitter.name) {
|
if (event.submitter && event.submitter.name) {
|
||||||
@@ -1543,6 +2039,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
if (isCreateForm) {
|
if (isCreateForm) {
|
||||||
try {
|
try {
|
||||||
if (response.url) {
|
if (response.url) {
|
||||||
|
clearPendingUploadCleanupPaths();
|
||||||
window.location.replace(response.url);
|
window.location.replace(response.url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1568,6 +2065,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
|
|
||||||
showToast(savedMessage || 'Saved slide.', 'success');
|
showToast(savedMessage || 'Saved slide.', 'success');
|
||||||
slideForm.dataset.dirty = 'false';
|
slideForm.dataset.dirty = 'false';
|
||||||
|
clearPendingUploadCleanupPaths();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof showToast === 'function') {
|
if (typeof showToast === 'function') {
|
||||||
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
||||||
@@ -1618,6 +2116,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
window.addEventListener('resize', requestPreviewRender);
|
window.addEventListener('resize', requestPreviewRender);
|
||||||
window.addEventListener('resize', requestSidebarSync);
|
window.addEventListener('resize', requestSidebarSync);
|
||||||
window.addEventListener('beforeunload', function () {
|
window.addEventListener('beforeunload', function () {
|
||||||
|
queueUploadCleanup(getPendingUploadCleanupPaths());
|
||||||
if (previewPopupWindow && !previewPopupWindow.closed) {
|
if (previewPopupWindow && !previewPopupWindow.closed) {
|
||||||
previewPopupWindow.close();
|
previewPopupWindow.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -114,6 +114,10 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (headerCell.getAttribute && headerCell.getAttribute('data-sortable') === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (/actions?|buttons?/i.test(getTableHeaderText(headerCell))) {
|
if (/actions?|buttons?/i.test(getTableHeaderText(headerCell))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
const collectUploadReferencesFromSlide = deps.collectUploadReferencesFromSlide;
|
const collectUploadReferencesFromSlide = deps.collectUploadReferencesFromSlide;
|
||||||
const collectUploadReferencesFromTemplate = deps.collectUploadReferencesFromTemplate;
|
const collectUploadReferencesFromTemplate = deps.collectUploadReferencesFromTemplate;
|
||||||
const collectUploadReferencesFromPayload = deps.collectUploadReferencesFromPayload;
|
const collectUploadReferencesFromPayload = deps.collectUploadReferencesFromPayload;
|
||||||
|
const removeUnusedUploadFiles = deps.removeUnusedUploadFiles;
|
||||||
const syncPlaylistUploadsOnChange = deps.syncPlaylistUploadsOnChange;
|
const syncPlaylistUploadsOnChange = deps.syncPlaylistUploadsOnChange;
|
||||||
const getAuditUserId = deps.getAuditUserId;
|
const getAuditUserId = deps.getAuditUserId;
|
||||||
const redirectAfterSave = deps.redirectAfterSave;
|
const redirectAfterSave = deps.redirectAfterSave;
|
||||||
@@ -18,14 +19,30 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
const getTemplateDeleteBlockMessage = deps.getTemplateDeleteBlockMessage;
|
const getTemplateDeleteBlockMessage = deps.getTemplateDeleteBlockMessage;
|
||||||
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
|
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
|
const hasAnyPermission = deps.hasAnyPermission;
|
||||||
const { buildPagination } = require('../../lib/pagination');
|
const { buildPagination } = require('../../lib/pagination');
|
||||||
|
|
||||||
const LIST_PAGE_SIZE = 10;
|
const LIST_PAGE_SIZE = 10;
|
||||||
|
|
||||||
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.');
|
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 queueSlideThumbnailRefresh(slideId, previousThumbnailPath) {
|
function queueSlideThumbnailRefresh(slideId, previousThumbnailPath) {
|
||||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
|
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
@@ -145,6 +162,37 @@ 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.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const payload = await common.buildSlidePayload(pool, req, null);
|
const payload = await common.buildSlidePayload(pool, req, null);
|
||||||
|
|||||||
@@ -369,7 +369,17 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
|||||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
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.'));
|
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ module.exports = {
|
|||||||
renderCanvasSizesPage: require('./canvas-sizes/list'),
|
renderCanvasSizesPage: require('./canvas-sizes/list'),
|
||||||
renderCanvasSizeFormPage: require('./canvas-sizes/add'),
|
renderCanvasSizeFormPage: require('./canvas-sizes/add'),
|
||||||
renderCanvasSizeEditPage: require('./canvas-sizes/edit'),
|
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'),
|
renderErrorPage: require('./error'),
|
||||||
renderRbacPage: require('./rbac/list'),
|
renderRbacPage: require('./rbac/list'),
|
||||||
renderRbacAddPage: require('./rbac/add'),
|
renderRbacAddPage: require('./rbac/add'),
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
const { renderView } = require('../../view');
|
const { renderView } = require('../../view');
|
||||||
const { hasAnyPermission } = require('../../../rbac');
|
const { hasAnyPermission } = require('../../../rbac');
|
||||||
|
const { buildPaginationPages } = require('../../lib/pagination');
|
||||||
|
|
||||||
const TASKS_PER_PAGE = 10;
|
const TASKS_PER_PAGE = 10;
|
||||||
const TASK_STATUSES = new Set(['queued', 'running', 'failed', 'completed', 'canceled']);
|
const QUEUE_PAGE_URL = '/settings/background-tasks';
|
||||||
|
const SCHEDULED_PAGE_URL = '/settings/background-tasks/scheduled';
|
||||||
|
|
||||||
function formatIntervalLabel(intervalMs) {
|
function formatIntervalLabel(intervalMs) {
|
||||||
const value = Math.max(1, Number(intervalMs) || 0);
|
const value = Math.max(1, Number(intervalMs) || 0);
|
||||||
@@ -22,11 +24,6 @@ function parsePageNumber(value) {
|
|||||||
return Math.max(1, pageNumber);
|
return Math.max(1, pageNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStatusFilter(value) {
|
|
||||||
const status = String(value || '').trim().toLowerCase();
|
|
||||||
return TASK_STATUSES.has(status) ? status : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSourceFilter(sourceType, sourceId) {
|
function normalizeSourceFilter(sourceType, sourceId) {
|
||||||
const normalizedSourceType = String(sourceType || '').trim();
|
const normalizedSourceType = String(sourceType || '').trim();
|
||||||
const normalizedSourceId = Math.floor(Number(sourceId) || 0);
|
const normalizedSourceId = Math.floor(Number(sourceId) || 0);
|
||||||
@@ -61,16 +58,7 @@ function buildPagination(totalItems, currentPage, pageParam, queryState) {
|
|||||||
const safeCurrentPage = Math.min(Math.max(1, currentPage), totalPages);
|
const safeCurrentPage = Math.min(Math.max(1, currentPage), totalPages);
|
||||||
const startIndex = (safeCurrentPage - 1) * TASKS_PER_PAGE;
|
const startIndex = (safeCurrentPage - 1) * TASKS_PER_PAGE;
|
||||||
const endIndex = Math.min(totalItems, startIndex + TASKS_PER_PAGE);
|
const endIndex = Math.min(totalItems, startIndex + TASKS_PER_PAGE);
|
||||||
const pages = [];
|
const pages = buildPaginationPages(totalPages, safeCurrentPage, normalizedPageParam, queryState, 7);
|
||||||
|
|
||||||
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 previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
|
||||||
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
|
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
|
||||||
@@ -92,11 +80,7 @@ function buildPagination(totalItems, currentPage, pageParam, queryState) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskMatchesFilters(task, statusFilter, sourceFilter) {
|
function taskMatchesFilters(task, sourceFilter) {
|
||||||
if (statusFilter && task.status !== statusFilter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) {
|
if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -106,12 +90,26 @@ function taskMatchesFilters(task, statusFilter, sourceFilter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatStatusLabel(status) {
|
function formatStatusLabel(status) {
|
||||||
const normalizedStatus = String(status || '').trim().toLowerCase();
|
const normalized = String(status || '').trim().toLowerCase();
|
||||||
if (!normalizedStatus) {
|
if (!normalized) {
|
||||||
return 'All tasks';
|
return '';
|
||||||
}
|
}
|
||||||
|
if (normalized === 'queued') {
|
||||||
return normalizedStatus.charAt(0).toUpperCase() + normalizedStatus.slice(1);
|
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) {
|
function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
|
||||||
@@ -133,18 +131,7 @@ function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
|
|||||||
return buildQueryString(nextQuery);
|
return buildQueryString(nextQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStatusFilterUrl(queryState, statusFilter) {
|
function buildQueuePageViewModel(data, message, currentUser) {
|
||||||
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) {
|
|
||||||
const tasks = (data && data.tasks) || [];
|
const tasks = (data && data.tasks) || [];
|
||||||
const recurringTasks = (data && data.recurringTasks) || [];
|
const recurringTasks = (data && data.recurringTasks) || [];
|
||||||
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
|
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
|
||||||
@@ -152,16 +139,14 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
|
|||||||
const queryState = {
|
const queryState = {
|
||||||
page: parsePageNumber(data && data.page),
|
page: parsePageNumber(data && data.page),
|
||||||
recurringPage: parsePageNumber(data && data.recurringPage),
|
recurringPage: parsePageNumber(data && data.recurringPage),
|
||||||
status: normalizeStatusFilter(data && data.status),
|
|
||||||
sourceType: '',
|
sourceType: '',
|
||||||
sourceId: ''
|
sourceId: ''
|
||||||
};
|
};
|
||||||
const sourceFilter = normalizeSourceFilter(data && data.sourceType, data && data.sourceId);
|
const sourceFilter = normalizeSourceFilter(data && data.sourceType, data && data.sourceId);
|
||||||
queryState.status = normalizeStatusFilter(data && data.status);
|
|
||||||
queryState.sourceType = sourceFilter.sourceType;
|
queryState.sourceType = sourceFilter.sourceType;
|
||||||
queryState.sourceId = sourceFilter.sourceId;
|
queryState.sourceId = sourceFilter.sourceId;
|
||||||
const filteredTasks = tasks.filter(function (task) {
|
const filteredTasks = tasks.filter(function (task) {
|
||||||
return taskMatchesFilters(task, queryState.status, sourceFilter);
|
return taskMatchesFilters(task, sourceFilter);
|
||||||
});
|
});
|
||||||
const pagination = buildPagination(filteredTasks.length, queryState.page, 'page', queryState);
|
const pagination = buildPagination(filteredTasks.length, queryState.page, 'page', queryState);
|
||||||
const recurringPagination = buildPagination(recurringTasks.length, queryState.recurringPage, 'recurringPage', queryState);
|
const recurringPagination = buildPagination(recurringTasks.length, queryState.recurringPage, 'recurringPage', queryState);
|
||||||
@@ -193,18 +178,8 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
|
|||||||
variant: 'success'
|
variant: 'success'
|
||||||
}
|
}
|
||||||
].map(function (card) {
|
].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, {
|
return Object.assign({}, card, {
|
||||||
active: isActive,
|
active: false
|
||||||
href: buildStatusFilterUrl(queryState, statusFilter),
|
|
||||||
ariaLabel: isActive ? `Clear ${card.label.toLowerCase()} filter` : `Filter tasks by ${card.label.toLowerCase()}`
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,24 +191,19 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
|
|||||||
sourceIdLabel: task.metadata && task.metadata.sourceId ? Number(task.metadata.sourceId) : ''
|
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',
|
title: 'Background tasks',
|
||||||
active: 'background-tasks',
|
active: 'background-tasks',
|
||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
scripts: ['js/settings/background-tasks.js'],
|
scripts: ['js/settings/background-tasks.js'],
|
||||||
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
|
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
|
||||||
|
backgroundTasksMenuOpen: true,
|
||||||
tasks: visibleTasksWithSources,
|
tasks: visibleTasksWithSources,
|
||||||
recurringTasks: visibleRecurringTasks.map(function (task) {
|
|
||||||
return Object.assign({}, task, {
|
|
||||||
intervalLabel: formatIntervalLabel(task.intervalMs)
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
summary: summary,
|
summary: summary,
|
||||||
statusCards: statusCards,
|
statusCards: statusCards,
|
||||||
activeStatusLabel: formatStatusLabel(queryState.status),
|
|
||||||
activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '',
|
activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '',
|
||||||
clearFiltersUrl: buildQueryString({ recurringPage: queryState.recurringPage }),
|
clearFiltersUrl: buildQueryString({ recurringPage: queryState.recurringPage }),
|
||||||
hasActiveFilters: hasActiveFilters,
|
hasActiveFilters: hasActiveFilters,
|
||||||
@@ -244,4 +214,38 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
|
|||||||
return task.status !== 'queued' && task.status !== 'running';
|
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']);
|
||||||
|
|
||||||
|
return renderView('settings/tasks-scheduled/index', {
|
||||||
|
title: 'Scheduled tasks',
|
||||||
|
active: 'background-tasks-scheduled',
|
||||||
|
message: message,
|
||||||
|
currentUser: currentUser || null,
|
||||||
|
scripts: ['js/settings/background-tasks.js'],
|
||||||
|
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
|
||||||
|
backgroundTasksMenuOpen: true,
|
||||||
|
recurringTasks: recurringTasks.map(function (task) {
|
||||||
|
return Object.assign({}, task, {
|
||||||
|
intervalLabel: formatIntervalLabel(task.intervalMs)
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
summary: summary,
|
||||||
|
canManage: canManage,
|
||||||
|
scheduledCount: Number(summary.scheduledCount || recurringTasks.length || 0),
|
||||||
|
recurringPagination: buildPagination(recurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage', { recurringPage: parsePageNumber(data && data.recurringPage) })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
renderBackgroundTasksPage: function (data, message, currentUser) {
|
||||||
|
return buildQueuePageViewModel(data, message, currentUser);
|
||||||
|
},
|
||||||
|
renderBackgroundTasksScheduledPage: function (data, message, currentUser) {
|
||||||
|
return buildScheduledPageViewModel(data, message, currentUser);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
@@ -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) {
|
module.exports = function registerAdminSettingsRoutes(app, deps) {
|
||||||
const pages = deps.pages;
|
const pages = deps.pages;
|
||||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||||
@@ -50,7 +67,28 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
|
|||||||
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, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/settings/background-tasks/state', requireSettingsAccess(), function (req, res) {
|
app.get('/settings/background-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.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/settings/background-tasks/state', function (req, res, next) {
|
||||||
|
if (!req.currentUser) {
|
||||||
|
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAnyPermission(req.currentUser, ['background-tasks.read', 'background-tasks.allow', 'scheduled-tasks.read', '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();
|
||||||
|
}, function (req, res) {
|
||||||
const tasks = backgroundTaskQueue.listTasks();
|
const tasks = backgroundTaskQueue.listTasks();
|
||||||
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
||||||
const summary = backgroundTaskQueue.getSummary();
|
const summary = backgroundTaskQueue.getSummary();
|
||||||
@@ -96,4 +134,22 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
|
|||||||
const retryTask = backgroundTaskQueue.retryTask(Number(req.params.id));
|
const retryTask = backgroundTaskQueue.retryTask(Number(req.params.id));
|
||||||
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(retryTask ? 'Task requeued.' : 'Unable to retry that task.'));
|
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(retryTask ? 'Task requeued.' : 'Unable to retry that task.'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/settings/background-tasks/recurring/:key/run', function (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();
|
||||||
|
}, function (req, res) {
|
||||||
|
const ran = backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim());
|
||||||
|
res.redirect('/settings/background-tasks/scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.'));
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
<td data-label="Role">
|
<td data-label="Role">
|
||||||
<div class="user-cell">
|
<div class="user-cell">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{name}}</strong>
|
{{name}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<div id="background-tasks-state" data-state-version="{{stateVersion}}" hidden></div>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2>Background tasks</h2>
|
||||||
|
<p>Queued data refreshes run in the web process. Finished items stay here until you clear them.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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/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>
|
||||||
|
</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>
|
||||||
|
{{#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>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<div id="background-tasks-state" data-state-version="{{stateVersion}}" hidden></div>
|
||||||
|
|
||||||
|
<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" 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>
|
||||||
|
{{#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/background-tasks/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>
|
||||||
@@ -5,13 +5,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-4">
|
<form id="user-form" method="post" action="/users">
|
||||||
|
<div class="row g-4">
|
||||||
<div class="col-12 col-xl-4">
|
<div class="col-12 col-xl-4">
|
||||||
<div class="card card-outline card-primary admin-form-card">
|
<div class="card card-outline card-primary admin-form-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">User details</h3>
|
<h3 class="card-title">User details</h3>
|
||||||
</div>
|
</div>
|
||||||
<form id="user-form" method="post" action="/users">
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
@@ -38,7 +38,6 @@
|
|||||||
<a class="btn btn-warning" href="/users" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
<a class="btn btn-warning" href="/users" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -71,4 +70,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
|||||||
@@ -213,7 +213,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/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>
|
<li class="nav-header">SETTINGS</li>
|
||||||
{{#if (hasPermission currentUser 'users.read')}}
|
{{#if (hasPermission currentUser 'users.read')}}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
@@ -231,14 +231,35 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
{{#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')}}
|
{{#if (anyPermission currentUser 'background-tasks.read')}}
|
||||||
|
<ul class="nav nav-treeview">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/background-tasks">
|
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/background-tasks">
|
||||||
<i class="nav-icon bi bi-list-task"></i>
|
<i class="nav-icon bi bi-list-ol"></i>
|
||||||
<p>Background tasks</p>
|
<p>Task queue</p>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{{/if}}
|
{{/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/background-tasks/scheduled">
|
||||||
|
<i class="nav-icon bi bi-calendar-event"></i>
|
||||||
|
<p>Scheduled tasks</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{/if}}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td data-label="Slide">
|
<td data-label="Slide">
|
||||||
<div class="playlist-slide-cell">
|
<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}}
|
{{#if thumbnail_path}}
|
||||||
<img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" />
|
<img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" />
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -153,13 +153,18 @@
|
|||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body playlist-slide-picker-modal-body">
|
<div class="modal-body playlist-slide-picker-modal-body">
|
||||||
<div class="playlist-slide-picker-toolbar">
|
<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>
|
<label class="form-label mb-1" for="playlist-slide-picker-search">Search</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
<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" />
|
<input type="search" class="form-control" id="playlist-slide-picker-search" placeholder="Search slide titles" autocomplete="off" />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div class="playlist-slide-picker-empty empty is-hidden" id="playlist-slide-picker-empty">No slides match your search.</div>
|
||||||
<div class="playlist-slide-picker-grid" id="playlist-slide-picker-grid" aria-live="polite"></div>
|
<div class="playlist-slide-picker-grid" id="playlist-slide-picker-grid" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="slide-editor-shell">
|
<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">
|
<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 card-outline card-primary admin-form-card slide-template-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
|
|
||||||
<div class="slide-editor-sidebar">
|
<div class="slide-editor-sidebar">
|
||||||
<div class="card card-outline card-primary admin-form-card mb-3">
|
<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">
|
<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"}}}
|
{{{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>
|
<a class="btn btn-warning" href="/slides" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||||
@@ -47,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="card card-outline card-secondary admin-form-card slide-preview-card">
|
<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">
|
<div class="slide-preview-header">
|
||||||
<h3>Preview</h3>
|
<h3>Preview</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
<div class="card-body slide-preview-scroll">
|
<div class="card-body slide-preview-scroll">
|
||||||
<div class="slide-preview-stage" id="slide-preview-stage">
|
<div class="slide-preview-stage" id="slide-preview-stage">
|
||||||
<div class="slide-preview-canvas" id="slide-preview-canvas">
|
<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-empty" id="slide-preview-empty">Select a template to preview the slide.</div>
|
||||||
<div class="slide-preview-overlay" id="slide-preview-overlay"></div>
|
<div class="slide-preview-overlay" id="slide-preview-overlay"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,10 +68,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</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}}
|
{{#if isEdit}}
|
||||||
<form id="delete-slide-form" method="post" action="/slides/{{slide.id}}/delete" data-confirm-message="Delete this slide?"></form>
|
<form id="delete-slide-form" method="post" action="/slides/{{slide.id}}/delete" data-confirm-message="Delete this slide?"></form>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea>
|
<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.4"></script>
|
||||||
|
<script type="module" src="/assets/js/slides/slide-image-cropper.js?v=20260726.4"></script>
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Thumbnail</th>
|
<th data-sortable="false">Thumbnail</th>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th>Template</th>
|
<th>Template</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
{{#each slides}}
|
{{#each slides}}
|
||||||
<tr data-table-search-row data-search-text="{{title}} {{template_name}}">
|
<tr data-table-search-row data-search-text="{{title}} {{template_name}}">
|
||||||
<td data-label="Thumbnail">
|
<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}}
|
{{#if thumbnail_path}}
|
||||||
<img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" />
|
<img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" />
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
Reference in New Issue
Block a user