diff --git a/CHANGELOG.md b/CHANGELOG.md index d98801b..03827ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. ### Added - Added an All timetable display mode that includes past items while respecting date filters and the item limit. +- Added filename renaming in Media Library image and video previews without changing stored media paths or slide references. ### Changed @@ -19,6 +20,7 @@ All notable changes to this project will be documented in this file. - Region inputs no longer submit the template form when Enter is pressed. - Region width and height can be cleared and retyped without minimum-size clamping until the field is committed. - Restored remove controls when selecting image or video media from the Media Library. +- Preserved Media Library search, filters, and sort order after individual or bulk deletion. ## 2.15.4 - 2026-10-16 diff --git a/src/rbac.js b/src/rbac.js index e66bb21..305a2c7 100644 --- a/src/rbac.js +++ b/src/rbac.js @@ -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.' } ] }, diff --git a/src/web/public/js/settings/media-library.js b/src/web/public/js/settings/media-library.js index 35c09e4..54d6faf 100644 --- a/src/web/public/js/settings/media-library.js +++ b/src/web/public/js/settings/media-library.js @@ -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(); }); } \ No newline at end of file diff --git a/src/web/routes/settings/media-library.js b/src/web/routes/settings/media-library.js index 83b1d19..339b18b 100644 --- a/src/web/routes/settings/media-library.js +++ b/src/web/routes/settings/media-library.js @@ -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); } diff --git a/src/web/views/settings/media-library/list.hbs b/src/web/views/settings/media-library/list.hbs index 53915d1..c79cf4a 100644 --- a/src/web/views/settings/media-library/list.hbs +++ b/src/web/views/settings/media-library/list.hbs @@ -12,7 +12,6 @@ {{#if (hasPermission currentUser 'media-library.create')}} {{/if}} - {{#if assets.length}}
{{assetTotal}} item{{#unless (eq assetTotal 1)}}s{{/unless}}
{{assetTotal}} item{{#unless (eq assetTotal 1)}}s{{/unless}}
- {{/if}} - {{#if assets.length}}