Compare commits

...
6 Commits
Author SHA1 Message Date
lzstealth 47613e61a2 Tune playlist slide thumbnail placeholder 2026-07-25 17:49:00 +01:00
lzstealth bd130a1070 Bump version to 1.5.6 2026-07-25 17:43:41 +01:00
lzstealth d48f0e779f Backfill slide thumbnail migration 2026-07-25 17:27:25 +01:00
lzstealth 90ec3bb2df Bump version to 1.5.5 2026-07-25 17:27:12 +01:00
lzstealth 0ca20035cd Updated changelog 2026-07-25 17:24:26 +01:00
lzstealth c18f597068 Release 1.5.4 2026-07-25 17:22:21 +01:00
14 changed files with 972 additions and 126 deletions
+29
View File
@@ -6,6 +6,35 @@ All notable changes to this project will be documented in this file.
- No unreleased changes recorded yet. - No unreleased changes recorded yet.
## 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
+37 -91
View File
@@ -1,20 +1,21 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "1.5.3", "version": "1.5.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "1.5.3", "version": "1.5.4",
"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,26 +2742,19 @@
} }
}, },
"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", "engines": {
"string_decoder": "~1.1.1", "node": ">= 6"
"util-deprecate": "~1.0.1"
} }
}, },
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "1.5.3", "version": "1.5.6",
"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",
+9
View File
@@ -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,
+1
View File
@@ -163,6 +163,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 });
+41 -6
View File
@@ -1054,7 +1054,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 +1265,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 +1335,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 +1352,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 +1362,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 +1466,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 +1485,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) {
addSlideEmpty.textContent = query ? 'No slides match your search.' : 'No slides are currently available for this playlist.'; 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.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();
+156 -13
View File
@@ -773,6 +773,32 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
}); });
} }
function setRegionImageInputFile(input, file) {
if (!input || !file) {
return;
}
var dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
input.files = dataTransfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
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 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))),
@@ -1118,7 +1144,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 +1174,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 +1219,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 +1252,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 +1271,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 +1292,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 +1301,53 @@ 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>' + '<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-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>' +
'</label>' +
'</div>' +
'<div class="col-12 col-md-4">' +
'<div class="slide-image-region-preview-box">' +
(current
? '<div class="slide-image-region-preview-shell" data-remove-region-image="' + region.id + '" role="button" tabindex="0" aria-label="Remove image">' +
'<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) + '" />' + '<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' +
(current
? '<div class="muted slide-image-file">Current: <a href="' + escapeHtml(current) + '" target="_blank" rel="noreferrer">' + escapeHtml(current) + '</a></div>'
: '<div class="muted slide-image-file">No image selected.</div>') +
'</div>' + '</div>' +
'</div>'; '</div>';
} }
@@ -1444,10 +1501,96 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
if (input.files && input.files.length) { if (input.files && input.files.length) {
input.dataset.previewUrl = URL.createObjectURL(input.files[0]); input.dataset.previewUrl = URL.createObjectURL(input.files[0]);
} }
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 + '"]');
renderImageRegionPreview(card, input.dataset.previewUrl || (hidden ? hidden.value : ''));
templateSelectorLock.markEdited(); templateSelectorLock.markEdited();
requestPreviewRender(); 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;
}
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 + '"]');
if (input && input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
if (input) {
input.value = '';
}
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 + '"]');
if (input && input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
if (input) {
input.value = '';
}
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) {
input.addEventListener('input', function () { input.addEventListener('input', function () {
var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, ''); var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, '');
@@ -0,0 +1,415 @@
(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;
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 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 (!/^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;
}
});
}());
+4
View File
@@ -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;
} }
+1 -1
View File
@@ -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>
+11 -6
View File
@@ -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,11 +153,16 @@
<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">
<label class="form-label mb-1" for="playlist-slide-picker-search">Search</label> <div class="flex-grow-1">
<div class="input-group"> <label class="form-label mb-1" for="playlist-slide-picker-search">Search</label>
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span> <div class="input-group">
<input type="search" class="form-control" id="playlist-slide-picker-search" placeholder="Search slide titles" autocomplete="off" /> <span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" id="playlist-slide-picker-search" placeholder="Search slide titles" autocomplete="off" />
</div>
</div>
<div class="playlist-slide-picker-filter mb-0">
<button type="button" class="btn btn-outline-secondary playlist-slide-picker-filter-button" id="playlist-slide-picker-show-assigned" aria-pressed="false">Show already added slides</button>
</div> </div>
</div> </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>
+209 -2
View File
@@ -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>
@@ -67,10 +68,216 @@
</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" style="width:100%;height:560px;max-height:70vh;overflow:hidden;">
<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}}
<style>
.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: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;
}
</style>
{{#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=20260725.7"></script>
<script type="module" src="/assets/js/slides/slide-image-cropper.js?v=20260725.4"></script>
+2 -2
View File
@@ -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}}