Release v2.13.1
This commit is contained in:
1 parent
0a03cdd0b7
commit
394d23bb4d
47 files changed
+2408
-559
No files matched your search
+17
-2
@@ -101,7 +101,22 @@ function stripEditorOnlyMarkup(value) {
|
||||
}
|
||||
|
||||
function normalizeEditorMarkup(value) {
|
||||
return String(value === undefined || value === null ? '' : value).trim();
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
.replace(/https?:\/\/[^"'\s<>]+(\/media\/[^"'\s<>)]*)/gi, '$1')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeEditorMediaReferences(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(normalizeEditorMediaReferences);
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
Object.keys(value).forEach((key) => {
|
||||
value[key] = normalizeEditorMediaReferences(value[key]);
|
||||
});
|
||||
return value;
|
||||
}
|
||||
return typeof value === 'string' ? normalizeEditorMarkup(value) : value;
|
||||
}
|
||||
|
||||
async function fetchSlideById(pool, id) {
|
||||
@@ -507,7 +522,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
|
||||
};
|
||||
}
|
||||
}
|
||||
return content;
|
||||
return normalizeEditorMediaReferences(content);
|
||||
}
|
||||
|
||||
async function buildSlidePayload(pool, req, existingSlide) {
|
||||
|
||||
@@ -103,6 +103,24 @@ async function ensureSchema(pool, options) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_media_assets (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
media_path VARCHAR(512) NOT NULL UNIQUE,
|
||||
original_name VARCHAR(255) NOT NULL,
|
||||
media_type VARCHAR(16) NOT NULL,
|
||||
mime_type VARCHAR(128) NOT NULL,
|
||||
file_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
is_published TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
INDEX idx_c_media_assets_type (media_type),
|
||||
INDEX idx_c_media_assets_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_playlist_slides (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
|
||||
@@ -621,6 +621,37 @@ const VERSIONED_MIGRATIONS = [
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.13.0',
|
||||
label: 'v2.13.0 media library schema',
|
||||
run: async function (pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_media_assets (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
media_path VARCHAR(512) NOT NULL UNIQUE,
|
||||
original_name VARCHAR(255) NOT NULL,
|
||||
media_type VARCHAR(16) NOT NULL,
|
||||
mime_type VARCHAR(128) NOT NULL,
|
||||
file_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
modified_by INT NULL,
|
||||
INDEX idx_c_media_assets_type (media_type),
|
||||
INDEX idx_c_media_assets_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.13.1',
|
||||
label: 'v2.13.1 pending media upload visibility',
|
||||
run: async function (pool) {
|
||||
if (!(await columnExists(pool, 'c_media_assets', 'is_published'))) {
|
||||
await pool.query('ALTER TABLE c_media_assets ADD COLUMN is_published TINYINT(1) NOT NULL DEFAULT 1 AFTER file_size');
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
+10
@@ -81,6 +81,16 @@ const PERMISSION_SECTIONS = [
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete slide templates.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'media-library',
|
||||
order: 45,
|
||||
name: 'Media library',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View shared media assets.' },
|
||||
{ key: 'create', name: 'Create', description: 'Upload shared media assets.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete unused shared media assets.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'canvas-sizes',
|
||||
order: 50,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 = '';
|
||||
});
|
||||
})();
|
||||
@@ -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>' +
|
||||
|
||||
@@ -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>' +
|
||||
|
||||
@@ -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>' +
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
@@ -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)),
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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])
|
||||
};
|
||||
|
||||
@@ -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">·</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>
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user