Release 2.21.0: non-destructive media and scoped player sync
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m36s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s

This commit is contained in:
lzstealth committed 2026-10-26 21:46:33 +00:00
1 parent 24df245ea1
commit 7cff39dc76
35 files changed
+2009 -949

No files matched your search

+14 -1
View File
@@ -319,9 +319,22 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
const uploaded = filesByField[`region_image_${region.id}`];
const existing = body[`existing_region_image_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
const previous = existingContent[region.region_key] || {};
const value = uploaded ? `/media/uploads/${uploaded.filename}` : getSubmittedValue(body, `existing_region_image_${region.id}`, current);
const sameImage = value === current;
const original = uploaded ? value : getSubmittedValue(body, `region_image_original_${region.id}`, sameImage ? previous.original_value || value : value);
const submittedCrop = body[`region_image_crop_${region.id}`];
const crop = submittedCrop === undefined ? (sameImage ? previous.crop : null) : parseJsonSafe(submittedCrop);
const normalizedCrop = {};
for (const field of ['x', 'y', 'width', 'height', 'rotate', 'scaleX', 'scaleY']) {
const number = Number(crop && crop[field]);
if (crop && Number.isFinite(number) && Math.abs(number) <= 10000000) normalizedCrop[field] = number;
}
content[region.region_key] = {
type: 'image',
value: uploaded ? `/media/uploads/${uploaded.filename}` : getSubmittedValue(body, `existing_region_image_${region.id}`, current)
value: value,
original_value: value ? original || value : '',
crop: value && normalizedCrop.width > 0 && normalizedCrop.height > 0 ? normalizedCrop : null
};
} else if (region.region_type === 'video') {
const uploaded = filesByField[`region_video_${region.id}`];
+18
View File
@@ -0,0 +1,18 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
async function getMediaStorageId(mediaDir) {
const filename = path.join(path.resolve(mediaDir), '.storage-id');
await fs.promises.mkdir(path.dirname(filename), { recursive: true });
try {
await fs.promises.writeFile(filename, crypto.randomUUID(), { flag: 'wx' });
} catch (error) {
if (error.code !== 'EEXIST') throw error;
}
const value = (await fs.promises.readFile(filename, 'utf8')).trim();
if (!/^[a-f0-9-]{36}$/i.test(value)) throw new Error('Invalid media storage identifier.');
return value;
}
module.exports = { getMediaStorageId };
+1
View File
@@ -34,6 +34,7 @@ async function start() {
const PLAYER_AGENT_RECONNECT_DELAY_MS = Number(process.env.PLAYER_AGENT_RECONNECT_DELAY_MS || 5000);
const ASSET_DIR = path.join(__dirname, 'player', 'public');
const MEDIA_DIR = path.join(__dirname, '..', 'media');
await require('#src/media-storage').getMediaStorageId(MEDIA_DIR);
const ONBOARDING_QUEUE_FILE = path.join(MEDIA_DIR, 'player-onboarding-queue.json');
const DB_SYNC_INTERVAL_MS = Number(process.env.PLAYER_DB_SYNC_INTERVAL_MS || 15000);
const RECONNECT_SYNC_STALE_MS = 60 * 1000;
+26
View File
@@ -1,3 +1,29 @@
.player-media-loading {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: #15191d;
color: #fff;
}
.player-media-loading-spinner {
width: 2.5rem;
height: 2.5rem;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: currentColor;
border-radius: 50%;
animation: player-media-loading-spin 0.8s linear infinite;
}
@keyframes player-media-loading-spin {
to { transform: rotate(360deg); }
}
html,
body {
margin: 0;
+67
View File
@@ -1,3 +1,70 @@
async function waitForSlideImages(markup, renderRequestId) {
if (typeof Image !== 'function' || typeof document === 'undefined') return markup;
var template = document.createElement('template');
template.innerHTML = markup;
var sources = Array.from(new Set(Array.from(template.content.querySelectorAll('img[src]')).map(function (image) { return image.getAttribute('src'); }).filter(Boolean)));
if (!sources.length) return markup;
if (!lastRenderedViewKey && !app.querySelector('.player-media-loading')) {
var loading = document.createElement('div');
loading.className = 'empty player-media-loading';
loading.setAttribute('role', 'status');
loading.setAttribute('aria-live', 'polite');
loading.innerHTML = '<span class="player-media-loading-spinner" aria-hidden="true"></span><span>Loading media...</span>';
app.appendChild(loading);
}
async function loadSource(source) {
var attempt = 0;
var loadedUrl = source;
while (renderRequestId === slideRenderRequestId) {
var ready = await new Promise(function (resolve) {
var image = new Image();
var settled = false;
var timeout;
function finish(value) {
if (settled) return;
settled = true;
window.clearTimeout(timeout);
image.onload = null;
image.onerror = null;
resolve(value);
}
image.onload = async function () {
try {
if (typeof image.decode === 'function') await image.decode();
loadedUrl = image.src;
finish(image.naturalWidth > 0);
} catch (_error) { finish(false); }
};
image.onerror = function () { finish(false); };
timeout = window.setTimeout(function () { finish(false); }, 10000);
var url = source;
if (attempt) {
try {
var retryUrl = new URL(source, window.location.href);
if (retryUrl.origin === window.location.origin && retryUrl.pathname.startsWith('/media/')) {
retryUrl.searchParams.set('_mediaReady', String(Date.now()));
url = retryUrl.toString();
}
} catch (_error) {}
}
image.src = url;
});
if (ready) {
if (loadedUrl !== source) template.content.querySelectorAll('img[src]').forEach(function (element) {
if (element.getAttribute('src') === source) element.setAttribute('src', loadedUrl);
});
return renderRequestId === slideRenderRequestId;
}
if (renderRequestId !== slideRenderRequestId) return false;
attempt += 1;
await new Promise(function (resolve) { window.setTimeout(resolve, Math.min(1000 * attempt, 5000)); });
}
return false;
}
var results = await Promise.all(sources.map(loadSource));
return renderRequestId === slideRenderRequestId && results.every(Boolean) ? template.innerHTML : false;
}
// Slide media startup and teardown helpers.
var renderedVideoPlaybackCleanup = new WeakMap();
@@ -56,6 +56,11 @@ async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
index = currentIndex;
var markup = buildSlideMarkup(slide);
if (typeof waitForSlideImages === 'function') {
var readyMarkup = await waitForSlideImages(markup, renderRequestId);
if (readyMarkup === false || renderRequestId !== slideRenderRequestId) return false;
if (typeof readyMarkup === 'string') markup = readyMarkup;
}
var shouldFade = !(options && options.skipFade) && currentPlaylistFadeBetweenSlides;
var mediaDelayMs = slide && slide.use_video_duration ? 0 : undefined;
renderSlideMarkup(markup, shouldFade, mediaDelayMs);
@@ -159,6 +164,17 @@ function handleRtmpPlaybackFailure(message, options) {
// Fetch and queue data updates; rendering and pause/resume own the slide timer.
function refresh(applyImmediately, bypassDebounce) {
if (applyImmediately && typeof document !== 'undefined' && document.querySelectorAll) {
document.querySelectorAll('img').forEach(function (image) {
if (!image.complete || image.naturalWidth > 0) return;
try {
var mediaUrl = new URL(image.currentSrc || image.src, window.location.href);
if (mediaUrl.origin !== window.location.origin || !mediaUrl.pathname.startsWith('/media/')) return;
mediaUrl.searchParams.set('_mediaRefresh', String(Date.now()));
image.src = mediaUrl.toString();
} catch (_error) {}
});
}
if (playlistRefreshInFlight) {
return;
}
@@ -21,7 +21,7 @@ function startPlayerWatchdog() {
}
playerWatchdogTimer = window.setInterval(function () {
if (document.visibilityState === 'hidden' || isPaused || !slides.length || !lastRenderedSlide) {
if (document.visibilityState === 'hidden' || isPaused || !slides.length || !lastRenderedSlide || (document.querySelector && document.querySelector('.player-media-loading'))) {
return;
}
+8 -24
View File
@@ -303,31 +303,15 @@ function registerPlayerRoutes(app, options) {
}
});
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
if (bridgeBaseUrl) {
void fetch(new URL('/api/media/config', bridgeBaseUrl).toString(), {
method: 'GET',
headers: createRequestAuthHeaders({
method: 'GET',
pathname: '/api/media/config'
})
}).then(async function (response) {
res.status(response.status);
const contentType = response.headers.get('content-type');
if (contentType) {
res.type(contentType);
}
res.send(await response.text());
}).catch(function (_error) {
res.status(502).json({ error: 'Thin client unavailable.' });
app.get('/api/media/config', requireRequestAuth, async function (_req, res, next) {
try {
const { getMediaStorageId } = require('#src/media-storage');
res.json({
mediaDir: mediaDir,
uploadDir: path.join(mediaDir, 'uploads'),
storageId: await getMediaStorageId(mediaDir)
});
return;
}
res.json({
mediaDir: mediaDir,
uploadDir: path.join(mediaDir, 'uploads')
});
} catch (error) { next(error); }
});
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) {
+2
View File
@@ -30,6 +30,7 @@ const { sendAccountEmail } = require('#src/data/mailer');
const { recordRequestAuditEvent } = require('#src/data/audit-log');
const { hasAnyPermission } = require('#src/rbac');
const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
const { getMediaStorageId } = require('#src/media-storage');
const {
formatDashboardDate,
readArrayField,
@@ -53,6 +54,7 @@ async function start() {
const pool = common.createPool();
const webConfig = createWebConfig();
fs.mkdirSync(webConfig.mediaDir, { recursive: true });
await getMediaStorageId(webConfig.mediaDir);
await ensureFontLibrary(webConfig.mediaDir);
await common.ensureSchema(pool, { mediaDir: webConfig.mediaDir });
await common.bootstrapDatabase(pool);
+1
View File
@@ -76,6 +76,7 @@ function createWebBootstrap(options) {
count: Array.isArray(payload.connections) ? payload.connections.length : 0,
connections: Array.isArray(payload.connections) ? payload.connections : []
});
uploadSyncService.scheduleAssignmentSync(uploadDir);
if (broadcastDashboardState) {
broadcastDashboardState().catch(function (error) {
console.error(error);
+91
View File
@@ -0,0 +1,91 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const sharp = require('sharp');
const { outputSize } = require('../../public/js/shared/image-crop');
const { applyLayout } = require('../../public/js/shared/region-layout');
async function prepareImageCopies(content, template, uploadDir) {
const files = [];
if (!uploadDir) return files;
const canvas = { width: template.canvas_size_width, height: template.canvas_size_height };
for (const region of template.regions || []) {
const image = content[region.region_key];
if (region.region_type !== 'image' || !image || !image.value) continue;
const crop = image.crop;
if (crop && (!['x', 'y', 'width', 'height'].every(field => Number.isFinite(crop[field])) || crop.width <= 0 || crop.height <= 0)) {
throw Object.assign(new Error('Invalid image crop.'), { statusCode: 400 });
}
const original = String(image.original_value || image.value);
if (!crop && !original.startsWith('/media/uploads/')) continue;
const filename = original.startsWith('/media/uploads/') ? original.slice('/media/uploads/'.length) : '';
if (!filename || filename !== path.basename(filename) || filename.includes('\\')) {
throw Object.assign(new Error('The original image must be a local upload.'), { statusCode: 400 });
}
const originalPath = path.join(uploadDir, filename);
const sourceStats = await fs.promises.stat(originalPath).catch(error => {
if (error.code === 'ENOENT') throw Object.assign(new Error('The original image is missing. Upload it again before resizing this image.'), { statusCode: 400 });
throw error;
});
const bounds = applyLayout(region, image, canvas);
const sourceBuffer = crop ? null : await fs.promises.readFile(originalPath);
const metadata = sourceBuffer ? await sharp(sourceBuffer, { animated: true }).metadata() : null;
const sourceSize = metadata && { width: metadata.width, height: metadata.pageHeight || metadata.height };
if (metadata && metadata.orientation >= 5) [sourceSize.width, sourceSize.height] = [sourceSize.height, sourceSize.width];
const size = outputSize(crop || sourceSize, bounds, canvas);
if (!crop && size.width >= sourceSize.width && size.height >= sourceSize.height) {
image.original_value = original;
image.value = original;
continue;
}
const extension = !crop && /\.gif$/i.test(filename) ? '.gif' : /\.jpe?g$/i.test(filename) ? '.jpg' : /\.webp$/i.test(filename) ? '.webp' : '.png';
const digest = crypto.createHash('sha256').update(JSON.stringify([1, original, sourceStats.size, sourceStats.mtimeMs, crop || null, size])).digest('hex');
const copyName = (crop ? 'image-crop-' : 'image-playback-') + digest + extension;
const copyPath = path.join(uploadDir, copyName);
let copyStats = await fs.promises.stat(copyPath).catch(error => { if (error.code !== 'ENOENT') throw error; return null; });
if (!copyStats) {
let output;
if (!crop) {
output = sharp(sourceBuffer, { animated: true }).autoOrient().resize(size.width, size.height, { fit: 'inside', withoutEnlargement: true });
} else {
const transformed = await sharp(originalPath).autoOrient().png().toBuffer();
const rotated = await sharp(transformed).flip(crop.scaleY === -1).flop(crop.scaleX === -1)
.rotate(Number(crop.rotate) || 0, { background: '#00000000' }).ensureAlpha().png().toBuffer({ resolveWithObject: true });
const left = Math.max(0, Math.floor(crop.x));
const top = Math.max(0, Math.floor(crop.y));
const right = Math.min(rotated.info.width, Math.ceil(crop.x + crop.width));
const bottom = Math.min(rotated.info.height, Math.ceil(crop.y + crop.height));
output = sharp({ create: { width: size.width, height: size.height, channels: 4, background: '#00000000' } });
if (right > left && bottom > top) {
const outputLeft = Math.max(0, Math.round((left - crop.x) * size.width / crop.width));
const outputTop = Math.max(0, Math.round((top - crop.y) * size.height / crop.height));
const width = Math.min(size.width - outputLeft, Math.max(1, Math.round((right - left) * size.width / crop.width)));
const height = Math.min(size.height - outputTop, Math.max(1, Math.round((bottom - top) * size.height / crop.height)));
if (width > 0 && height > 0) {
const pixels = await sharp(rotated.data).extract({ left, top, width: right - left, height: bottom - top })
.resize(width, height, { fit: 'fill' }).png().toBuffer();
output = output.composite([{ input: pixels, left: outputLeft, top: outputTop }]);
}
}
}
if (extension === '.jpg') output = output.jpeg({ quality: 92 });
else if (extension === '.webp') output = output.webp({ quality: 92 });
else if (extension === '.gif') output = output.gif();
else output = output.png();
const temporaryPath = copyPath + '.partial-' + crypto.randomUUID();
try {
await output.toFile(temporaryPath);
await fs.promises.rename(temporaryPath, copyPath);
} finally {
await fs.promises.unlink(temporaryPath).catch(() => {});
}
copyStats = await fs.promises.stat(copyPath);
}
image.original_value = original;
image.value = '/media/uploads/' + copyName;
files.push({ path: copyPath, filename: copyName, originalname: copyName, size: copyStats.size, mimetype: extension === '.jpg' ? 'image/jpeg' : extension === '.webp' ? 'image/webp' : extension === '.gif' ? 'image/gif' : 'image/png' });
}
return files;
}
module.exports = { prepareImageCopies };
+19
View File
@@ -285,7 +285,26 @@ async function removePendingMediaAssets(pool, uploadDir, mediaPaths) {
}
}
async function removeUnusedImageCopies(pool, uploadDir, mediaPaths) {
if (!pool || !uploadDir) return;
const paths = Array.from(new Set(Array.from(mediaPaths || []).map(normalizeMediaPath).filter(Boolean)));
for (const mediaPath of paths) {
const match = /^\/media\/uploads\/([^/\\]+)$/.exec(mediaPath);
if (!match || match[1] === '.' || match[1] === '..') continue;
const [rows] = await pool.query('SELECT id, original_name, is_published FROM c_media_assets WHERE media_path = ? LIMIT 1', [mediaPath]);
const asset = rows && rows[0];
const generated = /^image-(?:crop|playback)-[a-f0-9]{64}\.(?:jpe?g|png|webp|gif)$/i.test(match[1]);
if (!generated && !(asset && Number(asset.is_published) === 0 && /^slide-image-crop\.(?:jpe?g|png|webp)$/i.test(asset.original_name || ''))) continue;
if (await countMediaAssetReferences(pool, mediaPath) > 0) continue;
const filePath = path.join(uploadDir, match[1]);
await fs.promises.unlink(filePath).catch(function (error) { if (error.code !== 'ENOENT') throw error; });
await removeImageVariants(filePath);
await pool.query('DELETE FROM c_media_assets WHERE media_path = ?', [mediaPath]);
}
}
module.exports = {
removeUnusedImageCopies,
normalizeMediaPath,
createImageVariants,
removeImageVariants,
+69
View File
@@ -0,0 +1,69 @@
const { resolveRegionContent } = require('../../public/js/shared/region-content');
function parseContent(value) {
if (value && typeof value === 'object') return value;
try { return JSON.parse(value || '{}') || {}; } catch (_error) { return {}; }
}
function buildPlayerAssetPlan(data, snapshots, players, previousClients, collectReferences) {
const clients = new Map();
for (const client of previousClients || []) {
const previous = clients.get(client.id);
if (!previous || Number(client.connectedAt || client.seenAt) > Number(previous.connectedAt || previous.seenAt)) clients.set(client.id, Object.assign({}, client));
}
const byIdentifier = new Map(players.map(player => [String(player.identifier), player]));
const byUrl = new Map();
for (const player of players) {
for (const value of [player.public_base_url, player.internal_base_url]) {
if (value) byUrl.set(String(value).replace(/\/$/, ''), String(player.identifier));
}
}
snapshots.forEach(function (snapshot, key) {
const slug = String(snapshot.slug || key || '');
for (const connection of snapshot.connections || []) {
let identifier = String(connection.playerDeviceId || connection.playerIdentifier || '');
if (!identifier) identifier = byUrl.get(String(connection.playerPublicBaseUrl || '').replace(/\/$/, '')) || '';
if (!identifier && players.length === 1) identifier = String(players[0].identifier);
if (!byIdentifier.has(identifier)) continue;
const deviceId = String(connection.deviceId || '');
const id = deviceId || String(connection.clientId || '') || identifier + ':' + slug;
const connectedAt = Date.parse(connection.connectedAt || '') || 0;
const previous = clients.get(id);
if (previous && previous.connectedAt && connectedAt && previous.connectedAt > connectedAt) continue;
clients.set(id, { id, deviceId, playerIdentifier: identifier, screenSlug: slug, connectedAt, seenAt: Date.now() });
}
});
const screensById = new Map((data.screens || []).map(screen => [Number(screen.id), screen]));
const devices = new Map((data.onboardingDevices || []).map(device => [String(device.device_id), device]));
for (const client of clients.values()) {
const device = devices.get(client.deviceId);
if (!device) continue;
const screen = screensById.get(Number(device.screen_id));
client.screenSlug = screen ? String(screen.slug) : '';
}
const assets = new Map(players.map(player => [String(player.identifier), new Set()]));
const screenSlugs = new Map(players.map(player => [String(player.identifier), new Set()]));
for (const client of clients.values()) {
if (screenSlugs.has(client.playerIdentifier) && client.screenSlug) screenSlugs.get(client.playerIdentifier).add(client.screenSlug);
}
const slides = new Map((data.slides || []).map(slide => [Number(slide.id), slide]));
const templates = new Map((data.templates || []).map(template => [Number(template.id), template]));
for (const player of players) {
const identifier = String(player.identifier);
const refs = assets.get(identifier);
const playlistIds = new Set((data.screens || []).filter(screen => screenSlugs.get(identifier).has(String(screen.slug))).map(screen => Number(screen.playlist_id)));
for (const member of data.playlistSlides || []) {
if (!playlistIds.has(Number(member.playlist_id))) continue;
const slide = slides.get(Number(member.slide_id)) || member;
const content = parseContent(slide.content_json);
const template = templates.get(Number(slide.template_id));
if (!template) { collectReferences(content, refs); continue; }
collectReferences(template.background_image_path, refs);
const regions = template.regions || (data.templateRegions || []).filter(region => Number(region.template_id) === Number(template.id));
for (const region of regions) collectReferences(resolveRegionContent(region, content), refs);
}
}
return { assets, screenSlugs, clients: [...clients.values()] };
}
module.exports = { buildPlayerAssetPlan };
+160 -74
View File
@@ -9,6 +9,8 @@ const multer = require('multer');
const { createRequestAuthHeaders } = require('#src/request-auth');
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
const { countContentReferenceOwners } = require('#src/data/content-references');
const { buildPlayerAssetPlan } = require('#src/web/lib/media/player-assets');
const { getMediaStorageId } = require('#src/media-storage');
const {
fetchPlayerRegistrations,
getConfiguredPlayerIdentifier,
@@ -22,7 +24,7 @@ function normalizeUploadRoot(uploadDir) {
}
function normalizePlayerRowBaseUrl(player) {
return normalizeBaseUrl(player && player.internal_base_url);
return normalizeBaseUrl(player && player.internal_base_url) || normalizeBaseUrl(player && player.public_base_url);
}
function createUploadSyncService(options) {
@@ -47,6 +49,10 @@ function createUploadSyncService(options) {
let playerInternalBaseUrlPromise = null;
let playerTaskMetadata = null;
let playerTaskMetadataPromise = null;
let assignmentSignature = '';
let assignmentTimer = null;
const playerAssetSyncs = new Map();
const sharedStorageByPlayer = new Map();
if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') {
throw new Error('createUploadSyncService requires the upload dependencies.');
@@ -73,7 +79,7 @@ function createUploadSyncService(options) {
return String(player && player.identifier || '').trim() === normalizedPreferredPlayerIdentifier;
}) || null;
if (exactPlayer) {
const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(exactPlayer) || normalizedPreferredPlayerInternalBaseUrl || null;
const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(exactPlayer) || normalizedPreferredPlayerInternalBaseUrl || configuredBridgeInternalBaseUrl || null;
const resolvedPublicBaseUrl = normalizeBaseUrl(exactPlayer && exactPlayer.public_base_url);
const resolvedIdentifier = String(exactPlayer && exactPlayer.identifier || '').trim();
playerInternalBaseUrl = resolvedInternalBaseUrl || null;
@@ -81,13 +87,16 @@ function createUploadSyncService(options) {
playerIdentifier: resolvedIdentifier || normalizedPreferredPlayerIdentifier || null,
playerPublicBaseUrl: resolvedPublicBaseUrl || null,
playerInternalBaseUrl: resolvedInternalBaseUrl || null,
playerViaBridge: Boolean(configuredBridgeInternalBaseUrl && (!normalizePlayerRowBaseUrl(exactPlayer) || !isLocalLikeBaseUrl(normalizePlayerRowBaseUrl(exactPlayer)))),
playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null,
playerActive: true
};
return playerTaskMetadata;
}
} catch (_error) {
return { playerIdentifier: normalizedPreferredPlayerIdentifier, playerActive: false };
}
return { playerIdentifier: normalizedPreferredPlayerIdentifier, playerActive: false };
}
if (playerTaskMetadata && playerTaskMetadata.playerActive !== false) {
@@ -343,6 +352,128 @@ function createUploadSyncService(options) {
return refs;
}
function collectPlaybackUploadReferences(value, refs) {
refs = refs || new Set();
if (Array.isArray(value)) {
value.forEach(function (item) { collectPlaybackUploadReferences(item, refs); });
} else if (value && typeof value === 'object') {
Object.keys(value).forEach(function (key) {
if (key !== 'original_value') collectPlaybackUploadReferences(value[key], refs);
});
} else {
collectUploadReferencesFromValue(value, refs);
}
return refs;
}
function getAssetManifestDirectory(uploadDir) {
const root = path.resolve(uploadDir);
return path.join(path.basename(root) === 'uploads' ? path.dirname(root) : root, 'player-cache', 'media-sync');
}
async function getPlayerAssetPlan(uploadDir) {
if (!uploadDir || !pool || typeof common.fetchAdminData !== 'function') return null;
const directory = getAssetManifestDirectory(uploadDir);
const names = await fs.promises.readdir(directory).catch(function (error) { if (error.code === 'ENOENT') return []; throw error; });
const states = new Map();
for (const name of names.filter(function (value) { return /^[a-f0-9]{64}\.json$/.test(value); })) {
const state = JSON.parse(await fs.promises.readFile(path.join(directory, name), 'utf8'));
if (state.playerIdentifier && Array.isArray(state.files) && Array.isArray(state.clients)) states.set(state.playerIdentifier, state);
}
const players = await fetchPlayerRegistrations(pool);
const data = await common.fetchAdminData(pool);
const [devices] = await pool.query('SELECT device_id, screen_id FROM d_onboarding_devices');
const clients = Array.from(states.values()).flatMap(function (state) { return state.clients; });
const plan = buildPlayerAssetPlan(Object.assign({}, data, { onboardingDevices: devices }), playerSnapshotCache, players, clients, collectPlaybackUploadReferences);
return { directory: directory, states: states, players: players, plan: plan };
}
function syncPlayerAssetPlan(taskPayload, uploadDir) {
const key = String(taskPayload.playerIdentifier || 'configured');
const previous = playerAssetSyncs.get(key) || Promise.resolve();
const current = previous.catch(function () {}).then(function () { return reconcilePlayerAssetPlan(taskPayload, uploadDir); });
playerAssetSyncs.set(key, current);
return current.finally(function () { if (playerAssetSyncs.get(key) === current) playerAssetSyncs.delete(key); });
}
async function reconcilePlayerAssetPlan(taskPayload, uploadDir) {
const metadata = await getPlayerTaskMetadata(taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
if (!metadata || metadata.playerActive === false || !metadata.playerIdentifier) return false;
const state = await getPlayerAssetPlan(uploadDir);
if (!state || !state.plan.assets.has(metadata.playerIdentifier)) return false;
const previous = state.states.get(metadata.playerIdentifier);
if (!previous && !state.plan.clients.some(function (client) { return client.playerIdentifier === metadata.playerIdentifier; })) return false;
const desired = Array.from(state.plan.assets.get(metadata.playerIdentifier));
const previousFiles = previous ? previous.files : await collectUploadPathsFromDirectory(path.basename(path.resolve(uploadDir)) === 'uploads' ? uploadDir : path.join(uploadDir, 'uploads'));
const clients = state.plan.clients.filter(function (client) { return client.playerIdentifier === metadata.playerIdentifier; });
await fs.promises.mkdir(state.directory, { recursive: true });
const filename = crypto.createHash('sha256').update(metadata.playerIdentifier).digest('hex') + '.json';
const target = path.join(state.directory, filename);
async function saveManifest(files) {
const temporary = target + '.tmp-' + crypto.randomUUID();
try {
await fs.promises.writeFile(temporary, JSON.stringify({ playerIdentifier: metadata.playerIdentifier, files: files, clients: clients }));
await fs.promises.rename(temporary, target);
} finally {
await fs.promises.unlink(temporary).catch(function () {});
}
}
await saveManifest(Array.from(new Set(previousFiles.concat(desired))));
for (const reference of desired) {
if (!await pushUploadFileToPlayer(reference, uploadDir, metadata.playerInternalBaseUrl, metadata.playerIdentifier)) {
throw new Error('Unable to sync required playback asset to ' + metadata.playerIdentifier + ': ' + reference);
}
}
const wanted = new Set(desired);
const sharedFiles = [];
for (const reference of previousFiles) {
if (wanted.has(reference)) continue;
if (!metadata.playerViaBridge && await uploadFileExists(uploadDir, reference) && await sharesWebMediaStorage(metadata, uploadDir)) {
sharedFiles.push(reference);
continue;
}
if (!await removeSingleUploadFileFromPlayer(reference, metadata.playerInternalBaseUrl, metadata.playerIdentifier)) {
throw new Error('Unable to remove obsolete playback asset from ' + metadata.playerIdentifier + ': ' + reference);
}
}
await saveManifest(desired.concat(sharedFiles));
return true;
}
async function sharesWebMediaStorage(metadata, uploadDir) {
const key = metadata.playerIdentifier + ':' + metadata.playerInternalBaseUrl;
if (sharedStorageByPlayer.has(key)) return sharedStorageByPlayer.get(key);
try {
const response = await fetch(metadata.playerInternalBaseUrl + '/api/media/config', {
headers: Object.assign({ 'x-pulse-player-device-id': metadata.playerIdentifier }, createRequestAuthHeaders({ method: 'GET', pathname: '/api/media/config' })),
signal: AbortSignal.timeout(10000)
});
if (!response.ok || typeof response.json !== 'function') return true;
const config = await response.json();
if (!config.storageId) return true;
const root = path.resolve(uploadDir);
const shared = config.storageId === await getMediaStorageId(path.basename(root) === 'uploads' ? path.dirname(root) : root);
sharedStorageByPlayer.set(key, shared);
return shared;
} catch (_error) { return true; }
}
function scheduleAssignmentSync(uploadDir) {
const associations = [];
playerSnapshotCache.forEach(function (snapshot, slug) {
for (const client of snapshot.connections || []) associations.push([slug, client.deviceId || client.clientId || '', client.playerDeviceId || client.playerIdentifier || client.playerPublicBaseUrl || '']);
});
const signature = JSON.stringify(associations.sort(function (left, right) { return JSON.stringify(left).localeCompare(JSON.stringify(right)); }));
if (signature === assignmentSignature) return;
assignmentSignature = signature;
if (assignmentTimer) clearTimeout(assignmentTimer);
assignmentTimer = setTimeout(function () {
assignmentTimer = null;
syncPlaylistUploadsOnChange({ key: 'player-assignments', localUploadDir: uploadDir, refreshScreenSlugs: Array.from(new Set(associations.map(function (association) { return association[0]; }))) }).catch(function (error) { console.warn('Unable to reconcile player media assignments:', error); });
}, 250);
if (assignmentTimer.unref) assignmentTimer.unref();
}
function collectUploadReferencesFromTemplate(template) {
const refs = new Set();
if (!template) {
@@ -396,7 +527,8 @@ function createUploadSyncService(options) {
}
async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) {
return;
const { removeUnusedImageCopies } = require('#src/web/lib/media/library');
await removeUnusedImageCopies(pool, uploadDir, uploadPaths);
}
async function collectUploadPathsFromDirectory(uploadDir) {
@@ -531,12 +663,13 @@ function createUploadSyncService(options) {
return false;
}
const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl();
const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl(preferredPlayerIdentifier);
if (!targetBaseUrl) {
return false;
}
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
if (!playerMetadata || playerMetadata.playerActive === false) return false;
const mediaBaseUrl = playerMetadata && playerMetadata.playerIdentifier && !isLocalLikeBaseUrl(targetBaseUrl) && configuredBridgeInternalBaseUrl
? configuredBridgeInternalBaseUrl
: targetBaseUrl;
@@ -592,12 +725,13 @@ function createUploadSyncService(options) {
return false;
}
const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl();
const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl(preferredPlayerIdentifier);
if (!targetBaseUrl) {
return false;
}
const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl);
if (!playerMetadata || playerMetadata.playerActive === false) return false;
const mediaBaseUrl = playerMetadata && playerMetadata.playerIdentifier && !isLocalLikeBaseUrl(targetBaseUrl) && configuredBridgeInternalBaseUrl
? configuredBridgeInternalBaseUrl
: targetBaseUrl;
@@ -787,6 +921,9 @@ function createUploadSyncService(options) {
return;
}
if (operation.previousUploadRefs.length) {
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs);
}
const players = await fetchLivePlayerRegistrations();
if (!players.length) {
return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', {
@@ -795,7 +932,17 @@ function createUploadSyncService(options) {
});
}
return Promise.all(players.map(function (player) {
const state = await getPlayerAssetPlan(operation.localUploadDir);
const changed = new Set(operation.previousUploadRefs.concat(operation.nextUploadRefs));
const targets = state ? players.filter(function (player) {
const identifier = String(player.identifier);
const desired = Array.from(state.plan.assets.get(identifier) || []);
const previous = state.states.get(identifier);
return operation.key === 'player-assignments' || desired.some(function (reference) { return changed.has(reference); }) ||
(previous && previous.files.some(function (reference) { return changed.has(reference); })) ||
Array.from(state.plan.screenSlugs.get(identifier) || []).some(function (slug) { return operation.refreshScreenSlugs.includes(slug); });
}) : [];
return Promise.all(targets.map(function (player) {
const playerIdentifier = String(player && player.identifier || '').trim();
const playerInternalBaseUrl = String(player && player.internal_base_url || '').trim().replace(/\/$/, '');
const playerPublicBaseUrl = String(player && player.public_base_url || '').trim().replace(/\/$/, '');
@@ -937,36 +1084,8 @@ function createUploadSyncService(options) {
const playerMetadata = await getPlayerTaskMetadata(taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
const data = await common.fetchAdminData(pool);
const uploadRefs = new Set();
(data.slides || []).forEach(function (slide) {
collectUploadReferencesFromSlide(slide).forEach(function (reference) {
uploadRefs.add(reference);
});
});
(data.templates || []).forEach(function (template) {
collectUploadReferencesFromTemplate(template).forEach(function (reference) {
uploadRefs.add(reference);
});
});
await syncPlayerAssetPlan(taskPayload, uploadDir);
const fontLibraryOperations = collectFontLibrarySyncOperations(uploadDir).concat(require('#src/web/lib/media/weather-icon-library').collectWeatherIconSyncOperations(uploadDir));
const generatedCachePaths = await collectUploadPathsFromDirectory(uploadDir);
generatedCachePaths.filter(function (uploadPath) {
return String(uploadPath || '').startsWith('/media/player-cache/remote-images/');
}).forEach(function (uploadPath) {
uploadRefs.add(uploadPath);
});
for (const uploadPath of uploadRefs) {
const uploadPaths = await getUploadPathsWithVariants(uploadPath, uploadDir);
uploadPaths.forEach(function (variantPath) {
queuePlayerUploadSync({
type: 'put',
uploadPath: variantPath,
uploadDir: uploadDir,
metadata: playerMetadata
});
});
}
fontLibraryOperations.forEach(function (operation) {
if (!operation || !operation.uploadPath) {
return;
@@ -976,6 +1095,8 @@ function createUploadSyncService(options) {
type: String(operation.type || 'put').trim().toLowerCase() === 'delete' ? 'delete' : 'put',
uploadPath: operation.uploadPath,
uploadDir: uploadDir,
playerIdentifier: playerMetadata.playerIdentifier,
playerInternalBaseUrl: playerMetadata.playerInternalBaseUrl,
metadata: playerMetadata
});
});
@@ -985,45 +1106,7 @@ function createUploadSyncService(options) {
if (mode === 'playlist') {
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
const generatedCachePaths = await collectUploadPathsFromDirectory(operation.localUploadDir);
const nextUploadRefs = operation.nextUploadRefs.concat(generatedCachePaths.filter(function (uploadPath) {
return String(uploadPath || '').startsWith('/media/player-cache/remote-images/');
}));
if (nextUploadRefs.length) {
await syncUploadRefsToPlayer(nextUploadRefs, operation.localUploadDir, taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl);
}
if (operation.previousUploadRefs.length) {
const nextUploadRefSet = new Set(operation.nextUploadRefs);
const removedUploadRefs = operation.previousUploadRefs.filter(function (reference) {
return !nextUploadRefSet.has(reference);
});
for (let i = 0; i < removedUploadRefs.length; i += 1) {
const removedUploadRef = removedUploadRefs[i];
const referenceCount = await countUploadReferences(pool, removedUploadRef);
if (referenceCount > 0) {
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({
type: 'delete',
uploadPath: removedUploadRef,
uploadDir: operation.localUploadDir,
playerIdentifier: taskPayload.playerIdentifier,
playerInternalBaseUrl: taskPayload.playerInternalBaseUrl,
metadata: playerMetadata
});
}
}
await removeUnusedUploadFiles(pool, operation.localUploadDir, removedUploadRefs);
}
await syncPlayerAssetPlan(taskPayload, operation.localUploadDir);
if (operation.refreshScreenSlugs.length) {
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
@@ -1074,6 +1157,9 @@ function createUploadSyncService(options) {
createUploadMiddleware: createUploadMiddleware,
normalizeUploadReference: normalizeUploadReference,
collectUploadReferencesFromValue: collectUploadReferencesFromValue,
collectPlaybackUploadReferences: collectPlaybackUploadReferences,
scheduleAssignmentSync: scheduleAssignmentSync,
getPlayerAssetPlan: getPlayerAssetPlan,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
+1 -1
View File
@@ -28,7 +28,7 @@ function getRegionModuleScripts(assetVersion) {
}
function getRegionEditorScripts(assetVersion) {
return ['js/shared/region-content.js', 'js/shared/weather-icons.js'].concat(REGION_CORE_SCRIPTS).map((script) => withAssetVersion(script, assetVersion)).concat(getRegionModuleScripts(assetVersion));
return ['js/shared/region-content.js', 'js/shared/weather-icons.js', 'js/shared/image-crop.js'].concat(REGION_CORE_SCRIPTS).map((script) => withAssetVersion(script, assetVersion)).concat(getRegionModuleScripts(assetVersion), [withAssetVersion('js/slides/slide-image-cropper.js', assetVersion)]);
}
module.exports = {
+35
View File
@@ -3321,6 +3321,41 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
justify-items: end;
}
.slide-image-region-preview-tools {
display: grid;
grid-template-columns: 2.5rem minmax(0, 1fr);
min-height: 176px;
background: var(--bs-tertiary-bg);
}
.slide-image-region-preview-tools > .slide-image-region-preview-box {
min-width: 0;
}
.slide-image-region-crop {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-right: 1px solid var(--bs-border-color);
border-radius: 0;
background: transparent;
color: var(--bs-secondary-color);
font-size: 1.125rem;
cursor: pointer;
}
.slide-image-region-crop:hover:not(:disabled) {
background: var(--bs-secondary-bg);
color: var(--bs-body-color);
}
.slide-image-region-crop:focus-visible {
outline: 2px solid var(--bs-primary);
outline-offset: -2px;
}
.slide-image-region-preview-shell {
position: relative;
display: block;
+52 -4
View File
@@ -26,10 +26,19 @@
return '<img class="slide-preview-image" src="' + escapeHtml(src) + '" alt="" style="width:100%;height:100%;object-fit:contain;display:block;" />';
}
function renderCropButton(region) {
return '<button type="button" class="slide-image-region-crop" data-edit-image-crop="' + region.id + '" data-region-key="' + escapeHtml(region.region_key) + '" data-region-width="' + escapeHtml(region.width) + '" data-region-height="' + escapeHtml(region.height) + '" data-region-ratio="' + escapeHtml(region.width + ':' + region.height) + '" title="Edit crop" aria-label="Edit crop"><i class="bi bi-crop" aria-hidden="true"></i></button>';
}
function renderFramingControls(region, value) {
var current = value || {};
return '<input type="hidden" name="region_image_original_' + region.id + '" value="' + escapeHtml(current.original_value || current.value || '') + '">' +
'<input type="hidden" name="region_image_crop_' + region.id + '" value="' + escapeHtml(current.crop ? JSON.stringify(current.crop) : '') + '">';
}
function renderEditorCard(context) {
var region = context.region;
var current = String(context.current || '');
var regionRatio = String(context.regionRatio || '1:1');
var uploadConfig = context.uploadConfig || {};
var uploadMaxLabel = String(uploadConfig.limitLabel || context.uploadMaxLabel || '100 MB');
var uploadAccept = Array.isArray(uploadConfig.accept) ? uploadConfig.accept.join(', ') : String(uploadConfig.accept || 'image/png, image/jpeg, image/gif, image/webp, image/svg+xml');
@@ -42,7 +51,7 @@
'<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 + '" 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" />' +
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + 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-cloud-arrow-up" aria-hidden="true"></i></span>' +
'<span class="slide-image-region-upload-zone-copy">' +
@@ -60,6 +69,8 @@
'</label>' +
'</div>' +
'<div class="col-12 col-md-4">' +
'<div class="slide-image-region-preview-tools">' +
renderCropButton(region) +
'<div class="slide-image-region-preview-box">' +
(current
? '<div class="slide-image-region-preview-shell" data-remove-region-image="' + region.id + '" role="button" tabindex="0" aria-label="Remove image">' +
@@ -68,13 +79,14 @@
'</div>'
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>') +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />'
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' + renderFramingControls(region, context.imageContent)
});
}
function buildEditorCardContext(context) {
function buildEditorCardContext(context, existingContent) {
var defaultConfig = context && context.uploadConfig ? context.uploadConfig : {};
var configuredTypes = Array.isArray(context && context.uploadMimeTypes)
? context.uploadMimeTypes.filter(function (mimeType) { return String(mimeType || '').indexOf('image/') === 0; })
@@ -92,6 +104,7 @@
return {
region: context.region,
current: String(context.current || ''),
imageContent: (existingContent || {})[context.region.region_key] || {},
regionRatio: String(context.regionRatio || '1:1'),
uploadConfig: {
accept: accept,
@@ -109,12 +122,47 @@
var imageHidden = card && card.querySelector ? card.querySelector('input[type="hidden"][name="existing_region_image_' + region.id + '"]') : null;
var imageFileInput = card && card.querySelector ? card.querySelector('input[type="file"][name="region_image_' + region.id + '"]') : null;
var original = card && card.querySelector ? card.querySelector('[name="region_image_original_' + region.id + '"]') : null;
var crop = card && card.querySelector ? card.querySelector('[name="region_image_crop_' + region.id + '"]') : null;
var cropData = current.crop || null;
try { if (crop) cropData = JSON.parse(crop.value || 'null'); } catch (_error) { cropData = null; }
return {
value: imageFileInput && imageFileInput.dataset.previewUrl ? imageFileInput.dataset.previewUrl : (imageHidden ? imageHidden.value : (current && current.value !== undefined ? current.value : '')),
original_value: original ? original.value : current.original_value || current.value || '',
crop: cropData,
existingContent: existingContent || {}
};
}
if (typeof document !== 'undefined' && document.addEventListener) {
document.addEventListener('change', function (event) {
var input = event.target;
if (!input.name || input.name.indexOf('existing_region_image_') !== 0 || input.dataset.applyingCrop === '1') return;
var card = input.closest('[data-region-id]');
if (!card) return;
var regionId = input.name.slice('existing_region_image_'.length);
var original = card.querySelector('[name="region_image_original_' + regionId + '"]');
var crop = card.querySelector('[name="region_image_crop_' + regionId + '"]');
if (original) original.value = input.value;
if (crop) crop.value = '';
if (input.value) input.dataset.cropSelectionPending = '1';
else delete input.dataset.cropSelectionPending;
if (!input.value) return;
var selectedValue = input.value;
var openCropper = function () {
if (!card.isConnected || input.value !== selectedValue) return;
var button = card.querySelector('[data-edit-image-crop]');
if (button && !button.disabled) button.click();
};
var picker = document.getElementById('media-picker-modal');
if (picker && picker.classList.contains('show')) {
picker.addEventListener('hidden.bs.modal', openCropper, { once: true });
} else {
window.setTimeout(openCropper, 0);
}
}, true);
}
function handleMediaInputChange(context) {
if (!context || !context.file || typeof context.uploadFile !== 'function') {
return false;
+79 -165
View File
@@ -35,17 +35,7 @@ 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 uploadModal = document.getElementById('media-library-upload-modal');
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;
let uploadInProgress = false;
function getColumnsPerRow() {
return window.innerWidth >= 1200 ? 6 : window.innerWidth >= 992 ? 3 : window.innerWidth >= 576 ? 2 : 1;
@@ -73,28 +63,6 @@ if (addMediaButton && uploadModal) {
});
}
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');
@@ -178,52 +146,9 @@ function setCropperActionState(disabled) {
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;
function setUploadFile(file) {
if (!uploadInput || uploadInput.disabled || !file) {
return false;
}
const allowedTypes = String(uploadInput.accept || '').split(',').map(function (value) { return value.trim().toLowerCase(); }).filter(Boolean);
const fileType = String(file.type || '').toLowerCase();
@@ -232,12 +157,12 @@ function setUploadFile(file, shouldCrop) {
if (!matchesType) {
window.PulseUpload.showMessage('This media type is disabled in Media Uploads settings.');
uploadInput.value = '';
return;
return false;
}
if (maxBytes && file.size > maxBytes) {
window.PulseUpload.showMessage('This file is larger than the current upload limit of ' + window.PulseUpload.formatUploadLimit(maxBytes) + '.');
uploadInput.value = '';
return;
return false;
}
const transfer = new DataTransfer();
@@ -247,15 +172,7 @@ function setUploadFile(file, shouldCrop) {
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');
showModal(cropModal);
}
return true;
}
function submitUpload() {
@@ -272,11 +189,8 @@ function submitUpload() {
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 (file && setUploadFile(file)) {
submitUpload();
}
});
}
@@ -293,91 +207,91 @@ if (uploadZone) {
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) {
hideModal(cropModal);
return;
}
const outputType = cropSourceFile.type === 'image/jpeg' || cropSourceFile.type === 'image/webp' ? cropSourceFile.type : 'image/png';
cropper.getCroppedCanvas().toBlob(function (blob) {
if (!blob) {
window.PulseUpload.showMessage('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);
hideModal(cropModal);
if (file && setUploadFile(file)) {
submitUpload();
}, outputType, 0.92);
});
}
if (cropCancel) {
cropCancel.addEventListener('click', function () {
clearCropper();
if (uploadInput) uploadInput.value = '';
if (uploadName) uploadName.textContent = 'No file selected';
hideModal(cropModal);
});
}
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();
});
function setUploadProgress(percent, message, indeterminate) {
if (!uploadZone) return;
const progress = uploadZone.querySelector('[data-media-library-upload-progress]');
const label = uploadZone.querySelector('[data-media-library-upload-progress-text]');
const bar = uploadZone.querySelector('[data-media-library-upload-progress-bar]');
const fill = uploadZone.querySelector('[data-media-library-upload-progress-fill]');
uploadZone.classList.toggle('is-uploading', uploadInProgress);
uploadZone.setAttribute('aria-busy', uploadInProgress ? 'true' : 'false');
if (progress) progress.hidden = !uploadInProgress;
if (label) label.textContent = message;
if (bar) {
if (indeterminate) bar.removeAttribute('aria-valuenow');
else bar.setAttribute('aria-valuenow', String(percent));
}
if (fill) {
fill.style.width = percent + '%';
fill.textContent = indeterminate ? '' : percent + '%';
fill.classList.toggle('progress-bar-animated', indeterminate);
}
}
if (uploadForm) {
uploadForm.addEventListener('submit', function (event) {
event.preventDefault();
if (uploadInProgress) return;
if (uploadInput && !uploadInput.files.length) {
event.preventDefault();
window.PulseUpload.showMessage('Choose an image or video to upload.');
return;
}
const formData = new FormData(uploadForm);
const xhr = new XMLHttpRequest();
const buttons = uploadModal ? Array.from(uploadModal.querySelectorAll('button')).map(button => ({ button, disabled: button.disabled })) : [];
uploadInProgress = true;
if (uploadInput) uploadInput.disabled = true;
buttons.forEach(state => { state.button.disabled = true; });
setUploadProgress(0, 'Uploading...', true);
function finish() {
uploadInProgress = false;
if (uploadInput) uploadInput.disabled = false;
buttons.forEach(state => { state.button.disabled = state.disabled; });
setUploadProgress(0, '', false);
}
function fail(message) {
finish();
window.PulseUpload.showMessage(message);
}
xhr.open('POST', uploadForm.action, true);
xhr.withCredentials = true;
xhr.timeout = 300000;
xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.onprogress = function (event) {
if (!event.lengthComputable || !event.total) { setUploadProgress(100, 'Uploading...', true); return; }
const percent = Math.min(100, Math.round(event.loaded / event.total * 100));
setUploadProgress(percent, percent === 100 ? 'Processing...' : 'Uploading ' + percent + '%', percent === 100);
};
xhr.upload.onload = function () { setUploadProgress(100, 'Processing...', true); };
xhr.onload = async function () {
let result;
try { result = JSON.parse(xhr.responseText || '{}'); } catch (_error) { fail('Unable to upload media. Check your sign-in status and retry.'); return; }
if (xhr.status < 200 || xhr.status >= 300 || !result.success) { fail(result.error || 'Unable to upload media.'); return; }
finish();
if (uploadInput) uploadInput.value = '';
hideModal(uploadModal);
if (typeof window.showToast === 'function') window.showToast('Media uploaded.', 'success');
await refreshGallery();
};
xhr.onerror = function () { fail('Unable to upload media. Check your connection and retry.'); };
xhr.onabort = function () { fail('Upload cancelled.'); };
xhr.ontimeout = function () { fail('Upload timed out. Please try again.'); };
try { xhr.send(formData); } catch (_error) { fail('Unable to upload media. Please try again.'); }
});
}
if (uploadModal) {
uploadModal.addEventListener('hide.bs.modal', function (event) { if (uploadInProgress) event.preventDefault(); });
}
function getGalleryFilters() {
const params = new URLSearchParams();
params.set('search', String(search && search.value || '').trim());
+29
View File
@@ -0,0 +1,29 @@
(function (root, factory) {
var api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.pulseImageCrop = api;
}(typeof window !== 'undefined' ? window : null, function () {
function dimension(value, fallback) {
var number = Number(value);
return Number.isFinite(number) && number > 0 ? number : fallback;
}
function outputSize(crop, region, canvas) {
var width = dimension(crop && crop.width, 1);
var height = dimension(crop && crop.height, 1);
var maximumWidth = Math.min(dimension(region && region.width, width), dimension(canvas && canvas.width, width));
var maximumHeight = Math.min(dimension(region && region.height, height), dimension(canvas && canvas.height, height));
var scale = Math.min(1, maximumWidth / width, maximumHeight / height);
return { width: Math.max(1, Math.floor(width * scale)), height: Math.max(1, Math.floor(height * scale)) };
}
function isFullImage(crop, image) {
if (!crop || !image || !(image.naturalWidth > 0) || !(image.naturalHeight > 0)) return false;
return Math.abs(Number(crop.x) || 0) < 0.01 && Math.abs(Number(crop.y) || 0) < 0.01 &&
Math.abs(Number(crop.width) - image.naturalWidth) < 0.01 && Math.abs(Number(crop.height) - image.naturalHeight) < 0.01 &&
Math.abs((Number(crop.rotate) || 0) % 360) < 0.01 &&
(crop.scaleX === undefined || crop.scaleX === 1) && (crop.scaleY === undefined || crop.scaleY === 1);
}
return { outputSize: outputSize, isFullImage: isFullImage };
}));
+257 -554
View File
@@ -1,581 +1,284 @@
// Slide image cropper modal wiring and file replacement helpers.
(function () {
var templateFields = document.getElementById('template-fields');
var modal = document.getElementById('slide-image-cropper-modal');
var image = document.getElementById('slide-image-cropper-image');
var status = document.getElementById('slide-image-cropper-status');
var modal;
var cropper;
var active;
var busy = false;
var loader;
var draftCopies = new Map();
if (!templateFields || !modal || !image || !status) {
return;
function cleanupCopies(paths) {
var unique = Array.from(new Set(paths));
if (!unique.length) return;
fetch('/slides/uploads/cleanup', {
method: 'POST', credentials: 'same-origin', keepalive: true,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ uploadPaths: unique })
}).catch(function () {});
}
var cropper = null;
var currentInput = null;
var currentFile = null;
var currentObjectUrl = '';
var cropperFrame = modal.querySelector('.slide-image-cropper-frame');
var flipX = 1;
var flipY = 1;
var currentAspectRatio = NaN;
var currentRegionAspectRatio = NaN;
var currentRegionAspectRatioLabel = 'Region';
var listenersAttached = false;
var uploadMaxBytes = 100 * 1024 * 1024;
var uploadMaxLabel = '100 MB';
var editorDataElement = document.getElementById('slide-editor-data');
try {
var editorData = JSON.parse(editorDataElement && (editorDataElement.value || editorDataElement.textContent) || '{}') || {};
uploadMaxBytes = Number(editorData.uploadLimits && editorData.uploadLimits.imageMaxBytes) || uploadMaxBytes;
var megabytes = uploadMaxBytes / 1024 / 1024;
uploadMaxLabel = Number.isInteger(megabytes) ? String(megabytes) + ' MB' : megabytes.toFixed(2).replace(/0+$/, '').replace(/\.$/, '') + ' MB';
} catch (_error) {
function trackCopy(input, path) {
var previous = draftCopies.get(input) || [];
draftCopies.set(input, [path]);
cleanupCopies(previous.filter(function (value) { return value !== path; }));
}
function getRegionId(input) {
var match = String(input && input.name || '').match(/^region_image_(\d+)$/);
return match ? match[1] : '';
}
function getExistingInput(regionId) {
if (!regionId) {
return null;
}
return document.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
}
function setStatus(message) {
status.textContent = String(message || '');
}
function setCropperLoading(loading) {
if (cropperFrame) {
cropperFrame.classList.toggle('is-loading', Boolean(loading));
}
}
function showModal() {
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(modal);
return;
}
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(modal).show();
}
}
function hideModal() {
if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
window.pulseModal.hide(modal);
return;
}
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(modal).hide();
}
}
function destroyCropper() {
if (cropper) {
cropper.destroy();
cropper = null;
}
}
function revokeObjectUrl() {
if (currentObjectUrl) {
URL.revokeObjectURL(currentObjectUrl);
currentObjectUrl = '';
}
}
function resetModalState() {
destroyCropper();
revokeObjectUrl();
setCropperLoading(false);
currentInput = null;
currentFile = null;
flipX = 1;
flipY = 1;
image.removeAttribute('src');
image.alt = 'Selected image to crop';
setStatus('');
}
function setButtonState(disabled) {
modal.querySelectorAll('[data-slide-image-cropper-action]').forEach(function (button) {
button.disabled = Boolean(disabled) && button.getAttribute('data-slide-image-cropper-action') !== 'reset';
});
}
function ratioLabelForValue(value) {
if (value === 'free') {
return 'free';
}
if (value === 'region') {
return 'region';
}
return String(value || '').trim();
}
function parseAspectRatio(value) {
var parts = String(value || '').trim().split(/[:/]/);
var width = Number(parts[0]);
var height = Number(parts[1]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return NaN;
}
return width / height;
}
function isSvgFile(file) {
if (!file) {
return false;
}
return String(file.type || '').toLowerCase() === 'image/svg+xml' || /\.svg$/i.test(String(file.name || ''));
}
function isGifFile(file) {
if (!file) {
return false;
}
return String(file.type || '').toLowerCase() === 'image/gif' || /\.gif$/i.test(String(file.name || ''));
}
function isSpecialRasterizationFile(file) {
return isSvgFile(file) || isGifFile(file);
}
function getSpecialFileWarning(file) {
if (isGifFile(file)) {
return 'GIF selected. If you crop, rotate, or flip this image, it will be rasterized and the animation will be lost. Leave the full image selected to keep the original GIF.';
}
return 'SVG selected. If you crop, rotate, or flip this image, it will be rasterized. Leave the full image selected to keep the original SVG.';
}
function isOriginalSpecialSelection() {
if (!cropper || !currentFile || !isSpecialRasterizationFile(currentFile) || typeof cropper.getData !== 'function' || typeof cropper.getImageData !== 'function') {
return false;
}
var cropData = cropper.getData(true) || {};
var imageData = cropper.getImageData() || {};
var width = Number(imageData.naturalWidth || 0);
var height = Number(imageData.naturalHeight || 0);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return false;
}
return Math.abs(Number(cropData.x || 0)) < 0.5 &&
Math.abs(Number(cropData.y || 0)) < 0.5 &&
Math.abs(Number(cropData.width || 0) - width) < 0.5 &&
Math.abs(Number(cropData.height || 0) - height) < 0.5 &&
Math.abs(Number(cropData.rotate || 0)) < 0.5 &&
Number(cropData.scaleX || 1) === 1 &&
Number(cropData.scaleY || 1) === 1;
}
function getRasterizedFileName(sourceName) {
var name = String(sourceName || '').trim();
if (!name) {
return 'slide-region-image.png';
}
if (/\.svg$/i.test(name)) {
return name.replace(/\.svg$/i, '.png');
}
if (/\.[a-z0-9]+$/i.test(name)) {
return name.replace(/\.[a-z0-9]+$/i, '.png');
}
return name + '.png';
}
function setActiveRatioButton(value) {
var targetValue = ratioLabelForValue(value);
modal.querySelectorAll('[data-slide-image-cropper-action="ratio"]').forEach(function (button) {
var buttonValue = ratioLabelForValue(button.getAttribute('data-slide-image-cropper-ratio'));
var isActive = buttonValue === targetValue;
button.classList.toggle('active', isActive);
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
});
}
function applyAspectRatio(value) {
if (!cropper) {
return;
}
currentAspectRatio = value;
if (value === 'free') {
cropper.setAspectRatio(NaN);
setActiveRatioButton('free');
return;
}
if (value === 'region') {
if (!Number.isFinite(currentRegionAspectRatio) || currentRegionAspectRatio <= 0) {
cropper.setAspectRatio(NaN);
setActiveRatioButton('free');
return;
}
cropper.setAspectRatio(currentRegionAspectRatio);
setActiveRatioButton('region');
return;
}
var parts = String(value || '').split(':');
var width = Number(parts[0]);
var height = Number(parts[1]);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return;
}
cropper.setAspectRatio(width / height);
setActiveRatioButton(value);
}
function initCropper() {
if (!currentInput || !currentFile || !window.Cropper) {
return;
}
destroyCropper();
cropper = new window.Cropper(image, {
aspectRatio: NaN,
autoCropArea: 1,
background: false,
dragMode: 'move',
initialAspectRatio: NaN,
movable: true,
responsive: true,
rotatable: true,
scalable: true,
viewMode: 1,
zoomOnTouch: true,
zoomOnWheel: true,
ready: function () {
setCropperLoading(false);
if (cropper && cropper.container) {
cropper.container.style.width = '100%';
cropper.container.style.height = '560px';
cropper.container.style.maxHeight = '70vh';
}
var containerData = cropper.getContainerData();
var imageData = cropper.getImageData();
var fitRatio = Math.min(
Number(containerData.width || 0) / Number(imageData.naturalWidth || 1),
Number(containerData.height || 0) / Number(imageData.naturalHeight || 1),
1
);
if (Number.isFinite(fitRatio) && fitRatio > 0) {
cropper.zoomTo(fitRatio);
} else {
cropper.reset();
}
applyAspectRatio(currentAspectRatio === undefined ? 'free' : currentAspectRatio);
}
});
setButtonState(false);
setActiveRatioButton(currentAspectRatio === undefined ? 'free' : currentAspectRatio);
if (isSpecialRasterizationFile(currentFile)) {
setStatus(getSpecialFileWarning(currentFile));
}
}
function openEditor(input, file) {
currentInput = input;
currentFile = file;
flipX = 1;
flipY = 1;
currentAspectRatio = 'free';
currentRegionAspectRatio = parseAspectRatio(input && input.dataset && input.dataset.slideImageCropperRegionRatio);
currentRegionAspectRatioLabel = String(input && input.dataset && input.dataset.slideImageCropperRegionRatioLabel || 'Region').trim() || 'Region';
setCropperLoading(true);
setButtonState(true);
setStatus(isSpecialRasterizationFile(file) ? getSpecialFileWarning(file) : '');
modal.querySelectorAll('[data-slide-image-cropper-action="ratio"][data-slide-image-cropper-ratio="region"]').forEach(function (button) {
button.title = currentRegionAspectRatioLabel ? 'Region ratio ' + currentRegionAspectRatioLabel : 'Region ratio';
});
revokeObjectUrl();
currentObjectUrl = URL.createObjectURL(file);
image.alt = file.name || 'Selected image to crop';
image.src = currentObjectUrl;
if (!listenersAttached) {
listenersAttached = true;
modal.addEventListener('shown.bs.modal', function () {
if (currentInput && currentFile) {
initCropper();
}
});
modal.addEventListener('hidden.bs.modal', function () {
resetModalState();
});
}
showModal();
}
function setInputFile(input, file) {
var dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
input.files = dataTransfer.files;
}
function rejectOversizeFile(input) {
if (!input) {
return;
}
if (typeof window.showToast === 'function') {
window.showToast('File must be ' + uploadMaxLabel + ' or smaller.', 'warning');
} else {
window.alert('File must be ' + uploadMaxLabel + ' or smaller.');
}
input.value = '';
}
function finalizeCropFile(file) {
if (!currentInput) {
return;
}
var input = currentInput;
var existing = getExistingInput(getRegionId(input));
input.dataset.cropperBypass = '1';
setInputFile(input, file);
if (existing) {
existing.value = '';
}
input.dispatchEvent(new Event('change', { bubbles: true }));
window.setTimeout(function () {
delete input.dataset.cropperBypass;
}, 0);
hideModal();
}
function finalizeCurrentSvgSelection() {
if (!currentFile) {
return;
}
if (isOriginalSpecialSelection()) {
finalizeCropFile(currentFile);
return;
}
setButtonState(true);
setStatus('Creating a rasterized image from the crop...');
var canvas = cropper && typeof cropper.getCroppedCanvas === 'function' ? cropper.getCroppedCanvas({
fillColor: 'transparent',
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high'
}) : null;
if (!canvas) {
setStatus('Unable to create the cropped image.');
setButtonState(false);
return;
}
canvas.toBlob(function (blob) {
if (!blob) {
setStatus('Unable to create the cropped image.');
setButtonState(false);
return;
}
var finalFile = new File([blob], getRasterizedFileName(currentFile.name), {
lastModified: Date.now(),
type: blob.type || 'image/png'
});
finalizeCropFile(finalFile);
}, 'image/png');
}
function commitCrop() {
if (!currentInput || !currentFile) {
return;
}
if (isSpecialRasterizationFile(currentFile)) {
if (!cropper) {
finalizeCropFile(currentFile);
return;
}
finalizeCurrentSvgSelection();
return;
}
if (!cropper) {
finalizeCropFile(currentFile);
return;
}
setButtonState(true);
setStatus('Creating the cropped image...');
var canvas = cropper.getCroppedCanvas({
fillColor: 'transparent',
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high'
});
if (!canvas) {
setStatus('Unable to create the cropped image.');
setButtonState(false);
return;
}
var outputType = /^image\/(jpeg|jpg|webp|png)$/i.test(currentFile.type || '') ? currentFile.type : 'image/png';
canvas.toBlob(function (blob) {
if (!blob) {
setStatus('Unable to create the cropped image.');
setButtonState(false);
return;
}
var finalFile = new File([blob], currentFile.name || 'slide-region-image.png', {
lastModified: Date.now(),
type: blob.type || outputType
});
finalizeCropFile(finalFile);
}, outputType, outputType.indexOf('jpeg') !== -1 ? 0.92 : undefined);
}
function rotateImage(direction) {
if (cropper) {
cropper.rotate(direction);
}
}
function flipImage(axis) {
if (!cropper) {
return;
}
if (axis === 'x') {
flipX *= -1;
cropper.scaleX(flipX);
return;
}
flipY *= -1;
cropper.scaleY(flipY);
}
function resetImage() {
if (!cropper) {
return;
}
cropper.reset();
flipX = 1;
flipY = 1;
}
templateFields.addEventListener('change', function (event) {
document.addEventListener('change', function (event) {
var input = event.target;
if (!input || !input.matches || !input.matches('input[type="file"][name^="region_image_"]')) {
return;
}
if (!input.name || input.name.indexOf('existing_region_image_') !== 0 || input.dataset.applyingCrop === '1') return;
var copies = draftCopies.get(input) || [];
cleanupCopies(copies.filter(function (path) { return path !== input.value; }));
if (copies.indexOf(input.value) !== -1) draftCopies.set(input, [input.value]);
else draftCopies.delete(input);
});
if (input.dataset.cropperBypass === '1') {
return;
}
window.addEventListener('pagehide', function () {
var paths = [];
draftCopies.forEach(function (copies) { paths = paths.concat(copies); });
cleanupCopies(paths);
});
var file = input.files && input.files[0];
if (!file) {
return;
function discardInitialSelection() {
if (!active || !active.initialSelection || active.applied || active.current.value !== active.previousValue) return;
cleanupCopies([active.original]);
if (active.fileInput && active.fileInput.dataset.uploadedPath === active.original) {
active.fileInput.value = '';
delete active.fileInput.dataset.uploadedPath;
delete active.fileInput.dataset.uploadedNeedsCleanup;
}
active.originalInput.value = '';
active.cropInput.value = '';
active.current.value = '';
delete active.current.dataset.cropSelectionPending;
active.current.dispatchEvent(new Event('change', { bubbles: true }));
var preview = active.card.querySelector('.slide-image-region-preview-box');
if (preview) preview.innerHTML = '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>';
}
if (Number(file.size || 0) > uploadMaxBytes) {
event.stopImmediatePropagation();
event.stopPropagation();
rejectOversizeFile(input);
return;
}
function loadCropper() {
if (window.Cropper) return Promise.resolve();
if (!loader) loader = new Promise(function (resolve, reject) {
var css = document.createElement('link');
css.rel = 'stylesheet';
css.href = '/assets/vendor/cropperjs/cropper.min.css';
document.head.appendChild(css);
var script = document.createElement('script');
script.src = '/assets/vendor/cropperjs/cropper.min.js';
script.onload = resolve;
script.onerror = function () { loader = null; reject(new Error('Unable to load the image editor.')); };
document.head.appendChild(script);
});
return loader;
}
if (!/^image\//i.test(file.type || '') && !/\.svg$/i.test(String(file.name || ''))) {
input.value = '';
return;
}
function status(message) {
modal.querySelector('[data-crop-status]').textContent = message || '';
}
event.stopImmediatePropagation();
event.stopPropagation();
input.value = '';
function setBusy(value) {
busy = value;
modal.querySelectorAll('button').forEach(function (button) { button.disabled = value; });
}
if (!window.Cropper) {
currentInput = input;
currentFile = file;
commitCrop();
return;
}
function setLoading(value) {
var frame = modal.querySelector('.slide-image-cropper-frame');
frame.classList.toggle('is-loading', value);
frame.setAttribute('aria-busy', value ? 'true' : 'false');
frame.querySelector('.slide-image-cropper-loading-overlay').setAttribute('aria-hidden', value ? 'false' : 'true');
}
openEditor(input, file);
}, true);
function createModal() {
if (modal) return;
modal = document.createElement('div');
modal.id = 'slide-image-cropper-modal';
modal.className = 'modal fade';
modal.tabIndex = -1;
modal.setAttribute('aria-labelledby', 'slide-image-cropper-title');
modal.setAttribute('data-bs-backdrop', 'static');
modal.setAttribute('data-bs-keyboard', 'false');
var tools = [
{ label: 'Rotate', buttons: [['rotate-left', 'arrow-counterclockwise', 'Rotate left'], ['rotate-right', 'arrow-clockwise', 'Rotate right']] },
{ label: 'Flip', buttons: [['flip-x', 'symmetry-vertical', 'Flip horizontal'], ['flip-y', 'symmetry-horizontal', 'Flip vertical']] }
].map(function (group) {
return '<div class="btn-group btn-group-sm" role="group" aria-label="' + group.label + '">' + group.buttons.map(function (tool) {
return '<button type="button" class="btn btn-outline-secondary" data-crop-action="' + tool[0] + '"><i class="bi bi-' + tool[1] + '" aria-hidden="true"></i> ' + tool[2] + '</button>';
}).join('') + '</div>';
}).join('');
var ratios = ['Free', '1:1', '4:3', '16:9', 'Region'].map(function (ratio) {
return '<button type="button" class="btn btn-outline-secondary btn-sm" data-crop-ratio="' + ratio + '" aria-pressed="false">' + ratio + '</button>';
}).join('');
modal.innerHTML = '<div class="modal-dialog modal-dialog-centered modal-xl modal-dialog-scrollable"><div class="modal-content">' +
'<div class="modal-header"><h2 class="modal-title fs-5" id="slide-image-cropper-title">Edit image</h2><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 is-loading" aria-busy="true"><img data-crop-image alt="Original image to crop"><div class="slide-image-cropper-loading-overlay" aria-hidden="false"><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="d-flex flex-wrap gap-2" role="toolbar" aria-label="Image editing controls">' + tools + '<div class="btn-group btn-group-sm" role="group" aria-label="Crop ratio">' + ratios + '</div><button type="button" class="btn btn-outline-danger btn-sm" data-crop-action="reset"><i class="bi bi-arrow-repeat" aria-hidden="true"></i> Reset</button></div>' +
'<p class="small text-body-secondary mb-0" data-crop-status role="status" aria-live="polite"></p></div>' +
'<div class="modal-footer"><button type="button" class="btn btn-primary" data-crop-apply>Apply crop</button><button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel</button></div></div></div>';
document.body.appendChild(modal);
modal.addEventListener('shown.bs.modal', function () {
if (!active) return;
cropper = new window.Cropper(modal.querySelector('[data-crop-image]'), {
viewMode: 1, autoCropArea: 1, background: false, responsive: true, dragMode: 'move',
ready: function () {
if (active.crop) {
cropper.rotateTo(active.crop.rotate || 0);
cropper.scale(active.crop.scaleX || 1, active.crop.scaleY || 1);
cropper.setCropBoxData({ width: 1, height: 1 });
var container = cropper.getContainerData();
var canvas = cropper.getCanvasData();
cropper.zoomTo(Math.min(container.width / canvas.naturalWidth, container.height / canvas.naturalHeight));
cropper.setData(active.crop);
}
setLoading(false);
setBusy(false);
if (/\.(?:svg|gif)(?:[?#]|$)/i.test(active.original)) status('Applying a crop creates a PNG copy. The original stays unchanged.');
else status('');
}
});
});
modal.addEventListener('hide.bs.modal', function (event) {
if (busy) event.preventDefault();
else modal.dataset.cropClosing = '1';
});
modal.addEventListener('hidden.bs.modal', function () {
discardInitialSelection();
if (cropper) cropper.destroy();
cropper = null;
active = null;
setLoading(false);
modal.querySelector('[data-crop-image]').removeAttribute('src');
delete modal.dataset.cropClosing;
});
modal.querySelector('[data-crop-image]').addEventListener('error', function () {
setLoading(false);
setBusy(false);
modal.querySelector('[data-crop-apply]').disabled = true;
status('Unable to load the original image.');
});
modal.addEventListener('click', handleAction);
}
modal.addEventListener('click', function (event) {
var button = event.target && event.target.closest ? event.target.closest('[data-slide-image-cropper-action]') : null;
if (!button) {
return;
}
function setSelection(value, crop) {
active.applied = true;
delete active.current.dataset.cropSelectionPending;
var copies = draftCopies.get(active.current) || [];
cleanupCopies(copies.filter(function (path) { return path !== value; }));
if (copies.indexOf(value) === -1) draftCopies.delete(active.current);
active.originalInput.value = active.original;
active.cropInput.value = crop ? JSON.stringify(crop) : '';
active.current.dataset.applyingCrop = '1';
active.current.value = value;
active.current.dispatchEvent(new Event('change', { bubbles: true }));
delete active.current.dataset.applyingCrop;
var preview = active.card.querySelector('.slide-image-region-preview');
if (preview && preview.tagName === 'IMG') preview.src = value;
window.pulseModal.hide(modal);
}
var action = button.getAttribute('data-slide-image-cropper-action');
if (action === 'apply') {
commitCrop();
async function applyCrop() {
if (!active || !cropper || busy) return;
var selection = active;
var crop = cropper.getData();
if (window.pulseImageCrop.isFullImage(crop, cropper.getImageData())) {
setSelection(selection.original, null);
return;
}
if (action === 'ratio') {
applyAspectRatio(button.getAttribute('data-slide-image-cropper-ratio') || 'free');
return;
setBusy(true);
status('Saving cropped copy...');
try {
var size = window.pulseImageCrop.outputSize(crop, selection.bounds, selection.canvas);
var canvas = cropper.getCroppedCanvas({ width: size.width, height: size.height, maxWidth: size.width, maxHeight: size.height, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' });
if (!canvas || !canvas.width || !canvas.height) throw new Error('Unable to create the cropped image.');
var outputType = /\.jpe?g(?:[?#]|$)/i.test(selection.original) ? 'image/jpeg' : /\.webp(?:[?#]|$)/i.test(selection.original) ? 'image/webp' : 'image/png';
var extension = outputType === 'image/jpeg' ? '.jpg' : outputType === 'image/webp' ? '.webp' : '.png';
var blob = await new Promise(function (resolve) { canvas.toBlob(resolve, outputType, 0.92); });
if (!blob) throw new Error('Unable to create the cropped image.');
var form = new FormData();
form.append('file', blob, 'slide-image-crop' + extension);
var response = await fetch('/slides/uploads', { method: 'POST', credentials: 'same-origin', headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, body: form });
var payload = await response.json();
if (!response.ok || !payload.path) throw new Error(payload.error || 'Unable to save the cropped copy.');
if (!selection.card.isConnected || selection.current.value !== selection.previousValue) {
cleanupCopies([payload.path]);
throw new Error('The selected image changed. Open the crop editor again.');
}
trackCopy(selection.current, payload.path);
setBusy(false);
setSelection(payload.path, crop);
} catch (error) {
setBusy(false);
status(error.message || 'Unable to save the cropped copy.');
}
if (action === 'rotate-left') {
rotateImage(-90);
return;
}
if (action === 'rotate-right') {
rotateImage(90);
return;
}
if (action === 'flip-horizontal') {
flipImage('x');
return;
}
if (action === 'flip-vertical') {
flipImage('y');
}
function handleAction(event) {
if (busy || !active) return;
if (event.target.closest('[data-crop-apply]')) { applyCrop(); return; }
if (!cropper) return;
var ratio = event.target.closest('[data-crop-ratio]');
if (ratio) {
var label = ratio.dataset.cropRatio;
var parts = String(label === 'Region' ? active.ratio : label).split(':');
var aspect = Number(parts[0]) / Number(parts[1]);
cropper.setAspectRatio(Number.isFinite(aspect) && aspect > 0 ? aspect : NaN);
modal.querySelectorAll('[data-crop-ratio]').forEach(function (button) {
button.classList.toggle('active', button === ratio);
button.setAttribute('aria-pressed', button === ratio ? 'true' : 'false');
});
return;
}
var button = event.target.closest('[data-crop-action]');
if (!button) return;
var action = button.dataset.cropAction;
if (action === 'rotate-left') cropper.rotate(-90);
if (action === 'rotate-right') cropper.rotate(90);
if (action === 'flip-x') cropper.scaleX(-(cropper.getData().scaleX || 1));
if (action === 'flip-y') cropper.scaleY(-(cropper.getData().scaleY || 1));
if (action === 'reset') {
resetImage();
cropper.reset();
modal.querySelector('[data-crop-ratio="Free"]').click();
}
}
document.addEventListener('click', async function (event) {
var button = event.target.closest && event.target.closest('[data-edit-image-crop]');
if (!button || busy) return;
if (modal && modal.dataset.cropClosing === '1') {
modal.addEventListener('hidden.bs.modal', function () { if (button.isConnected) button.click(); }, { once: true });
return;
}
var card = button.closest('[data-region-id]');
var regionId = button.dataset.editImageCrop;
var current = card.querySelector('[name="existing_region_image_' + regionId + '"]');
if (!current || !current.value) {
if (window.showToast) window.showToast('Choose an image first.', 'warning');
return;
}
button.disabled = true;
try {
await loadCropper();
createModal();
var originalInput = card.querySelector('[name="region_image_original_' + regionId + '"]');
var cropInput = card.querySelector('[name="region_image_crop_' + regionId + '"]');
var crop = null;
try { crop = JSON.parse(cropInput.value || 'null'); } catch (_error) {}
var previewRegion = Array.prototype.find.call(document.querySelectorAll('.slide-preview-region'), function (region) { return region.dataset.regionKey === button.dataset.regionKey; });
var previewCanvas = document.getElementById('slide-preview-canvas');
var bounds = { width: previewRegion ? parseFloat(previewRegion.style.width) : Number(button.dataset.regionWidth), height: previewRegion ? parseFloat(previewRegion.style.height) : Number(button.dataset.regionHeight) };
var canvas = { width: previewCanvas ? parseFloat(previewCanvas.style.width) : bounds.width, height: previewCanvas ? parseFloat(previewCanvas.style.height) : bounds.height };
active = { card: card, current: current, originalInput: originalInput, cropInput: cropInput, original: originalInput.value || current.value, crop: crop, bounds: bounds, canvas: canvas, ratio: bounds.width + ':' + bounds.height, previousValue: current.value, initialSelection: current.dataset.cropSelectionPending === '1', applied: false, fileInput: card.querySelector('input[type="file"][name="region_image_' + regionId + '"]') };
modal.querySelectorAll('[data-crop-ratio]').forEach(function (ratio) {
var free = ratio.dataset.cropRatio === 'Free';
ratio.classList.toggle('active', free);
ratio.setAttribute('aria-pressed', free ? 'true' : 'false');
});
setLoading(true);
modal.querySelector('[data-crop-image]').src = active.original;
status('Loading image...');
setBusy(true);
window.pulseModal.show(modal);
} catch (error) {
if (window.showToast) window.showToast(error.message, 'danger');
} finally {
button.disabled = false;
}
});
}());
+62 -5
View File
@@ -12,6 +12,7 @@ const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const { buildTemplateLayoutUpdate, buildTemplateAnimationUpdate } = require('#src/data/templates');
const { retainLayoutAnimation, getTemplateAnimationSnapshot } = require('#src/web/public/js/shared/region-layout');
const { fetchMediaAssets, createImageVariants, registerMediaAssets, syncMediaAssetsFromDirectory, publishMediaAssets, removePendingMediaAssets } = require('#src/web/lib/media/library');
const { prepareImageCopies } = require('#src/web/lib/media/image-crops');
module.exports = function registerContentRoutes(app, deps) {
const pool = deps.pool;
@@ -76,6 +77,53 @@ module.exports = function registerContentRoutes(app, deps) {
}, {});
}
async function prepareContentImageCopies(database, template, content, actorId) {
const previous = JSON.stringify(content);
const files = await prepareImageCopies(content, template, deps.uploadDir);
await registerMediaAssets(database, files, file => '/media/uploads/' + file.filename, () => 'image', actorId, { published: false, preservePublication: true, preserveOriginalName: true });
return JSON.stringify(content) !== previous;
}
async function prepareSlideImagePayload(payload, actorId) {
if (!deps.uploadDir) return;
const content = common.parseJsonSafe(payload.contentJson) || {};
if (!Object.values(content).some(image => image && image.value && (image.type === 'image' || image.original_value))) return;
const template = await common.fetchTemplateById(pool, payload.templateId);
if (template && await prepareContentImageCopies(pool, template, content, actorId)) payload.contentJson = JSON.stringify(content);
}
async function refreshTemplateImageCopies(database, templateId, actorId) {
const references = { previous: new Set(), next: new Set() };
if (!deps.uploadDir) return references;
const template = await common.fetchTemplateById(database, templateId);
const defaults = JSON.parse(JSON.stringify(mapTemplateDefaultContent(template.regions)));
const previousDefaults = JSON.stringify(defaults);
await prepareContentImageCopies(database, template, defaults, actorId);
if (JSON.stringify(defaults) !== previousDefaults) {
for (const region of template.regions) {
const next = defaults[region.region_key];
if (JSON.stringify(next) === JSON.stringify(region.default_content || {})) continue;
await database.query('UPDATE c_template_regions SET default_content_json = ? WHERE id = ? AND template_id = ?', [JSON.stringify(next), region.id, templateId]);
region.default_content = next;
}
await replaceContentReferences(database, 'template', templateId, defaults);
for (const value of collectUploadReferencesFromPayload({ contentJson: previousDefaults })) references.previous.add(value);
for (const value of collectUploadReferencesFromPayload({ contentJson: JSON.stringify(defaults) })) references.next.add(value);
}
const [slides] = await database.query('SELECT id, content_json FROM c_slides WHERE template_id = ? FOR UPDATE', [templateId]);
for (const slide of slides) {
const content = typeof slide.content_json === 'string' ? common.parseJsonSafe(slide.content_json) || {} : slide.content_json || {};
const previous = JSON.stringify(content);
await prepareContentImageCopies(database, template, content, actorId);
if (JSON.stringify(content) === previous) continue;
await database.query('UPDATE c_slides SET content_json = ?, modified_at = CURRENT_TIMESTAMP, modified_by = ? WHERE id = ? AND template_id = ?', [JSON.stringify(content), actorId, slide.id, templateId]);
await replaceContentReferences(database, 'slide', slide.id, content);
for (const value of collectUploadReferencesFromPayload({ contentJson: previous })) references.previous.add(value);
for (const value of collectUploadReferencesFromPayload({ contentJson: JSON.stringify(content) })) references.next.add(value);
}
return references;
}
async function fetchSlideFormData(includeTemplates) {
const data = includeTemplates === false ? {} : await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
@@ -580,6 +628,7 @@ module.exports = function registerContentRoutes(app, deps) {
return '/media/uploads/' + file.filename;
}, getUploadedFileMediaType, getAuditUserId(req));
const payload = await common.buildSlidePayload(pool, req, null);
await prepareSlideImagePayload(payload, getAuditUserId(req));
if (await common.fetchDuplicateName(pool, 'c_slides', payload.title, null, 'title')) {
return res.status(400).send('A slide with that title already exists.');
}
@@ -639,6 +688,7 @@ module.exports = function registerContentRoutes(app, deps) {
const screenSlideCounts = await fetchScreenSlideCountsBySlug(affectedScreens);
const existingUploadRefs = collectUploadReferencesFromSlide(slide);
const payload = await common.buildSlidePayload(pool, req, slide);
await prepareSlideImagePayload(payload, getAuditUserId(req));
if (await common.fetchDuplicateName(pool, 'c_slides', payload.title, slide.id, 'title')) {
return res.status(400).send('A slide with that title already exists.');
}
@@ -803,16 +853,17 @@ module.exports = function registerContentRoutes(app, deps) {
);
}
await replaceContentReferences(connection, 'template', result.insertId, mapTemplateDefaultContent(payload.regions));
const imageReferences = await refreshTemplateImageCopies(connection, result.insertId, actorId);
await connection.commit();
transaction = false;
connection.release();
connection = null;
await publishMediaAssets(pool, collectUploadReferencesFromPayload(payload));
await publishMediaAssets(pool, Array.from(new Set([...collectUploadReferencesFromPayload(payload), ...imageReferences.next])));
await syncPlaylistUploadsOnChange({
key: 'template:create:' + result.insertId,
pool: pool,
localUploadDir: deps.uploadDir,
nextUploadRefs: collectUploadReferencesFromPayload(payload)
nextUploadRefs: new Set([...collectUploadReferencesFromPayload(payload), ...imageReferences.next])
});
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'templates', eventType: 'template.created', actorUserId: actorId, targetType: 'template', targetId: result.insertId, targetLabel: payload.name });
redirectAfterSave(req, res, '/templates/' + result.insertId + '/edit', {
@@ -904,10 +955,15 @@ module.exports = function registerContentRoutes(app, deps) {
);
}
await connection.query('UPDATE c_templates SET modified_at = CURRENT_TIMESTAMP, modified_by = ? WHERE id = ?', [actorId, templateId]);
const imageReferences = await refreshTemplateImageCopies(connection, templateId, actorId);
await connection.commit();
transaction = false;
connection.release();
connection = null;
if (imageReferences.next.size) {
await publishMediaAssets(pool, Array.from(imageReferences.next));
await syncPlaylistUploadsOnChange({ key: 'template:layout:' + templateId, pool: pool, localUploadDir: deps.uploadDir, previousUploadRefs: imageReferences.previous, nextUploadRefs: imageReferences.next });
}
const slideCount = await fetchTemplateSlideCount(templateId);
if (slideCount > 0) {
queueTemplateSlideThumbnailRefresh(templateId).catch(function (error) {
@@ -1079,17 +1135,18 @@ module.exports = function registerContentRoutes(app, deps) {
);
}
await replaceContentReferences(connection, 'template', template.id, mapTemplateDefaultContent(payload.regions));
const imageReferences = await refreshTemplateImageCopies(connection, template.id, actorId);
await connection.commit();
transaction = false;
connection.release();
connection = null;
await publishMediaAssets(pool, Array.from(new Set(Array.from(existingUploadRefs).concat(Array.from(nextUploadRefs)))));
await publishMediaAssets(pool, Array.from(new Set([...existingUploadRefs, ...nextUploadRefs, ...imageReferences.next])));
await syncPlaylistUploadsOnChange({
key: 'template:update:' + template.id,
pool: pool,
localUploadDir: deps.uploadDir,
previousUploadRefs: existingUploadRefs,
nextUploadRefs: nextUploadRefs
previousUploadRefs: new Set([...existingUploadRefs, ...imageReferences.previous]),
nextUploadRefs: new Set([...nextUploadRefs, ...imageReferences.next])
});
const templateSlides = await fetchSlidesByTemplateId(template.id);
if (templateSlides.length > 0) {
+8 -3
View File
@@ -182,9 +182,13 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
});
app.post('/media-library', requireAccess, deps.requirePermission('media-library.create'), deps.upload.single('file'), async function (req, res, next) {
const wantsJson = String(req.headers && req.headers.accept || '').includes('application/json');
function rejectUpload(message) {
return wantsJson ? res.status(400).json({ error: message }) : res.redirect('/media-library?message=' + encodeURIComponent(message));
}
try {
if (!req.file) {
return res.redirect('/media-library?message=' + encodeURIComponent('Choose an image or video to upload.'));
return rejectUpload('Choose an image or video to upload.');
}
const mediaType = getMediaType(req.file);
const settings = await fetchAppSettings(deps.pool);
@@ -194,14 +198,14 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
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.'));
return rejectUpload('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.'));
return rejectUpload('File must be ' + limitLabel + ' or smaller.');
}
await mediaLibrary.createImageVariants(req.file);
const mediaPath = '/media/uploads/' + req.file.filename;
@@ -217,6 +221,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
details: { mediaPath: mediaPath, mediaType: mediaType, mimeType: mimeType, fileSize: Number(req.file.size) || 0 }
});
}
if (wantsJson) return res.json({ success: true, path: mediaPath });
res.redirect('/media-library?message=' + encodeURIComponent('Media uploaded.'));
} catch (error) {
if (req.file && req.file.path) {
+6 -46
View File
@@ -5,8 +5,6 @@
</div>
</div>
<link rel="stylesheet" href="/assets/vendor/cropperjs/cropper.min.css" />
<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">
{{#if (hasPermission currentUser 'media-library.create')}}
@@ -126,55 +124,17 @@
<span class="slide-image-region-upload-zone-limit">Images max {{uploadLimits.imageMaxLabel}}; videos max {{uploadLimits.videoMaxLabel}}</span>
</span>
</span>
<span class="slide-image-region-upload-zone-progress" data-media-library-upload-progress hidden>
<span class="slide-image-region-upload-zone-progress-label" data-media-library-upload-progress-text role="status" aria-live="polite">Uploading...</span>
<span class="progress rounded-pill slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Media upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" data-media-library-upload-progress-bar>
<span class="progress-bar bg-primary rounded-pill progress-bar-striped progress-bar-animated" style="width:0%" data-media-library-upload-progress-fill>0%</span>
</span>
</span>
</label>
</div>
</form>
{{/modal-shell}}
{{/if}}
{{#> 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 src="/assets/js/shared/upload-utils.js?v={{appVersion}}"></script>
<script type="module" src="/assets/js/settings/media-library.js?v={{appVersion}}"></script>
-47
View File
@@ -6,7 +6,6 @@
</div>
<div class="slide-editor-shell">
<link rel="stylesheet" href="/assets/vendor/cropperjs/cropper.min.css" />
<form method="post" action="{{action}}" enctype="multipart/form-data" id="slide-form" class="slide-form-stack" {{{formAttrs}}}>
<input type="hidden" id="slide-layouts-json" name="region_layouts_json" value="{}" />
<div class="card card-outline card-primary admin-form-card slide-template-card">
@@ -181,50 +180,6 @@
{{> signage/templates/animation-advanced-modal}}
{{#> modal-shell modalId="slide-image-cropper-modal" modalLabelId="slide-image-cropper-modal-label" modalDialogClass="modal-dialog-centered modal-xl modal-dialog-scrollable" modalBackdropStatic=true modalKeyboardDisabled=true}}
<div class="modal-header">
<div>
<h2 class="modal-title fs-5" id="slide-image-cropper-modal-label">Edit image</h2>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body d-grid gap-3">
<div class="slide-image-cropper-frame">
<img id="slide-image-cropper-image" alt="Selected image to crop" />
<div 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-slide-image-cropper-action="rotate-left"><i class="bi bi-arrow-counterclockwise" aria-hidden="true"></i> Rotate left</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="rotate-right"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i> Rotate right</button>
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Flip">
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="flip-horizontal"><i class="bi bi-symmetry-vertical" aria-hidden="true"></i> Flip horizontal</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="flip-vertical"><i class="bi bi-symmetry-horizontal" aria-hidden="true"></i> Flip vertical</button>
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Crop ratio presets">
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="free">Free</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="1:1">1:1</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="4:3">4:3</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="16:9">16:9</button>
<button type="button" class="btn btn-outline-secondary" data-slide-image-cropper-action="ratio" data-slide-image-cropper-ratio="region">Region</button>
</div>
<button type="button" class="btn btn-outline-danger btn-sm" data-slide-image-cropper-action="reset"><i class="bi bi-arrow-repeat" aria-hidden="true"></i> Reset</button>
</div>
<p class="small text-body-secondary mb-0" id="slide-image-cropper-status"></p>
</div>
<div class="modal-footer justify-content-end">
<div class="btn-group" role="group" aria-label="Crop actions">
<button type="button" class="btn btn-primary" data-slide-image-cropper-action="apply">Apply crop</button>
<button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
{{/modal-shell}}
{{> media-picker-modal mediaAssets=slideEditorData.mediaAssets}}
{{#if isEdit}}
@@ -239,13 +194,11 @@
<script src="/assets/js/shared/region-animation-controls.js?v={{appVersion}}"></script>
<script src="/assets/js/shared/qr-code-svg.js?v={{appVersion}}"></script>
<script src="/assets/js/shared/upload-utils.js?v={{appVersion}}"></script>
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
<script src="/assets/vendor/tinymce/tinymce.min.js"></script>
<script src="/assets/vendor/qr-code-styling/qr-code-styling-loader.js?v={{appVersion}}"></script>
{{#each slideEditorScripts}}
<script type="module" src="/assets/{{this}}?v={{../appVersion}}"></script>
{{/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/shared/ui/media-picker.js?v={{appVersion}}"></script>