Release v2.13.1
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m15s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s

This commit is contained in:
lzstealth committed 2026-09-11 20:46:37 +01:00
1 parent 0a03cdd0b7
commit 394d23bb4d
47 files changed
+2408 -559

No files matched your search

+1
View File
@@ -172,6 +172,7 @@ function registerActionHelpers(Handlebars) {
// Shared partials are registered once at startup.
function registerPartials(Handlebars, viewsRoot) {
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(viewsRoot, 'shared', 'modal-shell.hbs'), 'utf8'));
Handlebars.registerPartial('media-picker-modal', fs.readFileSync(path.join(viewsRoot, 'shared', 'media-picker-modal.hbs'), 'utf8'));
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(viewsRoot, 'shared', 'table', 'table-pagination.hbs'), 'utf8'));
Handlebars.registerPartial('playlists/form', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'form.hbs'), 'utf8'));
Handlebars.registerPartial('signage/playlists/slide-row', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'slide-row.hbs'), 'utf8'));
@@ -1,42 +0,0 @@
const TASK = {
key: 'unused-upload-sweep',
title: 'Unused upload sweep',
category: 'cleanup',
trigger: 'recurring scheduled task, daily',
purpose: 'remove uploaded media files that are no longer referenced.',
taskType: 'recurring-run',
intervalMs: 24 * 60 * 60 * 1000
};
function registerUnusedUploadSweepTask(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const uploadSyncService = options && options.uploadSyncService;
const collectUploadPathsFromDirectory = uploadSyncService && uploadSyncService.collectUploadPathsFromDirectory;
const removeUnusedUploadFiles = uploadSyncService && uploadSyncService.removeUnusedUploadFiles;
const pool = options && options.pool;
const mediaDir = String(options && options.mediaDir || '').trim();
if (!backgroundTaskQueue || typeof collectUploadPathsFromDirectory !== 'function' || typeof removeUnusedUploadFiles !== 'function' || !pool || !mediaDir) {
throw new Error('registerUnusedUploadSweepTask requires the unused upload sweep dependencies.');
}
backgroundTaskQueue.registerRecurringTask({
key: TASK.key,
title: TASK.title,
category: TASK.category,
intervalMs: TASK.intervalMs,
metadata: {
mediaDir: mediaDir
},
run: async function () {
const uploadPaths = await collectUploadPathsFromDirectory(mediaDir);
if (!uploadPaths.length) {
return;
}
await removeUnusedUploadFiles(pool, mediaDir, uploadPaths);
}
});
}
module.exports = { registerUnusedUploadSweepTask };
+178
View File
@@ -0,0 +1,178 @@
// Data access helpers for the shared media library.
const fs = require('fs');
const path = require('path');
const MEDIA_MIME_TYPES = {
'.gif': 'image/gif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
'.mp4': 'video/mp4',
'.mpeg': 'video/mpeg',
'.webm': 'video/webm'
};
function normalizeMediaPath(value) {
const mediaPath = String(value || '').trim();
return mediaPath.startsWith('/media/') ? mediaPath : null;
}
async function registerMediaAsset(pool, file, mediaPath, userId, options) {
const normalizedPath = normalizeMediaPath(mediaPath);
if (!pool || !file || !normalizedPath) {
return null;
}
await pool.query(
`INSERT INTO c_media_assets
(media_path, original_name, media_type, mime_type, file_size, is_published, created_by, modified_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
${options && options.preserveOriginalName ? 'original_name = original_name,' : 'original_name = VALUES(original_name),' }
media_type = VALUES(media_type),
mime_type = VALUES(mime_type),
file_size = VALUES(file_size),
${options && options.preservePublication ? 'is_published = is_published,' : 'is_published = VALUES(is_published),' }
modified_by = VALUES(modified_by)`,
[
normalizedPath,
String(file.originalname || file.filename || 'media').slice(0, 255),
String(file.mediaType || '').trim() || 'unknown',
String(file.mimetype || '').trim() || 'application/octet-stream',
Number(file.size) || 0,
options && options.published === false ? 0 : 1,
userId || null,
userId || null
]
);
return normalizedPath;
}
async function registerMediaAssets(pool, files, getMediaPath, getMediaType, userId, options) {
const registered = [];
const list = Array.isArray(files) ? files : [];
for (let index = 0; index < list.length; index += 1) {
const file = list[index];
const mediaPath = typeof getMediaPath === 'function' ? getMediaPath(file) : null;
if (!mediaPath) {
continue;
}
const enrichedFile = Object.assign({}, file, {
mediaType: typeof getMediaType === 'function' ? getMediaType(file) : 'unknown'
});
registered.push(await registerMediaAsset(pool, enrichedFile, mediaPath, userId, options));
}
return registered.filter(Boolean);
}
async function fetchMediaAssets(pool) {
const [rows] = await pool.query(
`SELECT id, media_path, original_name, media_type, mime_type, file_size, created_at
FROM c_media_assets
WHERE is_published = 1
ORDER BY created_at DESC, id DESC`
);
return rows || [];
}
async function syncMediaAssetsFromDirectory(pool, uploadDir, userId) {
const directory = String(uploadDir || '').trim();
if (!pool || !directory) {
return 0;
}
let entries;
try {
entries = await fs.promises.readdir(directory, { withFileTypes: true });
} catch (error) {
if (error && error.code === 'ENOENT') {
return 0;
}
throw error;
}
let registeredCount = 0;
for (const entry of entries) {
if (!entry || !entry.isFile()) {
continue;
}
const originalName = String(entry.name || '').trim();
const mimeType = MEDIA_MIME_TYPES[path.extname(originalName).toLowerCase()];
if (!mimeType) {
continue;
}
const filePath = path.join(directory, originalName);
const stats = await fs.promises.stat(filePath);
const result = await registerMediaAsset(pool, {
originalname: originalName,
mimetype: mimeType,
size: stats.size,
mediaType: mimeType.startsWith('video/') ? 'video' : 'image'
}, '/media/uploads/' + originalName, userId, { preserveOriginalName: true, preservePublication: true });
if (result) {
registeredCount += 1;
}
}
return registeredCount;
}
async function countMediaAssetReferences(pool, mediaPath) {
const normalizedPath = normalizeMediaPath(mediaPath);
if (!normalizedPath) {
return 0;
}
const [slideRows] = await pool.query(
`SELECT COUNT(*) AS ref_count
FROM c_slides
WHERE LOCATE(?, COALESCE(content_json, '')) > 0`,
[normalizedPath]
);
const [templateRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
[normalizedPath]
);
return Number(slideRows[0] && slideRows[0].ref_count || 0) + Number(templateRows[0] && templateRows[0].ref_count || 0);
}
async function publishMediaAssets(pool, mediaPaths) {
const paths = Array.from(new Set((Array.isArray(mediaPaths) ? mediaPaths : []).map(normalizeMediaPath).filter(Boolean)));
for (let index = 0; index < paths.length; index += 1) {
await pool.query('UPDATE c_media_assets SET is_published = 1 WHERE media_path = ?', [paths[index]]);
}
}
async function removePendingMediaAssets(pool, uploadDir, mediaPaths) {
const paths = Array.from(new Set((Array.isArray(mediaPaths) ? mediaPaths : []).map(normalizeMediaPath).filter(Boolean)));
for (let index = 0; index < paths.length; index += 1) {
const mediaPath = paths[index];
const [rows] = await pool.query('SELECT id FROM c_media_assets WHERE media_path = ? AND is_published = 0 LIMIT 1', [mediaPath]);
const asset = rows && rows[0];
if (!asset || await countMediaAssetReferences(pool, mediaPath) > 0) {
continue;
}
await pool.query('DELETE FROM c_media_assets WHERE id = ?', [asset.id]);
const filePath = path.join(path.dirname(uploadDir), mediaPath.replace(/^\/media\//, ''));
await fs.promises.unlink(filePath).catch(function (error) {
if (error && error.code !== 'ENOENT') {
throw error;
}
});
}
}
module.exports = {
normalizeMediaPath,
registerMediaAsset,
registerMediaAssets,
fetchMediaAssets,
syncMediaAssetsFromDirectory,
countMediaAssetReferences,
publishMediaAssets,
removePendingMediaAssets
};
+35 -27
View File
@@ -253,10 +253,18 @@ function createUploadSyncService(options) {
function normalizeUploadReference(uploadPath) {
const value = String(uploadPath || '').trim();
if (!value || !value.startsWith('/media/')) {
if (!value) {
return null;
}
if (value.startsWith('/media/')) {
return value;
}
try {
const parsed = new URL(value);
return parsed.pathname.startsWith('/media/') ? parsed.pathname : null;
} catch (_error) {
return null;
}
return value;
}
function getUploadRelativePath(uploadPath) {
@@ -313,6 +321,13 @@ function createUploadSyncService(options) {
if (reference) {
refs.add(reference);
}
const embeddedReferences = current.match(/\/media\/[^"'\s<>)]+/g) || [];
embeddedReferences.forEach(function (embeddedReference) {
const normalizedReference = normalizeUploadReference(embeddedReference);
if (normalizedReference) {
refs.add(normalizedReference);
}
});
}
}
return refs;
@@ -364,32 +379,21 @@ function createUploadSyncService(options) {
return Number(slideRows[0].ref_count || 0) + Number(thumbnailRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0);
}
async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) {
const uniquePaths = Array.from(new Set((uploadPaths || []).map(normalizeUploadReference).filter(Boolean)));
for (let i = 0; i < uniquePaths.length; i += 1) {
const uploadPath = uniquePaths[i];
const referenceCount = await countUploadReferences(pool, uploadPath);
if (referenceCount > 0) {
continue;
}
const filePath = resolveUploadFilePath(uploadDir, uploadPath);
if (String(uploadPath || '').startsWith('/media/player-cache/')) {
continue;
}
try {
await fs.promises.unlink(filePath);
} catch (error) {
if (error && error.code !== 'ENOENT') {
console.warn('Unable to remove unused upload file:', filePath, error);
}
}
queuePlayerUploadSync({
type: 'delete',
uploadPath: uploadPath,
uploadDir: uploadDir
});
async function uploadFileExists(uploadDir, uploadPath) {
const filePath = resolveUploadFilePath(uploadDir, uploadPath);
if (!filePath) {
return false;
}
try {
await fs.promises.access(filePath, fs.constants.F_OK);
return true;
} catch (_error) {
return false;
}
}
async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) {
return;
}
async function collectUploadPathsFromDirectory(uploadDir) {
@@ -981,6 +985,10 @@ function createUploadSyncService(options) {
continue;
}
if (isLocalLikeBaseUrl(taskPayload.playerInternalBaseUrl) && await uploadFileExists(operation.localUploadDir, removedUploadRef)) {
continue;
}
const deleted = await removeUploadFileFromPlayer(removedUploadRef, operation.localUploadDir, taskPayload.playerInternalBaseUrl, taskPayload.playerIdentifier);
if (!deleted) {
queuePlayerUploadSync({
+1
View File
@@ -55,6 +55,7 @@ module.exports = {
renderCanvasSizeAddPage: require(routePath('signage', 'canvas-sizes', 'add')),
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
renderFontsPage: require(routePath('settings', 'fonts', 'list')),
renderMediaLibraryPage: require(routePath('settings', 'media-library', 'list')),
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage,
renderBackgroundTasksScheduledPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksScheduledPage,
renderErrorPage: require('./error'),
+406 -2
View File
@@ -1,3 +1,267 @@
.media-picker-item {
border: 1px solid var(--bs-border-color);
border-radius: .375rem;
background: var(--bs-body-bg);
padding: .5rem;
color: inherit;
}
.media-picker-item:hover,
.media-picker-item:focus-visible {
border-color: var(--bs-primary);
box-shadow: 0 0 0 .15rem rgba(var(--bs-primary-rgb), .2);
}
.media-picker-item-preview {
display: block;
aspect-ratio: 16 / 9;
background: var(--bs-tertiary-bg);
margin-bottom: .5rem;
overflow: hidden;
}
.media-picker-item-preview img,
.media-picker-item-preview video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.media-gallery-toolbar {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.media-gallery-search {
width: min(100%, 52rem);
}
.media-gallery-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: .75rem;
max-width: 100%;
min-width: 0;
}
.media-gallery-bulk-actions {
flex: 0 0 auto;
}
@media (min-width: 1200px) {
.media-gallery-controls {
flex: 1 1 auto;
}
.media-gallery-search {
width: auto;
flex: 0 1 auto;
flex-wrap: nowrap !important;
min-width: 0;
}
.media-gallery-search .input-group {
min-width: 10rem;
width: 28rem;
max-width: 28rem;
}
}
.media-gallery-search .input-group {
min-width: 15rem;
flex: 1 1 18rem;
}
.media-gallery-search .form-select {
flex: 0 1 9rem;
}
.media-gallery-card {
position: relative;
height: 100%;
overflow: hidden;
border: 1px solid var(--bs-border-color);
border-radius: .375rem;
background: var(--bs-body-bg);
box-shadow: 0 .125rem .25rem rgba(0, 0, 0, .04);
}
.media-gallery-card-selected {
border-color: var(--bs-primary);
background: var(--bs-primary-bg-subtle);
outline: 3px solid rgba(var(--bs-primary-rgb), .3);
outline-offset: -3px;
}
.media-gallery-card-selected::after {
position: absolute;
top: .65rem;
right: .65rem;
z-index: 2;
padding: .25rem .5rem;
border-radius: .25rem;
background: var(--bs-primary);
color: var(--bs-white);
content: 'Selected';
font-size: .75rem;
font-weight: 700;
}
.media-gallery-selection-mode .media-gallery-card {
cursor: pointer;
}
.media-gallery-preview {
position: relative;
display: block;
width: 100%;
padding: 0;
border: 0;
aspect-ratio: 4 / 3;
overflow: hidden;
background: var(--bs-tertiary-bg);
color: inherit;
cursor: pointer;
}
.media-gallery-preview img,
.media-gallery-preview video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform .2s ease;
}
.media-gallery-preview:hover img,
.media-gallery-preview:hover video {
transform: scale(1.03);
}
.media-gallery-preview:focus-visible {
outline: .2rem solid var(--bs-primary);
outline-offset: -.2rem;
}
.media-gallery-type {
position: absolute;
top: .75rem;
left: .75rem;
padding: .2rem .45rem;
border-radius: .25rem;
background: rgba(0, 0, 0, .65);
color: #fff;
font-size: .7rem;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
}
.media-gallery-play {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #fff;
font-size: 2.5rem;
text-shadow: 0 .1rem .35rem rgba(0, 0, 0, .55);
}
.media-gallery-details {
position: relative;
padding: .6rem .75rem .75rem;
}
.media-gallery-name {
min-width: 0;
min-height: 1.425rem;
margin: 0;
overflow: hidden;
font-size: .95rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.425rem;
}
.media-gallery-header {
display: block;
min-width: 0;
padding-right: 2.75rem;
}
.media-gallery-action {
position: absolute;
right: .75rem;
bottom: .75rem;
}
.media-gallery-action button {
display: block;
}
.media-gallery-meta {
color: var(--bs-secondary-color);
font-size: .8rem;
}
.media-gallery-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
}
.media-gallery-footer .media-gallery-meta {
min-width: 0;
}
.media-gallery-empty {
padding: 3rem 1rem;
border: 1px dashed var(--bs-border-color);
border-radius: .375rem;
text-align: center;
}
@media (max-width: 575.98px) {
.media-gallery-controls {
width: 100%;
}
.media-gallery-bulk-actions {
width: 100%;
}
.media-gallery-search {
width: 100%;
}
.media-gallery-search .input-group,
.media-gallery-search .form-select {
flex-basis: 100%;
}
}
.media-gallery-preview-modal-body {
display: grid;
min-height: min(70vh, 42rem);
place-items: center;
background: var(--bs-tertiary-bg);
}
.media-gallery-preview-modal-body img,
.media-gallery-preview-modal-body video {
display: block;
max-width: 100%;
max-height: min(72vh, 48rem);
object-fit: contain;
}
.status-dot {
position: relative;
display: inline-flex;
@@ -852,6 +1116,44 @@
overflow: hidden;
}
.media-library-page {
overflow: hidden;
}
.media-library-page .app-wrapper {
height: 100vh;
}
.media-library-page .app-main {
padding-bottom: 0;
height: 100%;
min-height: 0;
overflow: hidden !important;
}
.media-library-page .app-content {
flex: 1 1 0;
height: auto;
min-height: 0;
overflow: hidden;
}
.media-library-page .app-content .container-fluid {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
box-sizing: border-box;
padding-bottom: 0;
}
.media-library-page .media-gallery {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
[data-table-pagination-card] {
display: flex;
flex: 0 1 auto;
@@ -2164,8 +2466,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
}
.slide-image-cropper-frame.is-loading > :not(.slide-image-cropper-loading-overlay) {
opacity: 0.22;
filter: saturate(0.8);
visibility: hidden;
opacity: 0;
}
.slide-image-cropper-loading-overlay {
@@ -2365,6 +2667,56 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
font-size: 0.875rem;
}
.media-library-upload-zone {
min-height: 132px;
padding: 2rem 2.5rem;
text-align: left;
}
.media-library-upload-zone .slide-image-region-upload-zone-limit {
color: var(--bs-body-color);
font-size: 0.875rem;
font-weight: 600;
}
.media-picker-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) 12rem auto;
gap: 0.75rem;
background: var(--bs-modal-bg);
}
#media-picker-modal .modal-body {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
#media-picker-modal .media-picker-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding-bottom: 1rem;
}
#media-picker-modal [data-media-picker-no-results]:not([hidden]) {
display: block;
min-height: 4rem;
padding: 1rem 0;
}
@media (max-width: 575.98px) {
.media-picker-toolbar {
grid-template-columns: 1fr;
}
.media-picker-toolbar .btn {
width: 100%;
}
}
.slide-image-region-preview {
display: block;
width: 100%;
@@ -3670,4 +4022,56 @@ table.table thead th.sort-desc .table-sort-indicator {
.gradient-stop-handle:active {
cursor: grabbing;
}
.media-picker-item-selected-card .media-picker-item {
border-color: var(--bs-success);
box-shadow: 0 0 0 .15rem rgba(var(--bs-success-rgb), .2);
}
.media-picker-item-statuses {
display: flex;
flex-wrap: wrap;
gap: .5rem;
margin-top: .35rem;
min-height: 1.2rem;
white-space: nowrap;
}
.media-picker-item-statuses [hidden] {
display: inline;
visibility: hidden;
}
.media-picker-item-selected {
color: var(--bs-success-text-emphasis, var(--bs-success));
font-size: .8rem;
font-weight: 600;
}
.media-picker-item-current {
color: var(--bs-info-text-emphasis, var(--bs-info));
font-size: .8rem;
font-weight: 600;
}
.modal-dialog.modal-xxl {
width: calc(100% - 2rem);
max-width: 1440px;
}
@media (max-width: 575.98px) {
.modal-dialog.modal-xxl {
width: auto;
}
}
.media-gallery-scroll {
flex: 1 1 auto;
min-height: 0;
max-height: max(12rem, calc(100dvh - 29rem));
overflow-x: hidden;
overflow-y: auto;
padding-right: .5rem;
padding-bottom: 1rem;
}
+282
View File
@@ -0,0 +1,282 @@
// Shared media library picker for editor forms.
(function () {
var modal = document.getElementById('media-picker-modal');
if (!modal) {
return;
}
var activeButton = null;
var items = Array.prototype.slice.call(modal.querySelectorAll('[data-media-picker-item]'));
var grid = modal.querySelector('[data-media-picker-grid]');
var search = modal.querySelector('[data-media-picker-search]');
var sort = modal.querySelector('[data-media-picker-sort]');
var noResults = modal.querySelector('[data-media-picker-no-results]');
var loadMoreWrap = modal.querySelector('[data-media-picker-load-more-wrap]');
var loadMoreButton = modal.querySelector('[data-media-picker-load-more]');
var confirmButton = modal.querySelector('[data-media-picker-confirm]');
var cancelButton = modal.querySelector('[data-media-picker-cancel]');
var uploadButton = modal.querySelector('[data-media-picker-upload]');
var currentPath = '';
var pendingPath = '';
var pendingType = '';
var visibleRowLimit = 4;
var loadedAssets = [];
var mediaOffset = 0;
var mediaHasMore = false;
var refreshSequence = 0;
function getColumnsPerRow() {
return window.innerWidth >= 992 ? 4 : window.innerWidth >= 768 ? 3 : 2;
}
function renderItems(assets) {
if (!grid) {
return;
}
grid.innerHTML = (Array.isArray(assets) ? assets : []).map(function (asset) {
var type = String(asset.media_type || '');
var path = escapeHtml(asset.media_path);
var name = escapeHtml(asset.original_name);
var createdAt = escapeHtml(asset.created_at);
var preview = type === 'video'
? '<video src="' + path + '" muted playsinline preload="metadata"></video>'
: '<img src="' + path + '" alt="" loading="lazy" />';
return '<div class="col-6 col-md-4 col-lg-3" data-media-picker-item data-media-type="' + escapeHtml(type) + '" data-media-name="' + name + '" data-media-created-at="' + createdAt + '">' +
'<button type="button" class="media-picker-item w-100 text-start" data-media-picker-path="' + path + '" data-media-picker-type="' + escapeHtml(type) + '">' +
'<span class="media-picker-item-preview">' + preview + '</span>' +
'<span class="media-picker-item-name text-truncate d-block">' + name + '</span>' +
'<span class="media-picker-item-statuses"><span class="media-picker-item-current" data-media-picker-current hidden><i class="bi bi-check-circle me-1" aria-hidden="true"></i>Current</span><span class="media-picker-item-selected" data-media-picker-selected hidden><i class="bi bi-check-circle-fill me-1" aria-hidden="true"></i>Selected</span></span>' +
'</button></div>';
}).join('');
items = Array.prototype.slice.call(modal.querySelectorAll('[data-media-picker-item]'));
}
function getQueryParams(offset, limit) {
var params = new URLSearchParams();
var type = activeButton ? activeButton.getAttribute('data-media-picker-type') || 'image' : 'image';
params.set('offset', String(offset));
params.set('limit', String(limit));
params.set('search', String(search && search.value || '').trim());
params.set('type', type);
params.set('sort', sort && sort.value || 'date-desc');
return params.toString();
}
async function refreshItems() {
var requestSequence = ++refreshSequence;
try {
var response = await fetch('/media-library/assets?' + getQueryParams(0, 4 * getColumnsPerRow()), { credentials: 'same-origin', headers: { Accept: 'application/json' } });
if (!response.ok) {
return;
}
var payload = await response.json();
if (requestSequence !== refreshSequence) {
return;
}
loadedAssets = Array.isArray(payload.assets) ? payload.assets : [];
mediaOffset = Number(payload.nextOffset || loadedAssets.length);
mediaHasMore = Boolean(payload.hasMore);
renderItems(loadedAssets);
updateSelection();
applyFilterAndSort();
} catch (_error) {
// Keep the server-rendered list if refreshing is unavailable.
}
}
function escapeHtml(value) {
return String(value || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function showModal() {
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(modal);
} else 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);
} else if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(modal).hide();
}
}
function applyFilterAndSort() {
var visibleItems = items.slice();
var columnsPerRow = getColumnsPerRow();
var visibleItemLimit = visibleRowLimit * columnsPerRow;
items.forEach(function (item) { item.hidden = true; });
visibleItems.slice(0, visibleItemLimit).forEach(function (item) {
item.hidden = false;
if (grid) grid.appendChild(item);
});
if (noResults) noResults.hidden = visibleItems.length > 0;
if (loadMoreWrap) loadMoreWrap.hidden = !mediaHasMore || visibleItems.length === 0;
}
function resetVisibleItemLimit() {
visibleRowLimit = 4;
if (activeButton) {
refreshItems();
}
}
function updateSelection() {
items.forEach(function (item) {
var itemButton = item.querySelector('[data-media-picker-path]');
var path = itemButton && itemButton.getAttribute('data-media-picker-path');
var current = Boolean(currentPath && path === currentPath);
var selected = Boolean(pendingPath && path === pendingPath);
item.classList.toggle('media-picker-item-current-card', current);
item.classList.toggle('media-picker-item-selected-card', selected);
if (itemButton) {
itemButton.setAttribute('aria-pressed', selected ? 'true' : 'false');
var currentLabel = itemButton.querySelector('[data-media-picker-current]');
if (currentLabel) currentLabel.hidden = !current;
var selectedLabel = itemButton.querySelector('[data-media-picker-selected]');
if (selectedLabel) selectedLabel.hidden = !selected;
}
});
}
function getActiveHidden() {
var hiddenKey = activeButton && activeButton.getAttribute('data-media-picker-hidden');
return hiddenKey && (document.getElementById(hiddenKey) || document.querySelector('[name="' + hiddenKey + '"]'));
}
function closePicker() {
activeButton = null;
currentPath = '';
pendingPath = '';
pendingType = '';
hideModal();
}
function getActiveFileInput() {
if (!activeButton) {
return null;
}
var hiddenKey = activeButton.getAttribute('data-media-picker-hidden');
if (hiddenKey) {
var inputFromHidden = document.getElementById(hiddenKey.replace(/^existing_/, ''));
if (inputFromHidden) {
return inputFromHidden;
}
}
var inputId = activeButton.getAttribute('for');
if (inputId) {
return document.getElementById(inputId);
}
var region = activeButton.closest ? activeButton.closest('[data-region-id]') : null;
return region ? region.querySelector('input[type="file"]') : null;
}
function updatePreview(hidden, path, type) {
var region = hidden.closest ? hidden.closest('[data-region-id]') : null;
if (region) {
var previewBox = region.querySelector('.slide-image-region-preview-box');
if (previewBox) {
var safePath = escapeHtml(path);
previewBox.innerHTML = type === 'video'
? '<div class="slide-image-region-preview-shell"><video class="slide-image-region-preview" src="' + safePath + '" muted playsinline preload="metadata"></video></div>'
: '<div class="slide-image-region-preview-shell"><img class="slide-image-region-preview" src="' + safePath + '" alt="Selected media preview" /></div>';
}
return;
}
var backgroundPreview = document.getElementById('background-preview');
var backgroundEmpty = document.getElementById('background-empty');
if (backgroundPreview && path) {
backgroundPreview.src = path;
backgroundPreview.style.display = 'block';
if (backgroundEmpty) backgroundEmpty.style.display = 'none';
}
}
document.addEventListener('click', function (event) {
var uploadTrigger = event.target && event.target.closest ? event.target.closest('[data-media-picker-upload]') : null;
if (uploadTrigger) {
event.preventDefault();
var fileInput = getActiveFileInput();
if (fileInput) {
fileInput.addEventListener('click', function (inputEvent) {
inputEvent.stopPropagation();
}, { once: true });
fileInput.click();
closePicker();
}
return;
}
var button = event.target && event.target.closest ? event.target.closest('[data-media-picker]') : null;
if (button) {
event.preventDefault();
activeButton = button;
var hidden = getActiveHidden();
currentPath = hidden ? String(hidden.value || '') : '';
pendingPath = currentPath;
pendingType = button.getAttribute('data-media-picker-type') || 'image';
if (search) search.value = '';
if (sort) sort.value = 'date-desc';
visibleRowLimit = 4;
updateSelection();
applyFilterAndSort();
showModal();
refreshItems();
return;
}
var itemButton = event.target && event.target.closest ? event.target.closest('[data-media-picker-path]') : null;
if (!itemButton || !activeButton) {
return;
}
pendingPath = itemButton.getAttribute('data-media-picker-path') || '';
pendingType = itemButton.getAttribute('data-media-picker-type') || activeButton.getAttribute('data-media-picker-type') || 'image';
updateSelection();
});
if (search) search.addEventListener('input', resetVisibleItemLimit);
if (sort) sort.addEventListener('change', resetVisibleItemLimit);
if (loadMoreButton) loadMoreButton.addEventListener('click', async function () {
loadMoreButton.disabled = true;
try {
var response = await fetch('/media-library/assets?' + getQueryParams(mediaOffset, 4 * getColumnsPerRow()), { credentials: 'same-origin', headers: { Accept: 'application/json' } });
if (!response.ok) {
return;
}
var payload = await response.json();
loadedAssets = loadedAssets.concat(Array.isArray(payload.assets) ? payload.assets : []);
mediaOffset = Number(payload.nextOffset || mediaOffset);
mediaHasMore = Boolean(payload.hasMore);
renderItems(loadedAssets);
visibleRowLimit += 4;
updateSelection();
applyFilterAndSort();
} finally {
loadMoreButton.disabled = false;
}
});
if (confirmButton) confirmButton.addEventListener('click', function () {
var hidden = getActiveHidden();
if (!hidden || !pendingPath) {
closePicker();
return;
}
hidden.value = pendingPath;
updatePreview(hidden, pendingPath, pendingType);
hidden.dispatchEvent(new Event('change', { bubbles: true }));
closePicker();
});
if (cancelButton) cancelButton.addEventListener('click', closePicker);
modal.addEventListener('hidden.bs.modal', function () {
activeButton = null;
currentPath = '';
pendingPath = '';
pendingType = '';
});
})();
+2 -2
View File
@@ -41,12 +41,12 @@
bodyHtml: '' +
'<div class="row g-3 align-items-start">' +
'<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 + '">' +
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" data-media-picker data-media-picker-type="image" data-media-picker-hidden="existing_region_image_' + region.id + '" for="region_image_' + region.id + '">' +
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" 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>' +
'<strong>Drop an image here or click to select existing</strong>' +
'<span>' + escapeHtml(uploadHelpText) + '</span>' +
'<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadMaxLabel) + ' per file</span>' +
'</span>' +
+2 -2
View File
@@ -982,12 +982,12 @@
var imageOptionsHtml = '' +
'<div class="row g-3 align-items-start">' +
'<div class="col-12 col-md-8">' +
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_qr_image_' + region.id + '">' +
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" data-media-picker data-media-picker-type="image" data-media-picker-hidden="existing_region_qr_image_' + region.id + '" for="region_qr_image_' + region.id + '">' +
'<input type="file" id="region_qr_image_' + region.id + '" name="region_qr_image_' + region.id + '" class="visually-hidden" accept="image/*" />' +
'<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>' +
'<strong>Drop an image here or click to select existing</strong>' +
'<span>PNG, JPG, GIF, or WebP</span>' +
'<span class="slide-image-region-upload-zone-limit">Replaces the QR image logo</span>' +
'</span>' +
+2 -2
View File
@@ -46,12 +46,12 @@
bodyHtml: '' +
'<div class="row g-3 align-items-start">' +
'<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_video_' + region.id + '">' +
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" data-media-picker data-media-picker-type="video" data-media-picker-hidden="existing_region_video_' + region.id + '" for="region_video_' + region.id + '">' +
'<input type="file" id="region_video_' + region.id + '" name="region_video_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" />' +
'<span class="slide-image-region-upload-zone-content">' +
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-camera-video" aria-hidden="true"></i></span>' +
'<span class="slide-image-region-upload-zone-copy">' +
'<strong>Drop a video here or click to upload</strong>' +
'<strong>Drop a video here or click to select existing</strong>' +
'<span>' + escapeHtml(uploadHelpText) + '</span>' +
'<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadVideoMaxLabel) + ' per file</span>' +
'</span>' +
+559
View File
@@ -0,0 +1,559 @@
const modal = document.getElementById('media-gallery-preview-modal');
const body = modal && modal.querySelector('[data-media-gallery-preview-body]');
const title = modal && modal.querySelector('#media-gallery-preview-modal-label');
const gallery = document.querySelector('.media-gallery');
const search = gallery && gallery.querySelector('[data-media-gallery-search]');
const typeFilter = gallery && gallery.querySelector('[data-media-gallery-type-filter]');
const usageFilter = gallery && gallery.querySelector('[data-media-gallery-usage-filter]');
const sort = gallery && gallery.querySelector('[data-media-gallery-sort]');
const galleryCount = gallery && gallery.querySelector('[data-media-gallery-count]');
const noResults = gallery && gallery.querySelector('[data-media-gallery-no-results]');
const loadMoreWrap = gallery && gallery.querySelector('[data-media-gallery-load-more-wrap]');
const loadMoreButton = gallery && gallery.querySelector('[data-media-gallery-load-more]');
const selectModeButton = gallery && gallery.querySelector('[data-media-gallery-select-mode]');
const deleteSelectedButton = gallery && gallery.querySelector('[data-media-gallery-delete-selected]');
let galleryItems = gallery ? Array.from(gallery.querySelectorAll('[data-media-gallery-column]')) : [];
let visibleRowLimit = 4;
let galleryOffset = gallery ? Number(gallery.dataset.mediaGalleryOffset || galleryItems.length) : 0;
let galleryHasMore = gallery ? gallery.dataset.mediaGalleryHasMore === 'true' : false;
let galleryTotal = galleryCount ? Number(galleryCount.textContent || galleryItems.length) : galleryItems.length;
let galleryRefreshSequence = 0;
let selectionMode = false;
const selectedAssetIds = new Set();
const uploadForm = document.querySelector('form[action="/media-library"]');
const uploadInput = document.querySelector('[data-media-library-upload]');
const uploadZone = document.querySelector('[data-media-library-upload-zone]');
const uploadName = document.querySelector('[data-media-library-upload-name]');
const cropModal = document.getElementById('media-library-crop-modal');
const cropImage = cropModal && cropModal.querySelector('#media-library-crop-image');
const cropApply = cropModal && cropModal.querySelector('[data-media-library-crop-apply]');
const cropCancel = cropModal && cropModal.querySelector('[data-media-library-crop-cancel]');
const cropStatus = cropModal && cropModal.querySelector('#media-library-crop-status');
const cropFrame = cropModal && cropModal.querySelector('.slide-image-cropper-frame');
let cropper = null;
let cropObjectUrl = '';
let cropSourceFile = null;
let cropFlipX = 1;
let cropFlipY = 1;
function getColumnsPerRow() {
return window.innerWidth >= 1200 ? 6 : window.innerWidth >= 992 ? 3 : window.innerWidth >= 576 ? 2 : 1;
}
function showUploadMessage(message) {
if (typeof window.showToast === 'function') {
window.showToast(message, 'warning');
return;
}
window.alert(message);
}
function showUploadModal() {
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(cropModal);
} else if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(cropModal).show();
}
}
function hideUploadModal() {
if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
window.pulseModal.hide(cropModal);
} else if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(cropModal).hide();
}
}
function clearCropper() {
if (cropper) {
cropper.destroy();
cropper = null;
}
if (cropObjectUrl) {
URL.revokeObjectURL(cropObjectUrl);
cropObjectUrl = '';
}
cropSourceFile = null;
cropFlipX = 1;
cropFlipY = 1;
if (cropStatus) cropStatus.textContent = '';
if (cropFrame) cropFrame.classList.remove('is-loading');
}
function setCropperActionState(disabled) {
if (!cropModal) return;
cropModal.querySelectorAll('[data-media-library-crop-action]').forEach(function (button) {
button.disabled = Boolean(disabled);
});
}
function appendGalleryAsset(asset) {
if (!gallery || !asset) return;
const column = document.createElement('div');
column.className = 'col-12 col-sm-6 col-lg-4 col-xl-2';
column.dataset.mediaGalleryColumn = '';
column.dataset.mediaId = String(asset.id || '');
column.dataset.mediaType = asset.media_type || '';
column.dataset.mediaUsed = asset.inUse ? 'true' : 'false';
const article = document.createElement('article');
article.className = 'media-gallery-card';
article.dataset.mediaCreatedAt = asset.created_at || '';
article.dataset.mediaFileSize = asset.file_size || '0';
const preview = document.createElement('button');
preview.type = 'button';
preview.className = 'media-gallery-preview';
preview.dataset.mediaGalleryPreview = '';
preview.dataset.mediaPath = asset.media_path || '';
preview.dataset.mediaType = asset.media_type || '';
preview.dataset.mediaName = asset.original_name || '';
preview.setAttribute('aria-label', 'Preview ' + (asset.original_name || 'media'));
const media = asset.media_type === 'video' ? document.createElement('video') : document.createElement('img');
media.src = asset.media_path || '';
if (asset.media_type === 'video') {
media.muted = true;
media.playsInline = true;
media.preload = 'metadata';
} else {
media.alt = asset.original_name || '';
media.loading = 'lazy';
}
preview.appendChild(media);
if (asset.media_type === 'video') {
const play = document.createElement('span');
play.className = 'media-gallery-play';
play.setAttribute('aria-hidden', 'true');
play.innerHTML = '<i class="bi bi-play-fill"></i>';
preview.appendChild(play);
}
const format = document.createElement('span');
format.className = 'media-gallery-type';
format.textContent = asset.mediaFormat || '';
preview.appendChild(format);
const details = document.createElement('div');
details.className = 'media-gallery-details';
const header = document.createElement('div');
header.className = 'media-gallery-header';
const name = document.createElement('h4');
name.className = 'media-gallery-name';
name.title = asset.original_name || '';
name.textContent = asset.original_name || '';
header.appendChild(name);
if (asset.canDelete) {
if (asset.inUse) {
const lock = document.createElement('button');
lock.type = 'button';
lock.className = 'btn btn-outline-secondary btn-sm media-gallery-action';
lock.disabled = true;
lock.title = 'Media is in use';
lock.innerHTML = '<i class="bi bi-lock" aria-hidden="true"></i><span class="visually-hidden">Media is in use</span>';
header.appendChild(lock);
} else {
const form = document.createElement('form');
form.method = 'post';
form.action = '/media-library/' + encodeURIComponent(asset.id) + '/delete';
form.dataset.confirmMessage = 'Delete this media?';
form.className = 'media-gallery-action';
form.innerHTML = '<button type="submit" class="btn btn-outline-danger btn-sm" title="Delete media"><i class="bi bi-trash" aria-hidden="true"></i><span class="visually-hidden">Delete media</span></button>';
header.appendChild(form);
}
}
const meta = document.createElement('div');
meta.className = 'media-gallery-meta';
meta.textContent = (asset.displayFileSize || '') + ' · Used ' + Number(asset.referenceCount || 0) + ' time' + (Number(asset.referenceCount || 0) === 1 ? '' : 's');
details.appendChild(header);
details.appendChild(meta);
article.appendChild(preview);
article.appendChild(details);
column.appendChild(article);
gallery.querySelector('.media-gallery-scroll .row').appendChild(column);
galleryItems.push(column);
column.querySelector('.media-gallery-card').classList.toggle('media-gallery-card-selected', selectedAssetIds.has(String(asset.id || '')));
}
function applyCropRatio(value) {
if (!cropper) return;
cropper.setAspectRatio(value === 'free' ? NaN : Number(value.split(':')[0]) / Number(value.split(':')[1]));
cropModal.querySelectorAll('[data-media-library-crop-action="ratio"]').forEach(function (button) {
const active = button.dataset.mediaLibraryCropRatio === value;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
}
function initMediaCropper() {
if (!cropSourceFile || !cropImage || !window.Cropper || cropper) return;
if (cropFrame) cropFrame.classList.add('is-loading');
cropper = new window.Cropper(cropImage, {
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 (cropFrame) cropFrame.classList.remove('is-loading');
if (cropper && cropper.container) {
cropper.container.style.width = '100%';
cropper.container.style.height = '560px';
cropper.container.style.maxHeight = '70vh';
}
const containerData = cropper.getContainerData();
const imageData = cropper.getImageData();
const fitRatio = Math.min(Number(containerData.width || 0) / Number(imageData.naturalWidth || 1), Number(containerData.height || 0) / Number(imageData.naturalHeight || 1), 1);
if (fitRatio > 0) cropper.zoomTo(fitRatio);
applyCropRatio('free');
setCropperActionState(false);
}
});
}
function setUploadFile(file, shouldCrop) {
if (!uploadInput || !file) {
return;
}
const allowedTypes = String(uploadInput.accept || '').split(',').map(function (value) { return value.trim().toLowerCase(); }).filter(Boolean);
const fileType = String(file.type || '').toLowerCase();
const matchesType = allowedTypes.length === 0 || allowedTypes.indexOf(fileType) !== -1;
const maxBytes = fileType.indexOf('video/') === 0 ? Number(uploadInput.dataset.videoMaxBytes) : Number(uploadInput.dataset.imageMaxBytes);
if (!matchesType) {
showUploadMessage('This media type is disabled in Media Uploads settings.');
uploadInput.value = '';
return;
}
if (maxBytes && file.size > maxBytes) {
showUploadMessage('This file is larger than the configured upload limit.');
uploadInput.value = '';
return;
}
const transfer = new DataTransfer();
transfer.items.add(file);
uploadInput.files = transfer.files;
if (uploadName) {
uploadName.textContent = file.name;
}
if (shouldCrop && fileType.indexOf('image/') === 0 && cropModal && cropImage && window.Cropper) {
clearCropper();
setCropperActionState(true);
cropSourceFile = file;
cropObjectUrl = URL.createObjectURL(file);
cropImage.src = cropObjectUrl;
if (cropFrame) cropFrame.classList.add('is-loading');
showUploadModal();
}
}
function submitUpload() {
if (!uploadForm) {
return;
}
if (typeof uploadForm.requestSubmit === 'function') {
uploadForm.requestSubmit();
} else {
uploadForm.submit();
}
}
if (uploadInput) {
uploadInput.addEventListener('change', function () {
const file = uploadInput.files && uploadInput.files[0];
if (file) {
setUploadFile(file, true);
if (String(file.type || '').toLowerCase().indexOf('image/') !== 0 || !(cropModal && cropImage && window.Cropper)) {
submitUpload();
}
}
});
}
if (uploadZone) {
uploadZone.addEventListener('dragover', function (event) {
event.preventDefault();
uploadZone.classList.add('is-dragover');
});
uploadZone.addEventListener('dragleave', function () {
uploadZone.classList.remove('is-dragover');
});
uploadZone.addEventListener('drop', function (event) {
event.preventDefault();
uploadZone.classList.remove('is-dragover');
const file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0];
if (file) {
setUploadFile(file, true);
if (String(file.type || '').toLowerCase().indexOf('image/') !== 0 || !(cropModal && cropImage && window.Cropper)) {
submitUpload();
}
}
});
}
if (cropApply) {
cropApply.addEventListener('click', function () {
if (!cropper || !cropSourceFile || !uploadInput) {
hideUploadModal();
return;
}
const outputType = cropSourceFile.type === 'image/jpeg' || cropSourceFile.type === 'image/webp' ? cropSourceFile.type : 'image/png';
cropper.getCroppedCanvas().toBlob(function (blob) {
if (!blob) {
showUploadMessage('Unable to crop this image.');
return;
}
const extension = outputType === 'image/jpeg' ? '.jpg' : outputType === 'image/webp' ? '.webp' : '.png';
const croppedFile = new File([blob], cropSourceFile.name.replace(/\.[^.]+$/, '') + extension, { type: outputType, lastModified: Date.now() });
clearCropper();
setUploadFile(croppedFile, false);
hideUploadModal();
submitUpload();
}, outputType, 0.92);
});
}
if (cropCancel) {
cropCancel.addEventListener('click', function () {
clearCropper();
if (uploadInput) uploadInput.value = '';
if (uploadName) uploadName.textContent = 'No file selected';
hideUploadModal();
});
}
if (cropModal) {
cropModal.addEventListener('click', function (event) {
const button = event.target && event.target.closest ? event.target.closest('[data-media-library-crop-action]') : null;
if (!button || !cropper) return;
const action = button.dataset.mediaLibraryCropAction;
if (action === 'rotate-left') cropper.rotate(-90);
if (action === 'rotate-right') cropper.rotate(90);
if (action === 'flip-horizontal') {
cropFlipX *= -1;
cropper.scaleX(cropFlipX);
}
if (action === 'flip-vertical') {
cropFlipY *= -1;
cropper.scaleY(cropFlipY);
}
if (action === 'ratio') applyCropRatio(button.dataset.mediaLibraryCropRatio || 'free');
if (action === 'reset') {
cropper.reset();
cropFlipX = 1;
cropFlipY = 1;
applyCropRatio('free');
}
});
}
if (cropModal) {
cropModal.addEventListener('shown.bs.modal', function () {
if (cropSourceFile && cropImage && window.Cropper && !cropper) {
initMediaCropper();
}
});
cropModal.addEventListener('hidden.bs.modal', function () {
clearCropper();
});
}
if (uploadForm) {
uploadForm.addEventListener('submit', function (event) {
if (uploadInput && !uploadInput.files.length) {
event.preventDefault();
showUploadMessage('Choose an image or video to upload.');
}
});
}
function getGalleryQuery(offset, limit) {
const params = new URLSearchParams();
params.set('offset', String(offset));
params.set('limit', String(limit));
params.set('search', String(search && search.value || '').trim());
params.set('type', typeFilter ? typeFilter.value : 'all');
params.set('usage', usageFilter ? usageFilter.value : 'all');
params.set('sort', sort ? sort.value : 'date-desc');
return params.toString();
}
function updateSearchResults() {
const columnsPerRow = getColumnsPerRow();
const visibleItemLimit = visibleRowLimit * columnsPerRow;
let visibleCount = 0;
galleryItems.forEach(function (card) {
card.hidden = visibleCount >= visibleItemLimit;
if (!card.hidden) {
visibleCount += 1;
}
});
if (galleryCount) {
galleryCount.textContent = String(galleryTotal);
}
if (noResults) {
noResults.hidden = galleryTotal > 0;
}
if (loadMoreWrap) {
loadMoreWrap.hidden = !galleryHasMore || galleryTotal === 0;
}
updateBulkDeleteActions();
}
function updateBulkDeleteActions() {
if (selectModeButton) {
selectModeButton.setAttribute('aria-pressed', selectionMode ? 'true' : 'false');
selectModeButton.textContent = selectionMode ? 'Done' : 'Select';
}
if (deleteSelectedButton) {
deleteSelectedButton.hidden = !selectionMode;
deleteSelectedButton.disabled = selectedAssetIds.size === 0;
}
galleryItems.forEach(function (item) {
const id = String(item.dataset.mediaId || '');
const card = item.querySelector('.media-gallery-card');
if (card) card.classList.toggle('media-gallery-card-selected', selectionMode && selectedAssetIds.has(id));
});
}
function clearSelectedAssets() {
selectedAssetIds.clear();
}
function setSelectionMode(enabled) {
selectionMode = Boolean(enabled);
if (!selectionMode) clearSelectedAssets();
if (gallery) gallery.classList.toggle('media-gallery-selection-mode', selectionMode);
updateBulkDeleteActions();
}
function submitBulkDelete() {
if (!selectedAssetIds.size || !window.confirm('Delete ' + selectedAssetIds.size + ' selected media asset' + (selectedAssetIds.size === 1 ? '' : 's') + '?')) {
return;
}
const form = document.createElement('form');
form.method = 'post';
form.action = '/media-library/delete';
selectedAssetIds.forEach(function (id) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'assetIds[]';
input.value = id;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
}
async function refreshGallery() {
const requestSequence = ++galleryRefreshSequence;
try {
const response = await fetch('/media-library/assets?' + getGalleryQuery(0, 4 * getColumnsPerRow()), { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error('Unable to refresh media.');
const result = await response.json();
if (requestSequence !== galleryRefreshSequence) return;
galleryItems.forEach(function (item) { item.remove(); });
galleryItems = [];
clearSelectedAssets();
(result.assets || []).forEach(appendGalleryAsset);
galleryOffset = Number(result.nextOffset || 0);
galleryHasMore = Boolean(result.hasMore);
galleryTotal = Number(result.total || 0);
visibleRowLimit = 4;
updateSearchResults();
} catch (error) {
showUploadMessage(error.message || 'Unable to refresh media.');
}
}
function resetVisibleItemLimit() {
visibleRowLimit = 4;
refreshGallery();
}
if (search) {
search.addEventListener('input', resetVisibleItemLimit);
}
if (typeFilter) typeFilter.addEventListener('change', resetVisibleItemLimit);
if (usageFilter) usageFilter.addEventListener('change', resetVisibleItemLimit);
if (sort) sort.addEventListener('change', resetVisibleItemLimit);
if (gallery) {
gallery.addEventListener('click', function (event) {
if (!selectionMode) return;
const column = event.target && event.target.closest ? event.target.closest('[data-media-gallery-column]') : null;
if (!column || !gallery.contains(column)) return;
event.preventDefault();
event.stopPropagation();
if (column.dataset.mediaUsed === 'true') return;
const id = String(column.dataset.mediaId || '');
if (!id) return;
if (selectedAssetIds.has(id)) selectedAssetIds.delete(id);
else selectedAssetIds.add(id);
updateBulkDeleteActions();
});
}
if (selectModeButton) selectModeButton.addEventListener('click', function () { setSelectionMode(!selectionMode); });
if (deleteSelectedButton) deleteSelectedButton.addEventListener('click', submitBulkDelete);
if (loadMoreButton) {
loadMoreButton.addEventListener('click', async function () {
loadMoreButton.disabled = true;
try {
const response = await fetch('/media-library/assets?' + getGalleryQuery(galleryOffset, 4 * getColumnsPerRow()), { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error('Unable to load more media.');
const result = await response.json();
(result.assets || []).forEach(appendGalleryAsset);
galleryOffset = Number(result.nextOffset || galleryOffset);
galleryHasMore = Boolean(result.hasMore);
galleryTotal = Number(result.total || galleryTotal);
visibleRowLimit += 4;
updateSearchResults();
} catch (error) {
showUploadMessage(error.message || 'Unable to load more media.');
} finally {
loadMoreButton.disabled = false;
}
});
}
updateSearchResults();
function showModal() {
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(modal);
} else if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(modal).show();
}
}
if (modal && body) {
document.addEventListener('click', function (event) {
const preview = event.target && event.target.closest ? event.target.closest('[data-media-gallery-preview]') : null;
if (!preview) {
return;
}
const path = preview.getAttribute('data-media-path') || '';
const type = preview.getAttribute('data-media-type') || 'image';
const name = preview.getAttribute('data-media-name') || 'Media preview';
body.replaceChildren();
if (title) {
title.textContent = name;
}
const media = document.createElement(type === 'video' ? 'video' : 'img');
media.src = path;
media.alt = type === 'video' ? '' : name;
if (type === 'video') {
media.controls = true;
media.playsInline = true;
}
body.appendChild(media);
showModal();
});
modal.addEventListener('hidden.bs.modal', function () {
body.replaceChildren();
});
}
@@ -10,6 +10,7 @@ export function createSlideFormRegionHelpers(options) {
var apiSources = Array.isArray(settings.apiSources) ? settings.apiSources : [];
var timetableGroups = Array.isArray(settings.timetableGroups) ? settings.timetableGroups : [];
var weatherLocations = Array.isArray(settings.weatherLocations) ? settings.weatherLocations : [];
var mediaAssets = Array.isArray(settings.mediaAssets) ? settings.mediaAssets : [];
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; };
var requestPreviewRender = typeof settings.requestPreviewRender === 'function' ? settings.requestPreviewRender : function () {};
var placeholderUtils = window.placeholderUtils || {};
@@ -570,7 +571,8 @@ export function createSlideFormRegionHelpers(options) {
: [],
sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path),
timetableGroups: timetableGroups,
weatherLocations: weatherLocations
weatherLocations: weatherLocations,
mediaAssets: mediaAssets
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : region.region_type === 'weather' ? weatherLocations : apiSources);
}
@@ -612,7 +614,8 @@ export function createSlideFormRegionHelpers(options) {
: [],
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath),
timetableGroups: timetableGroups,
weatherLocations: weatherLocations
weatherLocations: weatherLocations,
mediaAssets: mediaAssets
};
}
+2 -1
View File
@@ -169,6 +169,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
apiSources: apiSources,
timetableGroups: timetableGroups,
weatherLocations: weatherLocations,
mediaAssets: Array.isArray(slideEditorData.mediaAssets) ? slideEditorData.mediaAssets : [],
defaultFontSize: DEFAULT_FONT_SIZE,
uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel,
@@ -927,7 +928,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
}
templateSelect.addEventListener('change', renderTemplate);
templateFields.addEventListener('change', function () {
templateFields.addEventListener('change', function (event) {
var webpageInput = event.target && typeof event.target.matches === 'function' && event.target.matches('input[type="url"][name^="region_webpage_"]');
if (webpageInput) {
templateSelectorLock.arm();
@@ -1776,16 +1776,25 @@
renderAddRegionOptions();
}
backgroundInput.addEventListener('change', function () {
var file = backgroundInput.files && backgroundInput.files[0];
if (file) {
updateBackgroundPreview(file);
}
});
if (backgroundInput) {
backgroundInput.addEventListener('change', function () {
var file = backgroundInput.files && backgroundInput.files[0];
if (file) {
updateBackgroundPreview(file);
}
});
}
if (removeBackgroundButton && removeBackgroundFlag) {
removeBackgroundButton.addEventListener('click', function () {
removeBackgroundFlag.checked = true;
backgroundInput.value = '';
var backgroundPathInput = document.getElementById('existing-background-image-path');
if (backgroundPathInput) {
backgroundPathInput.value = '';
backgroundPathInput.dispatchEvent(new Event('change', { bubbles: true }));
}
if (backgroundInput) {
backgroundInput.value = '';
}
backgroundPreview.removeAttribute('src');
backgroundPreview.style.display = 'none';
backgroundEmpty.style.display = 'block';
+30 -3
View File
@@ -7,6 +7,7 @@ const { fetchAppSettings } = require('#src/data/app-settings');
const { convertWeatherSnapshot } = require('#src/data/weather-units');
const { buildAuditChanges } = require('#src/data/audit-log');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const { fetchMediaAssets, registerMediaAssets, syncMediaAssetsFromDirectory, publishMediaAssets, removePendingMediaAssets } = require('#src/web/lib/media/library');
module.exports = function registerContentRoutes(app, deps) {
const pool = deps.pool;
@@ -68,6 +69,8 @@ module.exports = function registerContentRoutes(app, deps) {
const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] };
const weatherData = typeof common.fetchWeatherLocationsData === 'function' ? await common.fetchWeatherLocationsData(pool) : { weatherLocations: [] };
const appSettings = await fetchAppSettings(pool);
await syncMediaAssetsFromDirectory(pool, deps.uploadDir);
const mediaAssets = (await fetchMediaAssets(pool)).slice(0, 24);
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
@@ -92,6 +95,7 @@ module.exports = function registerContentRoutes(app, deps) {
apiSources: apiSources,
timetableGroups: timetableData.timetableGroups || [],
weatherLocations: weatherLocations,
mediaAssets: mediaAssets,
fontLibrary: loadFontLibrary(deps.uploadDir),
uploadLimits: {
imageMaxBytes: Number(appSettings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES,
@@ -448,6 +452,9 @@ module.exports = function registerContentRoutes(app, deps) {
const uploadContext = String(req.get('X-Upload-Context') || req.query.context || '').trim().toLowerCase();
await validateUploadedFiles([req.file], uploadContext);
await registerMediaAssets(pool, [req.file], function (file) {
return '/media/uploads/' + file.filename;
}, getUploadedFileMediaType, getAuditUserId(req), { published: false });
res.json({
path: '/media/uploads/' + req.file.filename,
@@ -471,7 +478,7 @@ module.exports = function registerContentRoutes(app, deps) {
return res.status(400).json({ error: 'No upload paths were provided.' });
}
await removeUnusedUploadFiles(pool, deps.uploadDir, uploadPaths);
await removePendingMediaAssets(pool, deps.uploadDir, uploadPaths);
res.sendStatus(204);
} catch (error) {
next(error);
@@ -481,6 +488,9 @@ module.exports = function registerContentRoutes(app, deps) {
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
try {
await validateUploadedFiles(req.files || []);
await registerMediaAssets(pool, req.files, function (file) {
return '/media/uploads/' + file.filename;
}, getUploadedFileMediaType, getAuditUserId(req));
const payload = await common.buildSlidePayload(pool, req, null);
if (await common.fetchDuplicateName(pool, 'c_slides', payload.title, null, 'title')) {
return res.status(400).send('A slide with that title already exists.');
@@ -490,6 +500,7 @@ module.exports = function registerContentRoutes(app, deps) {
'INSERT INTO c_slides (title, template_id, content_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
[payload.title, payload.templateId, payload.contentJson, actorId, actorId]
);
await publishMediaAssets(pool, collectUploadReferencesFromPayload(payload));
await syncPlaylistUploadsOnChange({
key: 'slide:create:' + result.insertId,
pool: pool,
@@ -513,6 +524,9 @@ module.exports = function registerContentRoutes(app, deps) {
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
try {
await validateUploadedFiles(req.files || []);
await registerMediaAssets(pool, req.files, function (file) {
return '/media/uploads/' + file.filename;
}, getUploadedFileMediaType, getAuditUserId(req));
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
return res.status(404).send('Slide not found');
@@ -539,6 +553,7 @@ module.exports = function registerContentRoutes(app, deps) {
'UPDATE c_slides SET title = ?, template_id = ?, content_json = ?, modified_by = ? WHERE id = ?',
[payload.title, payload.templateId, payload.contentJson, actorId, slide.id]
);
await publishMediaAssets(pool, Array.from(new Set(Array.from(existingUploadRefs).concat(Array.from(nextUploadRefs)))));
await syncPlaylistUploadsOnChange({
key: 'slide:update:' + slide.id,
pool: pool,
@@ -615,7 +630,9 @@ module.exports = function registerContentRoutes(app, deps) {
app.get('/templates/new', requirePermission('templates.create'), async function (req, res, next) {
try {
const data = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
await syncMediaAssetsFromDirectory(pool, deps.uploadDir);
const mediaAssets = (await fetchMediaAssets(pool)).slice(0, 24);
res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser, mediaAssets));
} catch (error) {
next(error);
}
@@ -623,6 +640,10 @@ module.exports = function registerContentRoutes(app, deps) {
app.post('/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) {
try {
await validateUploadedFiles(req.files || []);
await registerMediaAssets(pool, req.files, function (file) {
return '/media/uploads/' + file.filename;
}, getUploadedFileMediaType, getAuditUserId(req));
const payload = await common.buildTemplatePayload(pool, req, null);
if (!payload.regions.length) {
return res.status(400).send('At least 1 region needs to be added.');
@@ -668,7 +689,9 @@ module.exports = function registerContentRoutes(app, deps) {
template.region_usage = await fetchTemplateRegionUsage(template);
template.inUse = (await fetchSlidesByTemplateId(template.id)).length > 0;
const sizeData = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser));
await syncMediaAssetsFromDirectory(pool, deps.uploadDir);
const mediaAssets = await fetchMediaAssets(pool);
res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser, mediaAssets));
} catch (error) {
next(error);
}
@@ -676,6 +699,10 @@ module.exports = function registerContentRoutes(app, deps) {
app.post('/templates/:id', requirePermission('templates.update'), upload.any(), async function (req, res, next) {
try {
await validateUploadedFiles(req.files || []);
await registerMediaAssets(pool, req.files, function (file) {
return '/media/uploads/' + file.filename;
}, getUploadedFileMediaType, getAuditUserId(req));
const template = await common.fetchTemplateById(pool, Number(req.params.id));
if (!template) {
return res.status(404).send('Template not found');
+12
View File
@@ -16,6 +16,7 @@ const registerTimetableRoutes = require('./data-sources/timetables/routes');
const registerWeatherRoutes = require('./data-sources/weather');
const registerSettingsRoutes = require('./settings/background-tasks');
const registerFontRoutes = require('./settings/fonts');
const registerMediaLibraryRoutes = require('./settings/media-library');
const registerSettingsPageRoutes = require('./settings/routes');
const registerAuditLogRoutes = require('./settings/audit-log');
const registerAboutRoutes = require('./settings/about/routes');
@@ -333,6 +334,17 @@ function registerSettingsAndContentRoutes(app, deps) {
setAuthMessageCookie: deps.setAuthMessageCookie,
requirePermission: deps.requirePermission
});
registerMediaLibraryRoutes(app, {
pool: deps.pool,
pages: deps.pages,
upload: deps.upload,
uploadDir: deps.uploadDir,
getAuditUserId: deps.getAuditUserId,
setAuthMessageCookie: deps.setAuthMessageCookie,
requirePermission: deps.requirePermission,
syncPlaylistUploadsOnChange: deps.syncPlaylistUploadsOnChange
});
}
module.exports = { registerRoutes };
+276
View File
@@ -0,0 +1,276 @@
// Shared media library routes.
const fs = require('fs');
const path = require('path');
const { fetchAppSettings } = require('#src/data/app-settings');
const { hasAnyPermission, hasPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const mediaLibrary = require('#src/web/lib/media/library');
function requireMediaAccess(setAuthMessageCookie) {
return function (req, res, next) {
if (!req.currentUser) {
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, 'Please sign in to continue.');
}
return res.redirect('/login');
}
if (hasAnyPermission(req.currentUser, ['media-library.read', 'media-library.create', 'media-library.delete'])) {
return next();
}
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
};
}
function getMediaType(file) {
const mimeType = String(file && file.mimetype || '').toLowerCase();
if (mimeType.indexOf('video/') === 0) {
return 'video';
}
if (mimeType.indexOf('image/') === 0) {
return 'image';
}
return null;
}
function getUploadLimits(settings) {
const imageMaxBytes = Number(settings && settings['uploads.image_max_bytes']) || 100 * 1024 * 1024;
const videoMaxBytes = Number(settings && settings['uploads.video_max_bytes']) || 1024 * 1024 * 1024;
const formatBytes = function (bytes) {
const megabytes = bytes / 1024 / 1024;
if (megabytes >= 1024 && megabytes % 1024 === 0) {
return (megabytes / 1024) + ' GB';
}
return (Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')) + ' MB';
};
return {
imageMaxBytes: imageMaxBytes,
videoMaxBytes: videoMaxBytes,
imageMaxLabel: formatBytes(imageMaxBytes),
videoMaxLabel: formatBytes(videoMaxBytes),
allowedMimeTypes: Array.isArray(settings && settings['uploads.allowed_mime_types']) ? settings['uploads.allowed_mime_types'] : []
};
}
function formatFileSize(bytes) {
const value = Number(bytes) || 0;
if (value < 1024) {
return value + ' B';
}
const units = ['KB', 'MB', 'GB', 'TB'];
let size = value;
let unitIndex = -1;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const precision = size >= 10 ? 0 : 1;
return size.toFixed(precision).replace(/\.0$/, '') + ' ' + units[unitIndex];
}
function getMediaFormat(asset) {
const name = String(asset && (asset.original_name || asset.media_path) || '').trim();
const match = name.match(/\.([a-z0-9]+)$/i);
return match ? match[1].toUpperCase() : String(asset && asset.media_type || '').toUpperCase();
}
function getAssetQuery(query) {
const source = query || {};
const sort = ['date-desc', 'date-asc', 'name-asc', 'name-desc', 'size-asc', 'size-desc'].includes(String(source.sort || ''))
? String(source.sort)
: 'date-desc';
const type = ['image', 'video'].includes(String(source.type || '')) ? String(source.type) : 'all';
const usage = ['used', 'unused'].includes(String(source.usage || '')) ? String(source.usage) : 'all';
return {
search: String(source.search || '').trim().toLowerCase(),
type: type,
usage: usage,
sort: sort
};
}
function filterAndSortAssets(assets, query) {
const filtered = assets.filter(function (asset) {
const matchesSearch = !query.search || String(asset.original_name || '').toLowerCase().includes(query.search);
const matchesType = query.type === 'all' || asset.media_type === query.type;
const isUsed = Boolean(asset.inUse);
const matchesUsage = query.usage === 'all' || (query.usage === 'used' ? isUsed : !isUsed);
return matchesSearch && matchesType && matchesUsage;
});
filtered.sort(function (left, right) {
const leftName = String(left.original_name || '').toLowerCase();
const rightName = String(right.original_name || '').toLowerCase();
const leftDate = String(left.created_at || '');
const rightDate = String(right.created_at || '');
if (query.sort === 'name-asc' || query.sort === 'name-desc') {
return (query.sort === 'name-asc' ? 1 : -1) * leftName.localeCompare(rightName);
}
if (query.sort === 'size-asc' || query.sort === 'size-desc') {
return (query.sort === 'size-asc' ? 1 : -1) * (Number(left.file_size) - Number(right.file_size));
}
return (query.sort === 'date-asc' ? 1 : -1) * leftDate.localeCompare(rightDate);
});
return filtered;
}
module.exports = function registerMediaLibraryRoutes(app, deps) {
const requireAccess = requireMediaAccess(deps.setAuthMessageCookie);
const pageSize = 24;
if (!deps.pool || !deps.pages || !deps.upload || !deps.uploadDir) {
throw new Error('registerMediaLibraryRoutes requires pool, pages, upload, and uploadDir.');
}
app.get('/media-library', requireAccess, deps.requirePermission('media-library.read'), async function (req, res, next) {
try {
await mediaLibrary.syncMediaAssetsFromDirectory(deps.pool, deps.uploadDir);
const allAssets = await mediaLibrary.fetchMediaAssets(deps.pool);
const usage = await Promise.all(allAssets.map(function (asset) {
return mediaLibrary.countMediaAssetReferences(deps.pool, asset.media_path);
}));
allAssets.forEach(function (asset, index) {
asset.referenceCount = usage[index];
asset.inUse = usage[index] > 0;
asset.displayFileSize = formatFileSize(asset.file_size);
asset.mediaFormat = getMediaFormat(asset);
asset.canDelete = hasPermission(req.currentUser, 'media-library.delete');
});
const settings = await fetchAppSettings(deps.pool);
const filteredAssets = filterAndSortAssets(allAssets, getAssetQuery(req.query));
res.send(deps.pages.renderMediaLibraryPage({
assets: filteredAssets.slice(0, pageSize),
assetTotal: filteredAssets.length,
hasMore: filteredAssets.length > pageSize,
uploadLimits: getUploadLimits(settings)
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/media-library/assets', requireAccess, deps.requirePermission('media-library.read'), async function (req, res, next) {
try {
await mediaLibrary.syncMediaAssetsFromDirectory(deps.pool, deps.uploadDir);
const allAssets = await mediaLibrary.fetchMediaAssets(deps.pool);
const usage = await Promise.all(allAssets.map(function (asset) {
return mediaLibrary.countMediaAssetReferences(deps.pool, asset.media_path);
}));
allAssets.forEach(function (asset, index) {
asset.referenceCount = usage[index];
asset.inUse = usage[index] > 0;
asset.displayFileSize = formatFileSize(asset.file_size);
asset.mediaFormat = getMediaFormat(asset);
asset.canDelete = hasPermission(req.currentUser, 'media-library.delete');
});
const offset = Math.max(0, Number.parseInt(req.query.offset, 10) || 0);
const limit = Math.min(pageSize, Math.max(1, Number.parseInt(req.query.limit, 10) || pageSize));
const filteredAssets = filterAndSortAssets(allAssets, getAssetQuery(req.query));
const assets = filteredAssets.slice(offset, offset + limit);
res.json({ assets: assets, total: filteredAssets.length, nextOffset: offset + assets.length, hasMore: offset + assets.length < filteredAssets.length });
} catch (error) {
next(error);
}
});
app.post('/media-library', requireAccess, deps.requirePermission('media-library.create'), deps.upload.single('file'), async function (req, res, next) {
try {
if (!req.file) {
return res.redirect('/media-library?message=' + encodeURIComponent('Choose an image or video to upload.'));
}
const mediaType = getMediaType(req.file);
const settings = await fetchAppSettings(deps.pool);
const uploadLimits = getUploadLimits(settings);
const mimeType = String(req.file.mimetype || '').trim().toLowerCase();
const allowedMimeTypes = uploadLimits.allowedMimeTypes;
const maxBytes = mediaType === 'video' ? uploadLimits.videoMaxBytes : uploadLimits.imageMaxBytes;
if (allowedMimeTypes.indexOf(mimeType) === -1 || (mediaType !== 'image' && mediaType !== 'video')) {
await fs.promises.unlink(req.file.path).catch(function () {});
return res.redirect('/media-library?message=' + encodeURIComponent('This media type is disabled in Media Uploads settings.'));
}
if (Number(req.file.size || 0) > maxBytes) {
await fs.promises.unlink(req.file.path).catch(function () {});
const limitLabel = maxBytes >= 1024 * 1024 * 1024 && maxBytes % (1024 * 1024 * 1024) === 0
? (maxBytes / (1024 * 1024 * 1024)) + ' GB'
: Math.round(maxBytes / (1024 * 1024)) + ' MB';
return res.redirect('/media-library?message=' + encodeURIComponent('File must be ' + limitLabel + ' or smaller.'));
}
await mediaLibrary.registerMediaAsset(deps.pool, Object.assign({}, req.file, { mediaType: mediaType }), '/media/uploads/' + req.file.filename, deps.getAuditUserId(req));
res.redirect('/media-library?message=' + encodeURIComponent('Media uploaded.'));
} catch (error) {
if (req.file && req.file.path) {
await fs.promises.unlink(req.file.path).catch(function () {});
}
next(error);
}
});
async function deleteMediaAsset(assetId) {
const [rows] = await deps.pool.query('SELECT id, media_path FROM c_media_assets WHERE id = ?', [Number(assetId)]);
const asset = rows && rows[0];
if (!asset) {
return 'missing';
}
const referenceCount = await mediaLibrary.countMediaAssetReferences(deps.pool, asset.media_path);
if (referenceCount > 0) {
return 'in-use';
}
const filePath = path.join(path.dirname(deps.uploadDir), asset.media_path.replace(/^\/media\//, ''));
await deps.pool.query('DELETE FROM c_media_assets WHERE id = ?', [asset.id]);
await fs.promises.unlink(filePath).catch(function (error) {
if (error && error.code !== 'ENOENT') {
throw error;
}
});
if (typeof deps.syncPlaylistUploadsOnChange === 'function') {
await deps.syncPlaylistUploadsOnChange({
key: 'media-library:delete:' + asset.id,
pool: deps.pool,
localUploadDir: deps.uploadDir,
previousUploadRefs: [asset.media_path],
nextUploadRefs: []
});
}
return 'deleted';
}
app.post('/media-library/delete', requireAccess, deps.requirePermission('media-library.delete'), async function (req, res, next) {
try {
const rawAssetIds = req.body && (req.body.assetIds || req.body['assetIds[]']);
const assetIds = Array.from(new Set((Array.isArray(rawAssetIds) ? rawAssetIds : [rawAssetIds]).map(function (value) {
return Number(value);
}).filter(function (value) {
return Number.isInteger(value) && value > 0;
})));
if (!assetIds.length) {
return res.redirect('/media-library?message=' + encodeURIComponent('Select at least one media asset.'));
}
let deletedCount = 0;
let inUseCount = 0;
for (let index = 0; index < assetIds.length; index += 1) {
const result = await deleteMediaAsset(assetIds[index]);
if (result === 'deleted') deletedCount += 1;
if (result === 'in-use') inUseCount += 1;
}
const message = deletedCount + ' media asset' + (deletedCount === 1 ? '' : 's') + ' deleted.' + (inUseCount ? ' ' + inUseCount + ' in-use asset' + (inUseCount === 1 ? '' : 's') + ' skipped.' : '');
res.redirect('/media-library?message=' + encodeURIComponent(message));
} catch (error) {
next(error);
}
});
app.post('/media-library/:id/delete', requireAccess, deps.requirePermission('media-library.delete'), async function (req, res, next) {
try {
const result = await deleteMediaAsset(req.params.id);
if (result === 'missing') {
return res.redirect('/media-library?message=' + encodeURIComponent('Media asset not found.'));
}
if (result === 'in-use') {
return res.redirect('/media-library?message=' + encodeURIComponent('This media is still used and cannot be deleted.'));
}
res.redirect('/media-library?message=' + encodeURIComponent('Media deleted.'));
} catch (error) {
next(error);
}
});
};
@@ -0,0 +1,17 @@
// Media library page renderer.
const { renderView } = require('../../../view');
module.exports = function renderMediaLibraryPage(data, message, currentUser) {
return renderView('settings/media-library/list', {
title: 'Media library',
active: 'media-library',
bodyClass: 'media-library-page',
message: message,
currentUser: currentUser || null,
assets: data.assets || [],
assetTotal: Number(data.assetTotal || (data.assets || []).length),
hasMore: Boolean(data.hasMore),
uploadLimits: data.uploadLimits || {}
});
};
@@ -28,6 +28,7 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
const weatherLocations = data && data.weatherLocations ? data.weatherLocations : [];
const fontLibrary = data && data.fontLibrary ? data.fontLibrary : null;
const uploadLimits = data && data.uploadLimits ? data.uploadLimits : {};
const mediaAssets = Array.isArray(data && data.mediaAssets) ? data.mediaAssets : [];
const templates = buildTemplates(data && data.templates ? data.templates : [], templateRegions);
const viewSlide = slide || buildDefaultSlide();
@@ -54,7 +55,8 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '',
existingTemplateId: viewSlide && viewSlide.template_id ? viewSlide.template_id : null,
existingContent: viewSlide && viewSlide.content ? viewSlide.content : {},
uploadLimits: uploadLimits
uploadLimits: uploadLimits,
mediaAssets: mediaAssets
},
assetVersion: assetVersion,
slideEditorScripts: ['vendor/qr-code-styling/qr-code-styling-loader.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion)),
+2 -2
View File
@@ -3,6 +3,6 @@
const { renderView } = require('../../../view');
const { buildTemplateFormViewModel } = require('./form-view-model');
module.exports = function renderTemplateAddPage(template, message, canvasSizes, currentUser) {
return renderView('templates/form', buildTemplateFormViewModel(template, message, canvasSizes, currentUser, false));
module.exports = function renderTemplateAddPage(template, message, canvasSizes, currentUser, mediaAssets) {
return renderView('templates/form', buildTemplateFormViewModel(template, message, canvasSizes, currentUser, false, mediaAssets));
};
+2 -2
View File
@@ -3,6 +3,6 @@
const { renderView } = require('../../../view');
const { buildTemplateFormViewModel } = require('./form-view-model');
module.exports = function renderTemplateEditPage(template, data, message, currentUser) {
return renderView('templates/form', buildTemplateFormViewModel(template, message, data && data.canvasSizes ? data.canvasSizes : [], currentUser, true));
module.exports = function renderTemplateEditPage(template, data, message, currentUser, mediaAssets) {
return renderView('templates/form', buildTemplateFormViewModel(template, message, data && data.canvasSizes ? data.canvasSizes : [], currentUser, true, mediaAssets));
};
@@ -43,7 +43,7 @@ function resolveTemplateCanvasSize(template, canvasSizes) {
});
}
function buildTemplateFormViewModel(template, message, canvasSizes, currentUser, isEdit) {
function buildTemplateFormViewModel(template, message, canvasSizes, currentUser, isEdit, mediaAssets) {
const current = resolveTemplateCanvasSize(template || buildDefaultTemplate(), canvasSizes || []);
const assetVersion = Date.now().toString(36);
@@ -64,6 +64,7 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser,
deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '',
deleteConfirmMessage: isEdit ? 'Delete this template?' : '',
canvasSizes: canvasSizes || [],
mediaAssets: Array.isArray(mediaAssets) ? mediaAssets : [],
animationPresets: animationPresets,
scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion, 'vendor/qr-code-styling/qr-code-styling-loader.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
};
+7 -1
View File
@@ -5,6 +5,10 @@
</div>
</div>
{{#if stylesheetHref}}
<link rel="stylesheet" href="{{stylesheetHref}}" />
{{/if}}
<div class="card card-outline card-primary mb-4">
<div class="card-header">
<h3 class="card-title">Upload font</h3>
@@ -47,6 +51,7 @@
<thead>
<tr>
<th data-table-sort-key="family">Family</th>
<th>Preview</th>
<th data-table-sort-key="file">File</th>
<th data-table-sort-key="format">Format</th>
<th data-table-sort-key="status">Status</th>
@@ -58,6 +63,7 @@
{{#each fonts}}
<tr data-table-search-row data-font-toggle-row data-font-enabled="{{#if enabled}}true{{else}}false{{/if}}">
<td data-label="Family" class="fw-semibold">{{family}}</td>
<td data-label="Preview" style="font-family: '{{family}}'; font-size: 1.25rem;">Aa Bb Cc 123</td>
<td data-label="File" class="text-break">{{fileName}}</td>
<td data-label="Format">{{format}}</td>
<td data-label="Status">
@@ -85,7 +91,7 @@
{{/each}}
{{else}}
<tr data-table-search-empty-default>
<td colspan="5" class="empty">No managed fonts yet.</td>
<td colspan="6" class="empty">No managed fonts yet.</td>
</tr>
{{/if}}
</tbody>
@@ -0,0 +1,173 @@
<div class="page-header">
<div>
<h2>Media library</h2>
<p>Upload shared images and videos for use across slides and templates.</p>
</div>
</div>
<link rel="stylesheet" href="/assets/vendor/cropperjs/cropper.min.css" />
{{#if (hasPermission currentUser 'media-library.create')}}
<div class="card card-outline card-primary mb-4">
<div class="card-header"><h3 class="card-title">Upload media</h3></div>
<form method="post" action="/media-library" enctype="multipart/form-data">
<div class="card-body">
<div>
<label class="slide-image-region-upload-zone media-library-upload-zone" for="media-library-upload" data-media-library-upload-zone>
<input type="file" id="media-library-upload" name="file" class="visually-hidden" accept="{{#each uploadLimits.allowedMimeTypes}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}" data-media-library-upload data-image-max-bytes="{{uploadLimits.imageMaxBytes}}" data-video-max-bytes="{{uploadLimits.videoMaxBytes}}" required />
<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 or video here or click to upload</strong>
<span>Images and videos enabled in Media Uploads settings</span>
<span class="slide-image-region-upload-zone-limit">Images max {{uploadLimits.imageMaxLabel}}; videos max {{uploadLimits.videoMaxLabel}}</span>
</span>
</span>
</label>
</div>
</div>
</form>
</div>
{{/if}}
<section class="media-gallery" data-media-gallery-offset="{{assets.length}}" data-media-gallery-has-more="{{#if hasMore}}true{{else}}false{{/if}}">
<div class="media-gallery-toolbar">
<div>
<h3 class="h5 mb-1">Shared media</h3>
<p class="text-body-secondary small mb-0"><span data-media-gallery-count>{{assetTotal}}</span> item{{#unless (eq assetTotal 1)}}s{{/unless}}</p>
</div>
{{#if assets.length}}
<div class="media-gallery-controls">
<div class="media-gallery-search d-flex flex-wrap gap-2">
<label class="visually-hidden" for="media-gallery-search-input">Search media</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search" aria-hidden="true"></i></span>
<input id="media-gallery-search-input" type="search" class="form-control" placeholder="Search media" autocomplete="off" data-media-gallery-search />
</div>
<label class="visually-hidden" for="media-gallery-type-filter">Filter media type</label>
<select id="media-gallery-type-filter" class="form-select form-select-sm" data-media-gallery-type-filter>
<option value="all">All types</option>
<option value="image">Images</option>
<option value="video">Videos</option>
</select>
<label class="visually-hidden" for="media-gallery-usage-filter">Filter media usage</label>
<select id="media-gallery-usage-filter" class="form-select form-select-sm" data-media-gallery-usage-filter>
<option value="all">All usage</option>
<option value="used">In use</option>
<option value="unused">Unused</option>
</select>
<label class="visually-hidden" for="media-gallery-sort">Sort media</label>
<select id="media-gallery-sort" class="form-select form-select-sm" data-media-gallery-sort>
<option value="date-desc">Newest first</option>
<option value="date-asc">Oldest first</option>
<option value="name-asc">Name A-Z</option>
<option value="name-desc">Name Z-A</option>
<option value="size-desc">Largest first</option>
<option value="size-asc">Smallest first</option>
</select>
{{#if (hasPermission currentUser 'media-library.delete')}}
<button type="button" class="btn btn-outline-secondary btn-sm" data-media-gallery-select-mode aria-pressed="false">Select</button>
<button type="button" class="btn btn-outline-danger btn-sm" data-media-gallery-delete-selected hidden disabled title="Delete selected media">Delete</button>
{{/if}}
</div>
</div>
{{/if}}
</div>
{{#if assets.length}}
<div class="media-gallery-scroll">
<div class="row g-3 g-xl-4">
{{#each assets}}
<div class="col-12 col-sm-6 col-lg-4 col-xl-2" data-media-gallery-column data-media-id="{{id}}" data-media-type="{{media_type}}" data-media-used="{{#if inUse}}true{{else}}false{{/if}}">
<article class="media-gallery-card" data-media-created-at="{{created_at}}" data-media-file-size="{{file_size}}">
<button type="button" class="media-gallery-preview" data-media-gallery-preview data-media-path="{{media_path}}" data-media-type="{{media_type}}" data-media-name="{{original_name}}" aria-label="Preview {{original_name}}">
{{#if (eq media_type 'video')}}
<video src="{{media_path}}" muted playsinline preload="metadata"></video>
<span class="media-gallery-play" aria-hidden="true"><i class="bi bi-play-fill"></i></span>
{{else}}
<img src="{{media_path}}" alt="{{original_name}}" loading="lazy" />
{{/if}}
<span class="media-gallery-type">{{mediaFormat}}</span>
</button>
<div class="media-gallery-details">
<div class="media-gallery-header{{#if (hasPermission ../currentUser 'media-library.delete')}} media-gallery-header-selectable{{/if}}">
<h4 class="media-gallery-name" title="{{original_name}}">{{original_name}}</h4>
{{#if (hasPermission ../currentUser 'media-library.delete')}}
{{#if inUse}}
<button type="button" class="btn btn-outline-secondary btn-sm media-gallery-action" disabled title="Media is in use"><i class="bi bi-lock" aria-hidden="true"></i><span class="visually-hidden">Media is in use</span></button>
{{else}}
<form method="post" action="/media-library/{{id}}/delete" data-confirm-message="Delete this media?" class="media-gallery-action">
<button type="submit" class="btn btn-outline-danger btn-sm" title="Delete media"><i class="bi bi-trash" aria-hidden="true"></i><span class="visually-hidden">Delete media</span></button>
</form>
{{/if}}
{{/if}}
</div>
<div class="media-gallery-meta">{{displayFileSize}} <span aria-hidden="true">&middot;</span> Used {{referenceCount}} time{{#unless (eq referenceCount 1)}}s{{/unless}}</div>
</div>
</article>
</div>
{{/each}}
</div>
<div class="text-center mt-4" data-media-gallery-load-more-wrap hidden>
<button type="button" class="btn btn-primary px-4" data-media-gallery-load-more>Load more...</button>
</div>
</div>
<div class="media-gallery-empty text-body-secondary" data-media-gallery-no-results hidden>No media matches your search.</div>
{{else}}
<div class="media-gallery-empty text-body-secondary">No shared media yet.</div>
{{/if}}
</section>
{{#> modal-shell modalId="media-gallery-preview-modal" modalLabelId="media-gallery-preview-modal-label" modalDialogClass="modal-dialog-centered modal-fullscreen-md-down modal-xl"}}
<div class="modal-header">
<h2 class="modal-title fs-5 text-truncate" id="media-gallery-preview-modal-label">Media preview</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body media-gallery-preview-modal-body" data-media-gallery-preview-body></div>
{{/modal-shell}}
{{#> modal-shell modalId="media-library-crop-modal" modalLabelId="media-library-crop-modal-label" modalDialogClass="modal-dialog-centered modal-xl" modalBackdropStatic=true modalKeyboardDisabled=true}}
<div class="modal-header">
<div>
<h2 class="modal-title fs-5" id="media-library-crop-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="media-library-crop-image" alt="Selected image to crop" />
<div class="slide-image-cropper-loading-overlay" aria-hidden="true">
<div class="spinner-border text-primary slide-image-cropper-loading-spinner" role="status">
<span class="visually-hidden">Loading image editor</span>
</div>
</div>
</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-media-library-crop-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-media-library-crop-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-media-library-crop-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-media-library-crop-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-media-library-crop-action="ratio" data-media-library-crop-ratio="free">Free</button>
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="ratio" data-media-library-crop-ratio="1:1">1:1</button>
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="ratio" data-media-library-crop-ratio="4:3">4:3</button>
<button type="button" class="btn btn-outline-secondary" data-media-library-crop-action="ratio" data-media-library-crop-ratio="16:9">16:9</button>
</div>
<button type="button" class="btn btn-outline-danger btn-sm" data-media-library-crop-action="reset"><i class="bi bi-arrow-repeat" aria-hidden="true"></i> Reset</button>
</div>
<p class="small text-body-secondary mb-0" id="media-library-crop-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-media-library-crop-apply>Apply crop</button>
<button type="button" class="btn btn-warning" data-media-library-crop-cancel>Cancel</button>
</div>
</div>
{{/modal-shell}}
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
<script type="module" src="/assets/js/settings/media-library.js?v={{appVersion}}"></script>
+8
View File
@@ -239,6 +239,14 @@
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'media-library.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'media-library')}}active{{/if}}" href="/media-library">
<i class="nav-icon bi bi-images"></i>
<p>Media library</p>
</a>
</li>
{{/if}}
{{#if (anyPermission currentUser 'rss-feeds.read' 'schedules.read' 'api-sources.read' 'weather.read')}}
<li class="nav-header">DATA SOURCES</li>
{{/if}}
@@ -0,0 +1,57 @@
{{#> modal-shell modalId="media-picker-modal" modalLabelId="media-picker-modal-label" modalDialogClass="modal-dialog-centered modal-dialog-scrollable modal-xxl"}}
<div class="modal-header">
<h2 class="modal-title fs-5" id="media-picker-modal-label">Choose media</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="media-picker-toolbar mb-3" data-media-picker-toolbar>
<label class="visually-hidden" for="media-picker-search">Search media</label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search" aria-hidden="true"></i></span>
<input id="media-picker-search" type="search" class="form-control" placeholder="Search media" autocomplete="off" data-media-picker-search />
</div>
<label class="visually-hidden" for="media-picker-sort">Sort media</label>
<select id="media-picker-sort" class="form-select" data-media-picker-sort>
<option value="date-desc">Newest first</option>
<option value="date-asc">Oldest first</option>
<option value="name-asc">Name A-Z</option>
<option value="name-desc">Name Z-A</option>
</select>
<button type="button" class="btn btn-primary text-nowrap" data-media-picker-upload><i class="bi bi-cloud-arrow-up me-1" aria-hidden="true"></i>Upload media</button>
</div>
<div class="media-picker-scroll">
<div class="row g-3" data-media-picker-grid>
{{#if mediaAssets.length}}
{{#each mediaAssets}}
<div class="col-6 col-md-4 col-lg-3" data-media-picker-item data-media-type="{{media_type}}" data-media-name="{{original_name}}" data-media-created-at="{{created_at}}">
<button type="button" class="media-picker-item w-100 text-start" data-media-picker-path="{{media_path}}" data-media-picker-type="{{media_type}}">
<span class="media-picker-item-preview">
{{#if (eq media_type 'video')}}
<video src="{{media_path}}" muted playsinline preload="metadata"></video>
{{else}}
<img src="{{media_path}}" alt="" loading="lazy" />
{{/if}}
</span>
<span class="media-picker-item-name text-truncate d-block">{{original_name}}</span>
<span class="media-picker-item-statuses">
<span class="media-picker-item-current" data-media-picker-current hidden><i class="bi bi-check-circle me-1" aria-hidden="true"></i>Current</span>
<span class="media-picker-item-selected" data-media-picker-selected hidden><i class="bi bi-check-circle-fill me-1" aria-hidden="true"></i>Selected</span>
</span>
</button>
</div>
{{/each}}
{{else}}
<div class="col-12"><p class="text-body-secondary mb-0">No media has been added to the library yet.</p></div>
{{/if}}
</div>
<p class="text-body-secondary mb-0" data-media-picker-no-results hidden>No matching media found.</p>
<div class="text-center mt-3" data-media-picker-load-more-wrap hidden>
<button type="button" class="btn btn-primary px-4" data-media-picker-load-more>Load more...</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-media-picker-cancel>Cancel</button>
<button type="button" class="btn btn-primary" data-media-picker-confirm>Confirm selection</button>
</div>
{{/modal-shell}}
+3
View File
@@ -118,6 +118,8 @@
</div>
{{/modal-shell}}
{{> media-picker-modal mediaAssets=slideEditorData.mediaAssets}}
{{#if isEdit}}
<form id="delete-slide-form" method="post" action="/slides/{{slide.id}}/delete" data-confirm-message="Delete this slide?"></form>
{{/if}}
@@ -132,4 +134,5 @@
{{/each}}
<script type="module" src="/assets/js/slides/slide-form.js?v={{assetVersion}}"></script>
<script type="module" src="/assets/js/slides/slide-image-cropper.js?v={{assetVersion}}"></script>
<script type="module" src="/assets/js/media-picker.js?v={{appVersion}}"></script>
+20 -12
View File
@@ -10,7 +10,7 @@
<input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" />
<input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" />
<input type="hidden" id="canvas-size-summary" />
<input type="hidden" name="existing_background_image_path" value="{{template.background_image_path}}" />
<input type="hidden" name="existing_background_image_path" id="existing-background-image-path" value="{{template.background_image_path}}" />
<input type="hidden" name="regions_json" id="regions-json" value="" />
<textarea id="template-region-usage" hidden>{{json template.region_usage}}</textarea>
@@ -98,22 +98,27 @@
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="background-image" class="form-label">Background image</label>
<div class="template-background-row d-flex gap-2">
<input type="file" name="background_image" id="background-image" class="form-control" accept="image/*" />
<button type="button" class="btn btn-outline-secondary" id="remove-background-image">Remove</button>
<div class="col-12 col-md-9">
<span class="form-label d-block">Background image</span>
<div class="row g-3">
<div class="col-12 col-md-4">
<input type="file" name="background_image" id="background-image" class="d-none" accept="image/*" />
<button type="button" class="btn btn-outline-secondary w-100" data-media-picker data-media-picker-type="image" data-media-picker-hidden="existing-background-image-path"><i class="bi bi-images me-1" aria-hidden="true"></i>Choose</button>
</div>
<div class="col-12 col-md-4">
<button type="button" class="btn btn-outline-secondary w-100" id="remove-background-image">Remove</button>
</div>
<div class="col-12 col-md-4">
<button type="button" class="btn btn-outline-secondary w-100" data-bs-toggle="modal" data-bs-target="#background-advanced-modal"><i class="bi bi-sliders me-1" aria-hidden="true"></i>Advanced background</button>
</div>
</div>
<input type="checkbox" name="remove_background_image" id="remove-background-image-flag" value="1" hidden />
</div>
<div class="col-12 col-md-6">
<div class="col-12 col-md-3">
<label for="background-color" class="form-label">Background colour</label>
<input type="color" name="background_color" id="background-color" class="form-control form-control-color w-100" value="{{#if template.background_color}}{{template.background_color}}{{else}}#111111{{/if}}" title="Choose a background colour" />
</div>
<div class="col-12">
<input type="hidden" name="background_gradient" id="background-gradient" value="{{template.background_gradient}}" />
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#background-advanced-modal"><i class="bi bi-sliders me-1" aria-hidden="true"></i>Advanced background</button>
</div>
<input type="hidden" name="background_gradient" id="background-gradient" value="{{template.background_gradient}}" />
</div>
</div>
</div>
@@ -224,4 +229,7 @@
{{> signage/templates/animation-advanced-modal}}
<textarea id="template-editor-data" hidden>{{json template.regions}}</textarea>
{{> media-picker-modal mediaAssets=mediaAssets}}
<textarea id="template-editor-data" hidden>{{json template.regions}}</textarea>
<script type="module" src="/assets/js/media-picker.js?v={{appVersion}}"></script>