Add media filename renaming and preserve library filters
This commit is contained in:
1 parent
2fd059d35a
commit
eb724167bc
6 files changed
+310
-20
No files matched your search
@@ -88,6 +88,7 @@ const PERMISSION_SECTIONS = [
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View shared media assets.' },
|
||||
{ key: 'create', name: 'Create', description: 'Upload shared media assets.' },
|
||||
{ key: 'update', name: 'Update', description: 'Rename shared media assets.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete unused shared media assets.' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -6,6 +6,15 @@ const search = gallery && gallery.querySelector('[data-media-gallery-search]');
|
||||
const typeFilter = gallery && gallery.querySelector('[data-media-gallery-type-filter]');
|
||||
const usageFilter = gallery && gallery.querySelector('[data-media-gallery-usage-filter]');
|
||||
const sort = gallery && gallery.querySelector('[data-media-gallery-sort]');
|
||||
const initialGalleryQuery = new URLSearchParams(window.location.search);
|
||||
if (search && initialGalleryQuery.has('search')) search.value = initialGalleryQuery.get('search');
|
||||
[ ['type', typeFilter], ['usage', usageFilter], ['sort', sort] ].forEach(function (entry) {
|
||||
const control = entry[1];
|
||||
const value = initialGalleryQuery.get(entry[0]);
|
||||
if (control && Array.from(control.options).some(function (option) { return option.value === value; })) {
|
||||
control.value = value;
|
||||
}
|
||||
});
|
||||
const galleryCount = gallery && gallery.querySelector('[data-media-gallery-count]');
|
||||
const noResults = gallery && gallery.querySelector('[data-media-gallery-no-results]');
|
||||
const loadMoreWrap = gallery && gallery.querySelector('[data-media-gallery-load-more-wrap]');
|
||||
@@ -369,14 +378,19 @@ if (uploadForm) {
|
||||
});
|
||||
}
|
||||
|
||||
function getGalleryQuery(offset, limit) {
|
||||
function getGalleryFilters() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('offset', String(offset));
|
||||
params.set('limit', String(limit));
|
||||
params.set('search', String(search && search.value || '').trim());
|
||||
params.set('type', typeFilter ? typeFilter.value : 'all');
|
||||
params.set('usage', usageFilter ? usageFilter.value : 'all');
|
||||
params.set('sort', sort ? sort.value : 'date-desc');
|
||||
return params;
|
||||
}
|
||||
|
||||
function getGalleryQuery(offset, limit) {
|
||||
const params = getGalleryFilters();
|
||||
params.set('offset', String(offset));
|
||||
params.set('limit', String(limit));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
@@ -439,7 +453,7 @@ function submitBulkDelete() {
|
||||
}
|
||||
const form = document.createElement('form');
|
||||
form.method = 'post';
|
||||
form.action = '/media-library/delete';
|
||||
form.action = '/media-library/delete?' + getGalleryFilters().toString();
|
||||
selectedAssetIds.forEach(function (id) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
@@ -484,6 +498,12 @@ if (typeFilter) typeFilter.addEventListener('change', resetVisibleItemLimit);
|
||||
if (usageFilter) usageFilter.addEventListener('change', resetVisibleItemLimit);
|
||||
if (sort) sort.addEventListener('change', resetVisibleItemLimit);
|
||||
if (gallery) {
|
||||
gallery.addEventListener('submit', function (event) {
|
||||
const form = event.target;
|
||||
const action = form && form.getAttribute('action');
|
||||
if (!/^\/media-library\/[^/?]+\/delete(?:\?|$)/.test(action || '')) return;
|
||||
form.action = action.split('?')[0] + '?' + getGalleryFilters().toString();
|
||||
}, true);
|
||||
gallery.addEventListener('click', function (event) {
|
||||
if (!selectionMode) return;
|
||||
const column = event.target && event.target.closest ? event.target.closest('[data-media-gallery-column]') : null;
|
||||
@@ -534,6 +554,62 @@ function showPreviewModal() {
|
||||
}
|
||||
|
||||
if (modal && body) {
|
||||
const renameForm = modal.querySelector('[data-media-gallery-rename-form]');
|
||||
const filenameInput = modal.querySelector('[data-media-gallery-filename]');
|
||||
const renameSave = modal.querySelector('[data-media-gallery-rename-save]');
|
||||
const renameStatus = modal.querySelector('[data-media-gallery-rename-status]');
|
||||
let activePreview = null;
|
||||
|
||||
if (renameForm && filenameInput && renameSave && renameStatus) {
|
||||
renameForm.addEventListener('submit', async function (event) {
|
||||
event.preventDefault();
|
||||
if (renameSave.disabled || !activePreview) return;
|
||||
const preview = activePreview;
|
||||
const action = renameForm.action;
|
||||
renameSave.disabled = true;
|
||||
filenameInput.disabled = true;
|
||||
renameStatus.hidden = true;
|
||||
try {
|
||||
const response = await fetch(action, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ name: filenameInput.value })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || 'Unable to rename media.');
|
||||
preview.dataset.mediaName = result.name;
|
||||
preview.setAttribute('aria-label', 'Preview ' + result.name);
|
||||
const column = preview.closest('[data-media-gallery-column]');
|
||||
const label = column && column.querySelector('.media-gallery-name');
|
||||
if (label) {
|
||||
label.textContent = result.name;
|
||||
label.title = result.name;
|
||||
}
|
||||
if (activePreview === preview) {
|
||||
filenameInput.value = result.name;
|
||||
if (title) title.textContent = result.name;
|
||||
const image = body.querySelector('img');
|
||||
if (image) image.alt = result.name;
|
||||
renameStatus.textContent = 'Filename saved.';
|
||||
renameStatus.classList.remove('text-danger');
|
||||
renameStatus.hidden = false;
|
||||
}
|
||||
await refreshGallery();
|
||||
} catch (error) {
|
||||
if (activePreview === preview) {
|
||||
renameStatus.textContent = error.message || 'Unable to rename media.';
|
||||
renameStatus.classList.add('text-danger');
|
||||
renameStatus.hidden = false;
|
||||
}
|
||||
} finally {
|
||||
if (activePreview === preview) {
|
||||
renameSave.disabled = false;
|
||||
filenameInput.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
const preview = event.target && event.target.closest ? event.target.closest('[data-media-gallery-preview]') : null;
|
||||
if (!preview) {
|
||||
@@ -543,6 +619,17 @@ if (modal && body) {
|
||||
const path = preview.getAttribute('data-media-path') || '';
|
||||
const type = preview.getAttribute('data-media-type') || 'image';
|
||||
const name = preview.getAttribute('data-media-name') || 'Media preview';
|
||||
activePreview = preview;
|
||||
if (renameForm && filenameInput && renameSave && renameStatus) {
|
||||
const column = preview.closest('[data-media-gallery-column]');
|
||||
const assetId = column && column.dataset.mediaId;
|
||||
renameForm.hidden = !assetId;
|
||||
renameForm.action = '/media-library/' + encodeURIComponent(assetId || '') + '/rename';
|
||||
filenameInput.value = name;
|
||||
filenameInput.disabled = false;
|
||||
renameSave.disabled = false;
|
||||
renameStatus.hidden = true;
|
||||
}
|
||||
body.replaceChildren();
|
||||
if (title) {
|
||||
title.textContent = name;
|
||||
@@ -560,6 +647,7 @@ if (modal && body) {
|
||||
});
|
||||
|
||||
modal.addEventListener('hidden.bs.modal', function () {
|
||||
activePreview = null;
|
||||
body.replaceChildren();
|
||||
});
|
||||
}
|
||||
@@ -14,7 +14,7 @@ function requireMediaAccess(setAuthMessageCookie) {
|
||||
}
|
||||
return res.redirect('/login');
|
||||
}
|
||||
if (hasAnyPermission(req.currentUser, ['media-library.read', 'media-library.create', 'media-library.delete'])) {
|
||||
if (hasAnyPermission(req.currentUser, ['media-library.read', 'media-library.create', 'media-library.update', 'media-library.delete'])) {
|
||||
return next();
|
||||
}
|
||||
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||
@@ -71,7 +71,7 @@ function formatFileSize(bytes) {
|
||||
}
|
||||
|
||||
function getMediaFormat(asset) {
|
||||
const name = String(asset && (asset.original_name || asset.media_path) || '').trim();
|
||||
const name = String(asset && (asset.media_path || asset.original_name) || '').trim();
|
||||
const match = name.match(/\.([a-z0-9]+)$/i);
|
||||
return match ? match[1].toUpperCase() : String(asset && asset.media_type || '').toUpperCase();
|
||||
}
|
||||
@@ -115,6 +115,13 @@ function filterAndSortAssets(assets, query) {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function getDeleteRedirect(req, message) {
|
||||
const params = new URLSearchParams(getAssetQuery(req.query));
|
||||
params.set('search', String(req.query && req.query.search || '').trim());
|
||||
params.set('message', message);
|
||||
return '/media-library?' + params.toString();
|
||||
}
|
||||
|
||||
module.exports = function registerMediaLibraryRoutes(app, deps) {
|
||||
const requireAccess = requireMediaAccess(deps.setAuthMessageCookie);
|
||||
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||
@@ -219,6 +226,37 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/media-library/:id/rename', requireAccess, deps.requirePermission('media-library.update'), async function (req, res, next) {
|
||||
try {
|
||||
const name = typeof (req.body && req.body.name) === 'string' ? req.body.name.trim() : '';
|
||||
if (!name || name.length > 255 || /[<>:"/\\|?*\u0000-\u001f\u007f]/.test(name) || name === '.' || name === '..' || /[. ]$/.test(name)) {
|
||||
return res.status(400).json({ error: 'Enter a valid filename of up to 255 characters.' });
|
||||
}
|
||||
const [rows] = await deps.pool.query('SELECT id, original_name, media_path FROM c_media_assets WHERE id = ?', [Number(req.params.id)]);
|
||||
const asset = rows && rows[0];
|
||||
if (!asset) {
|
||||
return res.status(404).json({ error: 'Media asset not found.' });
|
||||
}
|
||||
if (asset.original_name !== name) {
|
||||
await deps.pool.query('UPDATE c_media_assets SET original_name = ?, modified_by = ? WHERE id = ?', [name, deps.getAuditUserId(req), asset.id]);
|
||||
if (typeof recordRequestAuditEvent === 'function') {
|
||||
await recordRequestAuditEvent(deps.pool, req, {
|
||||
category: 'media-assets',
|
||||
eventType: 'media.renamed',
|
||||
actorUserId: deps.getAuditUserId(req),
|
||||
targetType: 'media-asset',
|
||||
targetId: asset.id,
|
||||
targetLabel: name,
|
||||
details: { mediaPath: asset.media_path, previousName: asset.original_name, name: name }
|
||||
});
|
||||
}
|
||||
}
|
||||
res.json({ id: asset.id, name: name });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
async function deleteMediaAsset(assetId, req) {
|
||||
const [rows] = await deps.pool.query('SELECT id, media_path, original_name, media_type, file_size FROM c_media_assets WHERE id = ?', [Number(assetId)]);
|
||||
const asset = rows && rows[0];
|
||||
@@ -269,7 +307,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
||||
return Number.isInteger(value) && value > 0;
|
||||
})));
|
||||
if (!assetIds.length) {
|
||||
return res.redirect('/media-library?message=' + encodeURIComponent('Select at least one media asset.'));
|
||||
return res.redirect(getDeleteRedirect(req, 'Select at least one media asset.'));
|
||||
}
|
||||
let deletedCount = 0;
|
||||
let inUseCount = 0;
|
||||
@@ -279,7 +317,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
||||
if (result === 'in-use') inUseCount += 1;
|
||||
}
|
||||
const message = deletedCount + ' media asset' + (deletedCount === 1 ? '' : 's') + ' deleted.' + (inUseCount ? ' ' + inUseCount + ' in-use asset' + (inUseCount === 1 ? '' : 's') + ' skipped.' : '');
|
||||
res.redirect('/media-library?message=' + encodeURIComponent(message));
|
||||
res.redirect(getDeleteRedirect(req, message));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -289,12 +327,12 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
|
||||
try {
|
||||
const result = await deleteMediaAsset(req.params.id, req);
|
||||
if (result === 'missing') {
|
||||
return res.redirect('/media-library?message=' + encodeURIComponent('Media asset not found.'));
|
||||
return res.redirect(getDeleteRedirect(req, 'Media asset not found.'));
|
||||
}
|
||||
if (result === 'in-use') {
|
||||
return res.redirect('/media-library?message=' + encodeURIComponent('This media is still used and cannot be deleted.'));
|
||||
return res.redirect(getDeleteRedirect(req, 'This media is still used and cannot be deleted.'));
|
||||
}
|
||||
res.redirect('/media-library?message=' + encodeURIComponent('Media deleted.'));
|
||||
res.redirect(getDeleteRedirect(req, 'Media deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
{{#if (hasPermission currentUser 'media-library.create')}}
|
||||
<button type="button" class="btn btn-primary btn-sm text-nowrap" data-media-library-add><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add media</button>
|
||||
{{/if}}
|
||||
{{#if assets.length}}
|
||||
<div class="media-gallery-controls">
|
||||
<p class="media-gallery-count text-body-secondary small mb-0"><span data-media-gallery-count>{{assetTotal}}</span> item{{#unless (eq assetTotal 1)}}s{{/unless}}</p>
|
||||
<div class="media-gallery-search d-flex flex-wrap gap-2">
|
||||
@@ -48,12 +47,8 @@
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="media-gallery-count text-body-secondary small mb-0"><span data-media-gallery-count>{{assetTotal}}</span> item{{#unless (eq assetTotal 1)}}s{{/unless}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
{{#if assets.length}}
|
||||
<div class="media-gallery-scroll">
|
||||
<div class="row g-3 g-xl-4">
|
||||
{{#each assets}}
|
||||
@@ -91,10 +86,7 @@
|
||||
<button type="button" class="btn btn-primary px-4" data-media-gallery-load-more>Load more...</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-gallery-empty text-body-secondary" data-media-gallery-no-results hidden>No media matches your search.</div>
|
||||
{{else}}
|
||||
<div class="media-gallery-empty text-body-secondary">No media yet.</div>
|
||||
{{/if}}
|
||||
<div class="media-gallery-empty text-body-secondary" data-media-gallery-no-results{{#if assets.length}} hidden{{/if}}>No media matches your search.</div>
|
||||
</section>
|
||||
|
||||
{{#> modal-shell modalId="media-gallery-preview-modal" modalLabelId="media-gallery-preview-modal-label" modalDialogClass="modal-dialog-centered modal-fullscreen-md-down modal-xl"}}
|
||||
@@ -103,6 +95,16 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body media-gallery-preview-modal-body" data-media-gallery-preview-body></div>
|
||||
{{#if (hasPermission currentUser 'media-library.update')}}
|
||||
<form class="modal-footer d-block" data-media-gallery-rename-form>
|
||||
<label for="media-gallery-filename" class="form-label">Filename</label>
|
||||
<div class="input-group">
|
||||
<input id="media-gallery-filename" name="name" type="text" class="form-control" maxlength="255" required data-media-gallery-filename />
|
||||
<button type="submit" class="btn btn-primary" title="Save filename" data-media-gallery-rename-save><i class="bi bi-floppy" aria-hidden="true"></i><span class="visually-hidden">Save filename</span></button>
|
||||
</div>
|
||||
<p class="small mb-0 mt-2" role="status" data-media-gallery-rename-status hidden></p>
|
||||
</form>
|
||||
{{/if}}
|
||||
{{/modal-shell}}
|
||||
|
||||
{{#if (hasPermission currentUser 'media-library.create')}}
|
||||
|
||||
Reference in new issue
Block a user