Release 2.21.4: media library thumbnails and scrolling
This commit is contained in:
1 parent
1b5a32cb05
commit
b80d4aa9c7
11 files changed
+340
-28
No files matched your search
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## 2.21.4 - 2026-10-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- The media library generates compact image thumbnails and video posters, and automatically loads more assets while scrolling.
|
||||||
|
|
||||||
## 2.21.3 - 2026-10-26
|
## 2.21.3 - 2026-10-26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ FROM node:26.12.1-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache chromium nss freetype harfbuzz ttf-freefont
|
RUN apk add --no-cache chromium ffmpeg nss freetype harfbuzz ttf-freefont
|
||||||
|
|
||||||
COPY build/package.web.json ./package.json
|
COPY build/package.web.json ./package.json
|
||||||
RUN npm install --omit=dev --no-audit --no-fund
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.21.3",
|
"version": "2.21.4",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.21.3",
|
"version": "2.21.4",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.21.3",
|
"version": "2.21.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.21.3",
|
"version": "2.21.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/passkey": "^1.7.13",
|
"@better-auth/passkey": "^1.7.13",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.21.3",
|
"version": "2.21.4",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
const { countContentReferenceOwners } = require('#src/data/content-references');
|
const { countContentReferenceOwners } = require('#src/data/content-references');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
@@ -22,6 +23,8 @@ const MEDIA_MIME_TYPES = {
|
|||||||
|
|
||||||
const OPTIMIZABLE_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
const OPTIMIZABLE_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
||||||
const IMAGE_VARIANT_DIMENSIONS = [2048, 4096, 8192, 16384];
|
const IMAGE_VARIANT_DIMENSIONS = [2048, 4096, 8192, 16384];
|
||||||
|
const IMAGE_THUMBNAIL_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg']);
|
||||||
|
const IMAGE_THUMBNAIL_MAX_DIMENSION = 480;
|
||||||
|
|
||||||
function getImageVariantPath(filePath, dimension) {
|
function getImageVariantPath(filePath, dimension) {
|
||||||
const extension = path.extname(filePath);
|
const extension = path.extname(filePath);
|
||||||
@@ -29,14 +32,167 @@ function getImageVariantPath(filePath, dimension) {
|
|||||||
return path.join(path.dirname(filePath), baseName + '.variant-' + dimension + extension);
|
return path.join(path.dirname(filePath), baseName + '.variant-' + dimension + extension);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getImageThumbnailPath(filePath) {
|
||||||
|
const extension = path.extname(filePath);
|
||||||
|
return path.join(path.dirname(filePath), path.basename(filePath, extension) + '.thumb.webp');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVideoPosterPath(filePath) {
|
||||||
|
const extension = path.extname(filePath);
|
||||||
|
return path.join(path.dirname(filePath), path.basename(filePath, extension) + '.poster.webp');
|
||||||
|
}
|
||||||
|
|
||||||
|
function runFfmpeg(args) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
execFile('ffmpeg', args, { timeout: 12000, maxBuffer: 1024 * 1024, windowsHide: true }, function (error) {
|
||||||
|
if (error) return reject(error);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createVideoThumbnail(file) {
|
||||||
|
const filePath = String(file && file.path || '').trim();
|
||||||
|
const extension = path.extname(filePath).toLowerCase();
|
||||||
|
if (!filePath || !MEDIA_MIME_TYPES[extension] || MEDIA_MIME_TYPES[extension].indexOf('video/') !== 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const posterPath = getVideoPosterPath(filePath);
|
||||||
|
try {
|
||||||
|
await fs.promises.access(posterPath);
|
||||||
|
return '/media/uploads/' + path.basename(posterPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (!error || error.code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalStats = await fs.promises.stat(filePath);
|
||||||
|
const temporaryPath = posterPath + '.partial-' + crypto.randomUUID();
|
||||||
|
try {
|
||||||
|
for (const seekSeconds of ['1', '0']) {
|
||||||
|
await fs.promises.unlink(temporaryPath).catch(function () {});
|
||||||
|
try {
|
||||||
|
await runFfmpeg([
|
||||||
|
'-hide_banner', '-loglevel', 'error', '-y', '-ss', seekSeconds, '-i', filePath,
|
||||||
|
'-frames:v', '1', '-an', '-sn', '-dn',
|
||||||
|
'-vf', "scale='min(480,iw)':'min(480,ih)':force_original_aspect_ratio=decrease",
|
||||||
|
'-c:v', 'libwebp', '-quality', '78', '-compression_level', '4', '-f', 'webp', temporaryPath
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const posterStats = await fs.promises.stat(temporaryPath).catch(function () { return null; });
|
||||||
|
if (!posterStats || !posterStats.size || posterStats.size >= originalStats.size) continue;
|
||||||
|
try {
|
||||||
|
await fs.promises.rename(temporaryPath, posterPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (!error || !['EEXIST', 'EPERM'].includes(error.code)) throw error;
|
||||||
|
await fs.promises.access(posterPath);
|
||||||
|
}
|
||||||
|
return '/media/uploads/' + path.basename(posterPath);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
await fs.promises.unlink(temporaryPath).catch(function () {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createImageThumbnail(file) {
|
||||||
|
const filePath = String(file && file.path || '').trim();
|
||||||
|
const extension = path.extname(filePath).toLowerCase();
|
||||||
|
if (!filePath || !IMAGE_THUMBNAIL_EXTENSIONS.has(extension)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailPath = getImageThumbnailPath(filePath);
|
||||||
|
try {
|
||||||
|
await fs.promises.access(thumbnailPath);
|
||||||
|
return '/media/uploads/' + path.basename(thumbnailPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (!error || error.code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalStats = await fs.promises.stat(filePath);
|
||||||
|
const thumbnailBuffer = await sharp(filePath, { failOn: 'none' })
|
||||||
|
.rotate()
|
||||||
|
.resize({ width: IMAGE_THUMBNAIL_MAX_DIMENSION, height: IMAGE_THUMBNAIL_MAX_DIMENSION, fit: 'inside', withoutEnlargement: true })
|
||||||
|
.webp({ quality: 78, effort: 4 })
|
||||||
|
.toBuffer();
|
||||||
|
if (!thumbnailBuffer.length || thumbnailBuffer.length >= originalStats.size) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const temporaryPath = thumbnailPath + '.partial-' + crypto.randomUUID();
|
||||||
|
try {
|
||||||
|
await fs.promises.writeFile(temporaryPath, thumbnailBuffer);
|
||||||
|
try {
|
||||||
|
await fs.promises.rename(temporaryPath, thumbnailPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (!error || !['EEXIST', 'EPERM'].includes(error.code)) throw error;
|
||||||
|
await fs.promises.access(thumbnailPath);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await fs.promises.unlink(temporaryPath).catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/media/uploads/' + path.basename(thumbnailPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureImageThumbnail(mediaPath, uploadDir) {
|
||||||
|
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||||
|
if (!normalizedPath || !String(uploadDir || '').trim()) return null;
|
||||||
|
const extension = path.extname(normalizedPath).toLowerCase();
|
||||||
|
if (!IMAGE_THUMBNAIL_EXTENSIONS.has(extension)) return null;
|
||||||
|
|
||||||
|
const mediaRoot = path.resolve(path.dirname(uploadDir));
|
||||||
|
const filePath = path.resolve(mediaRoot, normalizedPath.slice('/media/'.length));
|
||||||
|
if (!filePath.startsWith(mediaRoot + path.sep)) return null;
|
||||||
|
try {
|
||||||
|
await fs.promises.access(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === 'ENOENT') return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailPath = await createImageThumbnail({ path: filePath });
|
||||||
|
if (!thumbnailPath) return null;
|
||||||
|
return normalizedPath.slice(0, normalizedPath.length - extension.length) + '.thumb.webp';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureVideoThumbnail(mediaPath, uploadDir) {
|
||||||
|
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||||
|
if (!normalizedPath || !String(uploadDir || '').trim()) return null;
|
||||||
|
const extension = path.extname(normalizedPath).toLowerCase();
|
||||||
|
if (!MEDIA_MIME_TYPES[extension] || MEDIA_MIME_TYPES[extension].indexOf('video/') !== 0) return null;
|
||||||
|
|
||||||
|
const mediaRoot = path.resolve(path.dirname(uploadDir));
|
||||||
|
const filePath = path.resolve(mediaRoot, normalizedPath.slice('/media/'.length));
|
||||||
|
if (!filePath.startsWith(mediaRoot + path.sep)) return null;
|
||||||
|
try {
|
||||||
|
await fs.promises.access(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === 'ENOENT') return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const posterUrl = await createVideoThumbnail({ path: filePath });
|
||||||
|
if (!posterUrl) return null;
|
||||||
|
return normalizedPath.slice(0, normalizedPath.length - extension.length) + '.poster.webp';
|
||||||
|
}
|
||||||
|
|
||||||
async function removeImageVariants(filePath) {
|
async function removeImageVariants(filePath) {
|
||||||
const normalizedPath = String(filePath || '').trim();
|
const normalizedPath = String(filePath || '').trim();
|
||||||
const extension = path.extname(normalizedPath).toLowerCase();
|
const extension = path.extname(normalizedPath).toLowerCase();
|
||||||
if (!normalizedPath || !OPTIMIZABLE_IMAGE_EXTENSIONS.has(extension)) {
|
if (!normalizedPath) return;
|
||||||
return;
|
const derivativePaths = [getImageThumbnailPath(normalizedPath), getVideoPosterPath(normalizedPath)];
|
||||||
|
if (OPTIMIZABLE_IMAGE_EXTENSIONS.has(extension)) {
|
||||||
|
derivativePaths.push(...IMAGE_VARIANT_DIMENSIONS.map(function (dimension) {
|
||||||
|
return getImageVariantPath(normalizedPath, dimension);
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
await Promise.all(IMAGE_VARIANT_DIMENSIONS.map(function (dimension) {
|
await Promise.all(derivativePaths.map(function (derivativePath) {
|
||||||
return fs.promises.unlink(getImageVariantPath(normalizedPath, dimension)).catch(function (error) {
|
return fs.promises.unlink(derivativePath).catch(function (error) {
|
||||||
if (error && error.code !== 'ENOENT') {
|
if (error && error.code !== 'ENOENT') {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -221,7 +377,7 @@ async function syncMediaAssetsFromDirectory(pool, uploadDir, userId) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const originalName = String(entry.name || '').trim();
|
const originalName = String(entry.name || '').trim();
|
||||||
if (/\.variant-\d+\.(?:jpe?g|png|webp)$/i.test(originalName)) {
|
if (/\.variant-\d+\.(?:jpe?g|png|webp)$/i.test(originalName) || /\.(?:thumb|poster)\.webp$/i.test(originalName)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const mimeType = MEDIA_MIME_TYPES[path.extname(originalName).toLowerCase()];
|
const mimeType = MEDIA_MIME_TYPES[path.extname(originalName).toLowerCase()];
|
||||||
@@ -308,6 +464,10 @@ module.exports = {
|
|||||||
normalizeMediaPath,
|
normalizeMediaPath,
|
||||||
createImageVariants,
|
createImageVariants,
|
||||||
removeImageVariants,
|
removeImageVariants,
|
||||||
|
createImageThumbnail,
|
||||||
|
ensureImageThumbnail,
|
||||||
|
createVideoThumbnail,
|
||||||
|
ensureVideoThumbnail,
|
||||||
optimizeUploadedImage,
|
optimizeUploadedImage,
|
||||||
registerMediaAsset,
|
registerMediaAsset,
|
||||||
registerMediaAssets,
|
registerMediaAssets,
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ if (search && initialGalleryQuery.has('search')) search.value = initialGalleryQu
|
|||||||
const galleryCount = gallery && gallery.querySelector('[data-media-gallery-count]');
|
const galleryCount = gallery && gallery.querySelector('[data-media-gallery-count]');
|
||||||
const noResults = gallery && gallery.querySelector('[data-media-gallery-no-results]');
|
const noResults = gallery && gallery.querySelector('[data-media-gallery-no-results]');
|
||||||
const loadMoreWrap = gallery && gallery.querySelector('[data-media-gallery-load-more-wrap]');
|
const loadMoreWrap = gallery && gallery.querySelector('[data-media-gallery-load-more-wrap]');
|
||||||
const loadMoreButton = gallery && gallery.querySelector('[data-media-gallery-load-more]');
|
const galleryLoading = gallery && gallery.querySelector('[data-media-gallery-loading]');
|
||||||
|
const galleryScroll = gallery && gallery.querySelector('.media-gallery-scroll');
|
||||||
const selectModeButton = gallery && gallery.querySelector('[data-media-gallery-select-mode]');
|
const selectModeButton = gallery && gallery.querySelector('[data-media-gallery-select-mode]');
|
||||||
const deleteSelectedButton = gallery && gallery.querySelector('[data-media-gallery-delete-selected]');
|
const deleteSelectedButton = gallery && gallery.querySelector('[data-media-gallery-delete-selected]');
|
||||||
const addMediaButton = gallery && gallery.querySelector('[data-media-library-add]');
|
const addMediaButton = gallery && gallery.querySelector('[data-media-library-add]');
|
||||||
@@ -29,6 +30,7 @@ let galleryHasMore = gallery ? gallery.dataset.mediaGalleryHasMore === 'true' :
|
|||||||
let galleryTotal = galleryCount ? Number(galleryCount.textContent || galleryItems.length) : galleryItems.length;
|
let galleryTotal = galleryCount ? Number(galleryCount.textContent || galleryItems.length) : galleryItems.length;
|
||||||
let galleryRefreshSequence = 0;
|
let galleryRefreshSequence = 0;
|
||||||
let selectionMode = false;
|
let selectionMode = false;
|
||||||
|
let galleryLoadInProgress = false;
|
||||||
const selectedAssetIds = new Set();
|
const selectedAssetIds = new Set();
|
||||||
const uploadForm = document.querySelector('form[action="/media-library"]');
|
const uploadForm = document.querySelector('form[action="/media-library"]');
|
||||||
const uploadInput = document.querySelector('[data-media-library-upload]');
|
const uploadInput = document.querySelector('[data-media-library-upload]');
|
||||||
@@ -84,11 +86,12 @@ if (addMediaButton && uploadModal) {
|
|||||||
preview.dataset.mediaName = asset.original_name || '';
|
preview.dataset.mediaName = asset.original_name || '';
|
||||||
preview.setAttribute('aria-label', 'Preview ' + (asset.original_name || 'media'));
|
preview.setAttribute('aria-label', 'Preview ' + (asset.original_name || 'media'));
|
||||||
const media = asset.media_type === 'video' ? document.createElement('video') : document.createElement('img');
|
const media = asset.media_type === 'video' ? document.createElement('video') : document.createElement('img');
|
||||||
media.src = asset.media_path || '';
|
media.src = asset.thumbnail_path || asset.media_path || '';
|
||||||
if (asset.media_type === 'video') {
|
if (asset.media_type === 'video') {
|
||||||
media.muted = true;
|
media.muted = true;
|
||||||
media.playsInline = true;
|
media.playsInline = true;
|
||||||
media.preload = 'metadata';
|
media.preload = 'none';
|
||||||
|
if (asset.thumbnail_path) media.poster = asset.thumbnail_path;
|
||||||
} else {
|
} else {
|
||||||
media.alt = asset.original_name || '';
|
media.alt = asset.original_name || '';
|
||||||
media.loading = 'lazy';
|
media.loading = 'lazy';
|
||||||
@@ -478,9 +481,10 @@ if (gallery) {
|
|||||||
}
|
}
|
||||||
if (selectModeButton) selectModeButton.addEventListener('click', function () { setSelectionMode(!selectionMode); });
|
if (selectModeButton) selectModeButton.addEventListener('click', function () { setSelectionMode(!selectionMode); });
|
||||||
if (deleteSelectedButton) deleteSelectedButton.addEventListener('click', submitBulkDelete);
|
if (deleteSelectedButton) deleteSelectedButton.addEventListener('click', submitBulkDelete);
|
||||||
if (loadMoreButton) {
|
async function loadMoreMedia() {
|
||||||
loadMoreButton.addEventListener('click', async function () {
|
if (!loadMoreWrap || !galleryHasMore || galleryLoadInProgress) return;
|
||||||
loadMoreButton.disabled = true;
|
galleryLoadInProgress = true;
|
||||||
|
if (galleryLoading) galleryLoading.hidden = false;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/media-library/assets?' + getGalleryQuery(galleryOffset, 4 * getColumnsPerRow()), { headers: { Accept: 'application/json' } });
|
const response = await fetch('/media-library/assets?' + getGalleryQuery(galleryOffset, 4 * getColumnsPerRow()), { headers: { Accept: 'application/json' } });
|
||||||
if (!response.ok) throw new Error('Unable to load more media.');
|
if (!response.ok) throw new Error('Unable to load more media.');
|
||||||
@@ -494,9 +498,21 @@ if (loadMoreButton) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.PulseUpload.showMessage(error.message || 'Unable to load more media.');
|
window.PulseUpload.showMessage(error.message || 'Unable to load more media.');
|
||||||
} finally {
|
} finally {
|
||||||
loadMoreButton.disabled = false;
|
if (galleryLoading) galleryLoading.hidden = true;
|
||||||
|
galleryLoadInProgress = false;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (loadMoreWrap && galleryScroll && window.IntersectionObserver) {
|
||||||
|
const loadMoreObserver = new window.IntersectionObserver(function (entries) {
|
||||||
|
if (entries.some(function (entry) { return entry.isIntersecting; })) loadMoreMedia();
|
||||||
|
}, { root: galleryScroll, rootMargin: '0px' });
|
||||||
|
loadMoreObserver.observe(loadMoreWrap);
|
||||||
|
} else if (loadMoreWrap && galleryScroll) {
|
||||||
|
galleryScroll.addEventListener('scroll', function () {
|
||||||
|
const remaining = galleryScroll.scrollHeight - galleryScroll.scrollTop - galleryScroll.clientHeight;
|
||||||
|
if (remaining <= 0) loadMoreMedia();
|
||||||
|
}, { passive: true });
|
||||||
}
|
}
|
||||||
updateSearchResults();
|
updateSearchResults();
|
||||||
|
|
||||||
|
|||||||
@@ -152,8 +152,16 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
|||||||
});
|
});
|
||||||
const settings = await fetchAppSettings(deps.pool);
|
const settings = await fetchAppSettings(deps.pool);
|
||||||
const filteredAssets = filterAndSortAssets(allAssets, getAssetQuery(req.query));
|
const filteredAssets = filterAndSortAssets(allAssets, getAssetQuery(req.query));
|
||||||
|
const pageAssets = filteredAssets.slice(0, pageSize);
|
||||||
|
await Promise.all(pageAssets.map(async function (asset) {
|
||||||
|
asset.thumbnail_path = asset.media_type === 'image'
|
||||||
|
? await mediaLibrary.ensureImageThumbnail(asset.media_path, deps.uploadDir)
|
||||||
|
: asset.media_type === 'video'
|
||||||
|
? await mediaLibrary.ensureVideoThumbnail(asset.media_path, deps.uploadDir)
|
||||||
|
: null;
|
||||||
|
}));
|
||||||
res.send(deps.pages.renderMediaLibraryPage({
|
res.send(deps.pages.renderMediaLibraryPage({
|
||||||
assets: filteredAssets.slice(0, pageSize),
|
assets: pageAssets,
|
||||||
assetTotal: filteredAssets.length,
|
assetTotal: filteredAssets.length,
|
||||||
hasMore: filteredAssets.length > pageSize,
|
hasMore: filteredAssets.length > pageSize,
|
||||||
uploadLimits: getUploadLimits(settings)
|
uploadLimits: getUploadLimits(settings)
|
||||||
@@ -181,6 +189,13 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
|||||||
const limit = Math.min(pageSize, Math.max(1, Number.parseInt(req.query.limit, 10) || pageSize));
|
const limit = Math.min(pageSize, Math.max(1, Number.parseInt(req.query.limit, 10) || pageSize));
|
||||||
const filteredAssets = filterAndSortAssets(allAssets, getAssetQuery(req.query));
|
const filteredAssets = filterAndSortAssets(allAssets, getAssetQuery(req.query));
|
||||||
const assets = filteredAssets.slice(offset, offset + limit);
|
const assets = filteredAssets.slice(offset, offset + limit);
|
||||||
|
await Promise.all(assets.map(async function (asset) {
|
||||||
|
asset.thumbnail_path = asset.media_type === 'image'
|
||||||
|
? await mediaLibrary.ensureImageThumbnail(asset.media_path, deps.uploadDir)
|
||||||
|
: asset.media_type === 'video'
|
||||||
|
? await mediaLibrary.ensureVideoThumbnail(asset.media_path, deps.uploadDir)
|
||||||
|
: null;
|
||||||
|
}));
|
||||||
res.json({ assets: assets, total: filteredAssets.length, nextOffset: offset + assets.length, hasMore: offset + assets.length < filteredAssets.length });
|
res.json({ assets: assets, total: filteredAssets.length, nextOffset: offset + assets.length, hasMore: offset + assets.length < filteredAssets.length });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -214,6 +229,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
|||||||
return rejectUpload('File must be ' + limitLabel + ' or smaller.');
|
return rejectUpload('File must be ' + limitLabel + ' or smaller.');
|
||||||
}
|
}
|
||||||
await mediaLibrary.createImageVariants(req.file);
|
await mediaLibrary.createImageVariants(req.file);
|
||||||
|
await mediaLibrary.createImageThumbnail(req.file);
|
||||||
const mediaPath = '/media/uploads/' + req.file.filename;
|
const mediaPath = '/media/uploads/' + req.file.filename;
|
||||||
await mediaLibrary.registerMediaAsset(deps.pool, Object.assign({}, req.file, { mediaType: mediaType }), mediaPath, deps.getAuditUserId(req));
|
await mediaLibrary.registerMediaAsset(deps.pool, Object.assign({}, req.file, { mediaType: mediaType }), mediaPath, deps.getAuditUserId(req));
|
||||||
if (typeof recordRequestAuditEvent === 'function') {
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
|
|||||||
@@ -54,10 +54,10 @@
|
|||||||
<article class="media-gallery-card" data-media-created-at="{{created_at}}" data-media-file-size="{{file_size}}">
|
<article class="media-gallery-card" data-media-created-at="{{created_at}}" data-media-file-size="{{file_size}}">
|
||||||
<button type="button" class="media-gallery-preview" data-media-gallery-preview data-media-path="{{media_path}}" data-media-type="{{media_type}}" data-media-name="{{original_name}}" aria-label="Preview {{original_name}}">
|
<button type="button" class="media-gallery-preview" data-media-gallery-preview data-media-path="{{media_path}}" data-media-type="{{media_type}}" data-media-name="{{original_name}}" aria-label="Preview {{original_name}}">
|
||||||
{{#if (eq media_type 'video')}}
|
{{#if (eq media_type 'video')}}
|
||||||
<video src="{{media_path}}" muted playsinline preload="metadata"></video>
|
<video src="{{media_path}}" poster="{{thumbnail_path}}" muted playsinline preload="none"></video>
|
||||||
<span class="media-gallery-play" aria-hidden="true"><i class="bi bi-play-fill"></i></span>
|
<span class="media-gallery-play" aria-hidden="true"><i class="bi bi-play-fill"></i></span>
|
||||||
{{else}}
|
{{else}}
|
||||||
<img src="{{media_path}}" alt="{{original_name}}" loading="lazy" />
|
<img src="{{#if thumbnail_path}}{{thumbnail_path}}{{else}}{{media_path}}{{/if}}" alt="{{original_name}}" loading="lazy" />
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<span class="media-gallery-type">{{mediaFormat}}</span>
|
<span class="media-gallery-type">{{mediaFormat}}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -81,7 +81,10 @@
|
|||||||
{{/each}}
|
{{/each}}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center mt-4" data-media-gallery-load-more-wrap hidden>
|
<div class="text-center mt-4" data-media-gallery-load-more-wrap hidden>
|
||||||
<button type="button" class="btn btn-primary px-4" data-media-gallery-load-more>Load more...</button>
|
<div class="d-inline-flex align-items-center gap-2 text-body-secondary small" data-media-gallery-loading hidden role="status" aria-live="polite">
|
||||||
|
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
|
||||||
|
<span>Loading media...</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="media-gallery-empty text-body-secondary" data-media-gallery-no-results{{#if assets.length}} hidden{{/if}}>No media matches your search.</div>
|
<div class="media-gallery-empty text-body-secondary" data-media-gallery-no-results{{#if assets.length}} hidden{{/if}}>No media matches your search.</div>
|
||||||
|
|||||||
+116
-5
@@ -16,7 +16,9 @@ const { removeUnusedImageCopies, removePendingMediaAssets } = require('../src/we
|
|||||||
const {
|
const {
|
||||||
normalizeMediaPath,
|
normalizeMediaPath,
|
||||||
createImageVariants,
|
createImageVariants,
|
||||||
|
createImageThumbnail,
|
||||||
removeImageVariants,
|
removeImageVariants,
|
||||||
|
ensureImageThumbnail,
|
||||||
optimizeUploadedImage,
|
optimizeUploadedImage,
|
||||||
registerMediaAssets,
|
registerMediaAssets,
|
||||||
syncMediaAssetsFromDirectory,
|
syncMediaAssetsFromDirectory,
|
||||||
@@ -566,14 +568,19 @@ test('slide images retain original references and re-editable crops across saves
|
|||||||
test('media library sorts Date-valued creation timestamps chronologically', async () => {
|
test('media library sorts Date-valued creation timestamps chronologically', async () => {
|
||||||
const originalSyncMediaAssets = mediaLibrary.syncMediaAssetsFromDirectory;
|
const originalSyncMediaAssets = mediaLibrary.syncMediaAssetsFromDirectory;
|
||||||
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
|
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
|
||||||
|
const originalEnsureImageThumbnail = mediaLibrary.ensureImageThumbnail;
|
||||||
|
const originalEnsureVideoThumbnail = mediaLibrary.ensureVideoThumbnail;
|
||||||
const assets = [
|
const assets = [
|
||||||
{ id: 1, original_name: 'October 14', media_type: 'image', file_size: 1, created_at: new Date('2026-10-14T12:00:00Z') },
|
{ id: 1, original_name: 'October 14', media_type: 'image', media_path: '/media/uploads/14.png', file_size: 1, created_at: new Date('2026-10-14T12:00:00Z') },
|
||||||
{ id: 2, original_name: 'October 15', media_type: 'image', file_size: 1, created_at: new Date('2026-10-15T12:00:00Z') },
|
{ id: 2, original_name: 'October 15', media_type: 'image', media_path: '/media/uploads/15.png', file_size: 1, created_at: new Date('2026-10-15T12:00:00Z') },
|
||||||
{ id: 3, original_name: 'October 17', media_type: 'image', file_size: 1, created_at: new Date('2026-10-17T12:00:00Z') }
|
{ id: 3, original_name: 'October 17', media_type: 'image', media_path: '/media/uploads/17.png', file_size: 1, created_at: new Date('2026-10-17T12:00:00Z') },
|
||||||
|
{ id: 4, original_name: 'October 13', media_type: 'video', media_path: '/media/uploads/13.mp4', file_size: 1, created_at: new Date('2026-10-13T12:00:00Z') }
|
||||||
];
|
];
|
||||||
const handlers = {};
|
const handlers = {};
|
||||||
mediaLibrary.syncMediaAssetsFromDirectory = async function () { return 0; };
|
mediaLibrary.syncMediaAssetsFromDirectory = async function () { return 0; };
|
||||||
mediaLibrary.countMediaAssetReferences = async function () { return 0; };
|
mediaLibrary.countMediaAssetReferences = async function () { return 0; };
|
||||||
|
mediaLibrary.ensureImageThumbnail = async function (mediaPath) { return mediaPath.replace(/\.png$/, '.thumb.webp'); };
|
||||||
|
mediaLibrary.ensureVideoThumbnail = async function (mediaPath) { return mediaPath.replace(/\.mp4$/, '.poster.webp'); };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
registerMediaLibraryRoutes({
|
registerMediaLibraryRoutes({
|
||||||
@@ -591,14 +598,20 @@ test('media library sorts Date-valued creation timestamps chronologically', asyn
|
|||||||
const next = function (error) { if (error) throw error; };
|
const next = function (error) { if (error) throw error; };
|
||||||
const request = { currentUser: { permissions: [] }, query: { offset: '0', limit: '24', sort: 'date-desc' } };
|
const request = { currentUser: { permissions: [] }, query: { offset: '0', limit: '24', sort: 'date-desc' } };
|
||||||
await handlers['/media-library/assets'](request, response, next);
|
await handlers['/media-library/assets'](request, response, next);
|
||||||
assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 17', 'October 15', 'October 14']);
|
assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 17', 'October 15', 'October 14', 'October 13']);
|
||||||
|
assert.equal(response.data.assets[0].thumbnail_path, '/media/uploads/17.thumb.webp');
|
||||||
|
assert.equal(response.data.assets[0].media_path, '/media/uploads/17.png');
|
||||||
|
assert.equal(response.data.assets[3].thumbnail_path, '/media/uploads/13.poster.webp');
|
||||||
|
assert.equal(response.data.assets[3].media_path, '/media/uploads/13.mp4');
|
||||||
|
|
||||||
request.query.sort = 'date-asc';
|
request.query.sort = 'date-asc';
|
||||||
await handlers['/media-library/assets'](request, response, next);
|
await handlers['/media-library/assets'](request, response, next);
|
||||||
assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 14', 'October 15', 'October 17']);
|
assert.deepEqual(response.data.assets.map(asset => asset.original_name), ['October 13', 'October 14', 'October 15', 'October 17']);
|
||||||
} finally {
|
} finally {
|
||||||
mediaLibrary.syncMediaAssetsFromDirectory = originalSyncMediaAssets;
|
mediaLibrary.syncMediaAssetsFromDirectory = originalSyncMediaAssets;
|
||||||
mediaLibrary.countMediaAssetReferences = originalCountReferences;
|
mediaLibrary.countMediaAssetReferences = originalCountReferences;
|
||||||
|
mediaLibrary.ensureImageThumbnail = originalEnsureImageThumbnail;
|
||||||
|
mediaLibrary.ensureVideoThumbnail = originalEnsureVideoThumbnail;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -649,6 +662,48 @@ test('media library creates only smaller image variants and preserves the origin
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('media library creates compact WebP thumbnails and removes them with image derivatives', async () => {
|
||||||
|
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-thumbnails-'));
|
||||||
|
const filePath = path.join(uploadDir, 'large.png');
|
||||||
|
const sourceBuffer = await sharp(crypto.randomBytes(1600 * 900 * 3), {
|
||||||
|
raw: { width: 1600, height: 900, channels: 3 }
|
||||||
|
}).png({ compressionLevel: 0 }).toBuffer();
|
||||||
|
await fs.promises.writeFile(filePath, sourceBuffer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const thumbnailUrl = await createImageThumbnail({ path: filePath });
|
||||||
|
const thumbnailPath = path.join(uploadDir, 'large.thumb.webp');
|
||||||
|
const thumbnailBuffer = await fs.promises.readFile(thumbnailPath);
|
||||||
|
const metadata = await sharp(thumbnailBuffer).metadata();
|
||||||
|
assert.equal(thumbnailUrl, '/media/uploads/large.thumb.webp');
|
||||||
|
assert.equal(metadata.format, 'webp');
|
||||||
|
assert.ok(metadata.width <= 480 && metadata.height <= 480);
|
||||||
|
assert.ok((await fs.promises.stat(thumbnailPath)).size < sourceBuffer.length);
|
||||||
|
assert.deepEqual(await fs.promises.readFile(filePath), sourceBuffer);
|
||||||
|
await removeImageVariants(filePath);
|
||||||
|
await assert.rejects(fs.promises.access(thumbnailPath), { code: 'ENOENT' });
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(uploadDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('media library creates thumbnails for older assets when a gallery page requests them', async () => {
|
||||||
|
const mediaRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-backfill-'));
|
||||||
|
const uploadDir = path.join(mediaRoot, 'uploads');
|
||||||
|
await fs.promises.mkdir(uploadDir);
|
||||||
|
const filePath = path.join(uploadDir, 'legacy.jpg');
|
||||||
|
await sharp({ create: { width: 900, height: 600, channels: 3, background: '#16835d' } }).jpeg().toFile(filePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(await ensureImageThumbnail('/media/uploads/legacy.jpg', uploadDir), '/media/uploads/legacy.thumb.webp');
|
||||||
|
assert.equal(await ensureImageThumbnail('/media/uploads/missing.jpg', uploadDir), null);
|
||||||
|
assert.equal(await ensureImageThumbnail('/media/../outside.jpg', uploadDir), null);
|
||||||
|
assert.ok((await fs.promises.stat(path.join(uploadDir, 'legacy.thumb.webp'))).size > 0);
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('media library registers uploaded files with their metadata', async () => {
|
test('media library registers uploaded files with their metadata', async () => {
|
||||||
const queries = [];
|
const queries = [];
|
||||||
const pool = {
|
const pool = {
|
||||||
@@ -681,6 +736,7 @@ test('media library registers uploaded files with their metadata', async () => {
|
|||||||
test('media library backfills existing image and video uploads', async () => {
|
test('media library backfills existing image and video uploads', async () => {
|
||||||
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-library-'));
|
const uploadDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-media-library-'));
|
||||||
await fs.promises.writeFile(path.join(uploadDir, 'legacy.png'), Buffer.from('image'));
|
await fs.promises.writeFile(path.join(uploadDir, 'legacy.png'), Buffer.from('image'));
|
||||||
|
await fs.promises.writeFile(path.join(uploadDir, 'legacy.thumb.webp'), Buffer.from('thumbnail'));
|
||||||
await fs.promises.writeFile(path.join(uploadDir, 'legacy.mp4'), Buffer.from('video'));
|
await fs.promises.writeFile(path.join(uploadDir, 'legacy.mp4'), Buffer.from('video'));
|
||||||
await fs.promises.writeFile(path.join(uploadDir, 'ignore.txt'), Buffer.from('other'));
|
await fs.promises.writeFile(path.join(uploadDir, 'ignore.txt'), Buffer.from('other'));
|
||||||
const queries = [];
|
const queries = [];
|
||||||
@@ -746,6 +802,7 @@ test('full-size slide previews share the small thumbnail reference', async () =>
|
|||||||
|
|
||||||
test('media library upload and delete routes audit successful asset changes', async () => {
|
test('media library upload and delete routes audit successful asset changes', async () => {
|
||||||
const originalCreateVariants = mediaLibrary.createImageVariants;
|
const originalCreateVariants = mediaLibrary.createImageVariants;
|
||||||
|
const originalCreateThumbnail = mediaLibrary.createImageThumbnail;
|
||||||
const originalRegisterAsset = mediaLibrary.registerMediaAsset;
|
const originalRegisterAsset = mediaLibrary.registerMediaAsset;
|
||||||
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
|
const originalCountReferences = mediaLibrary.countMediaAssetReferences;
|
||||||
const originalRemoveVariants = mediaLibrary.removeImageVariants;
|
const originalRemoveVariants = mediaLibrary.removeImageVariants;
|
||||||
@@ -771,6 +828,7 @@ test('media library upload and delete routes audit successful asset changes', as
|
|||||||
};
|
};
|
||||||
|
|
||||||
mediaLibrary.createImageVariants = async function () {};
|
mediaLibrary.createImageVariants = async function () {};
|
||||||
|
mediaLibrary.createImageThumbnail = async function () {};
|
||||||
mediaLibrary.registerMediaAsset = async function () {};
|
mediaLibrary.registerMediaAsset = async function () {};
|
||||||
mediaLibrary.countMediaAssetReferences = async function () { return 0; };
|
mediaLibrary.countMediaAssetReferences = async function () { return 0; };
|
||||||
mediaLibrary.removeImageVariants = async function () {};
|
mediaLibrary.removeImageVariants = async function () {};
|
||||||
@@ -820,6 +878,7 @@ test('media library upload and delete routes audit successful asset changes', as
|
|||||||
assert.match(jsonResponse.data.error, /disabled/);
|
assert.match(jsonResponse.data.error, /disabled/);
|
||||||
} finally {
|
} finally {
|
||||||
mediaLibrary.createImageVariants = originalCreateVariants;
|
mediaLibrary.createImageVariants = originalCreateVariants;
|
||||||
|
mediaLibrary.createImageThumbnail = originalCreateThumbnail;
|
||||||
mediaLibrary.registerMediaAsset = originalRegisterAsset;
|
mediaLibrary.registerMediaAsset = originalRegisterAsset;
|
||||||
mediaLibrary.countMediaAssetReferences = originalCountReferences;
|
mediaLibrary.countMediaAssetReferences = originalCountReferences;
|
||||||
mediaLibrary.removeImageVariants = originalRemoveVariants;
|
mediaLibrary.removeImageVariants = originalRemoveVariants;
|
||||||
@@ -1019,9 +1078,61 @@ test('empty media gallery retains filters and its refreshable results container'
|
|||||||
assert.match(html, /data-media-gallery-usage-filter/);
|
assert.match(html, /data-media-gallery-usage-filter/);
|
||||||
assert.match(html, /data-media-gallery-sort/);
|
assert.match(html, /data-media-gallery-sort/);
|
||||||
assert.match(html, /class="media-gallery-scroll"/);
|
assert.match(html, /class="media-gallery-scroll"/);
|
||||||
|
assert.match(html, /data-media-gallery-loading[^>]*role="status" aria-live="polite"/);
|
||||||
|
assert.doesNotMatch(html, /data-media-gallery-load-more>/);
|
||||||
assert.match(html, /data-media-gallery-no-results>/);
|
assert.match(html, /data-media-gallery-no-results>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('media gallery automatically loads the next page when its load-more sentinel approaches the scroll viewport', async () => {
|
||||||
|
const controls = {};
|
||||||
|
const mediaScroll = {};
|
||||||
|
const loadMoreWrap = { hidden: false };
|
||||||
|
const loading = { hidden: true };
|
||||||
|
let observedTarget;
|
||||||
|
let observerCallback;
|
||||||
|
let observerOptions;
|
||||||
|
let requestedUrl = '';
|
||||||
|
const gallery = {
|
||||||
|
dataset: { mediaGalleryOffset: '24', mediaGalleryHasMore: 'true' },
|
||||||
|
querySelector(selector) {
|
||||||
|
if (selector === '.media-gallery-scroll') return mediaScroll;
|
||||||
|
return controls[selector] || null;
|
||||||
|
},
|
||||||
|
querySelectorAll() { return []; },
|
||||||
|
addEventListener() {}
|
||||||
|
};
|
||||||
|
controls['[data-media-gallery-load-more-wrap]'] = loadMoreWrap;
|
||||||
|
controls['[data-media-gallery-loading]'] = loading;
|
||||||
|
class TestIntersectionObserver {
|
||||||
|
constructor(callback, options) { observerCallback = callback; observerOptions = options; }
|
||||||
|
observe(target) { observedTarget = target; }
|
||||||
|
}
|
||||||
|
const context = vm.createContext({
|
||||||
|
URLSearchParams,
|
||||||
|
window: { location: { search: '' }, innerWidth: 1200, IntersectionObserver: TestIntersectionObserver },
|
||||||
|
document: {
|
||||||
|
getElementById() { return null; },
|
||||||
|
querySelector(selector) { return selector === '.media-gallery' ? gallery : null; }
|
||||||
|
},
|
||||||
|
fetch: async function (url) {
|
||||||
|
requestedUrl = url;
|
||||||
|
return { ok: true, async json() { return { assets: [], total: 24, nextOffset: 24, hasMore: false }; } };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
vm.runInContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/settings/media-library.js'), 'utf8'), context);
|
||||||
|
|
||||||
|
assert.equal(observedTarget, loadMoreWrap);
|
||||||
|
assert.equal(observerOptions.root, mediaScroll);
|
||||||
|
assert.equal(observerOptions.rootMargin, '0px');
|
||||||
|
observerCallback([{ isIntersecting: true }]);
|
||||||
|
assert.equal(loading.hidden, false);
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
assert.match(requestedUrl, /^\/media-library\/assets\?/);
|
||||||
|
assert.equal(new URL(requestedUrl, 'http://localhost').searchParams.get('offset'), '24');
|
||||||
|
assert.equal(loadMoreWrap.hidden, true);
|
||||||
|
assert.equal(loading.hidden, true);
|
||||||
|
});
|
||||||
|
|
||||||
test('media rename validates filenames and changes only metadata with update permission and auditing', async () => {
|
test('media rename validates filenames and changes only metadata with update permission and auditing', async () => {
|
||||||
const handlers = {};
|
const handlers = {};
|
||||||
const queries = [];
|
const queries = [];
|
||||||
|
|||||||
Reference in new issue
Block a user