/* * Raiju Image Manager — Unified Component * * Shared between the frontend inline editor and the backend dashboard. * Consumers initialize it once via NbImageManager.init({...}) and then * call NbImageManager.open(callback, currentPath, { types: ['image'] }) to pick media. * * Depends on css/image-manager.css and css/raiju-admin-tokens.css. */ (function () { 'use strict'; // ============================================================ // CONFIGURATION (set via init()) // ============================================================ var config = { apiUrl: 'api.php', csrfToken: '', t: function (key) { return key; }, showToast: function (msg) { console.log(msg); }, showConfirm: null, // optional; falls back to window.confirm canGenerateImages: function () { return false; }, openImageGenerator: null, itemsPerPage: 25, }; // ============================================================ // STATE // ============================================================ var state = { data: [], filtered: [], selectedPath: null, selectedPaths: [], revealSelection: false, multiplePicker: false, callback: null, isPicker: false, allowedTypes: ['image'], activeType: 'image', view: 'grid', mode: 'library', sort: { field: 'date', dir: 'desc' }, search: '', folders: [], folderFilter: 'all', expandedFolders: {}, lightboxItem: null, page: 1, }; var replaceTarget = null; var moveTarget = null; var renameTarget = null; var pendingRename = null; var aiLabelTarget = null; var previousFocus = null; // Upload limits from the server (list-media): what PHP lets through and what Raiju allows var uploadLimits = null; var mediaTypes = { image: { labelKey: 'media.type_images', uploadLabelKey: 'image.upload', formatsKey: 'image.formats_hint', accept: '.jpg,.jpeg,.png,.webp,.gif,.svg,image/*', extensions: ['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg'], maxSize: 5 * 1024 * 1024, }, audio: { labelKey: 'media.type_audio', uploadLabelKey: 'audio.upload', formatsKey: 'audio.formats_hint', accept: '.mp3,.wav,.ogg,.m4a,.aac,.flac,audio/*', extensions: ['mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac'], maxSize: 50 * 1024 * 1024, }, video: { labelKey: 'media.type_video', uploadLabelKey: 'media.upload', formatsKey: 'media.video_formats_hint', accept: '.mp4,.webm,.mov,.m4v,video/*', extensions: ['mp4', 'webm', 'mov', 'm4v'], maxSize: 250 * 1024 * 1024, }, document: { labelKey: 'media.type_documents', uploadLabelKey: 'media.upload', formatsKey: 'media.document_formats_hint', accept: '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.rtf,application/pdf,text/plain', extensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf'], maxSize: 50 * 1024 * 1024, }, }; // ============================================================ // SVG ICONS // ============================================================ var Icons = { upload: '', grid: '', list: '', eye: '', download: '', ai: '', replace: '', delete: '', copy: '', restore: '', folder: '', folderPlus: '', layers: '', move: '', rename: '', chevronRight: '', chevronLeft: '', }; // ============================================================ // HELPERS // ============================================================ function escapeHtml(str) { var div = document.createElement('div'); div.textContent = str == null ? '' : String(str); return div.innerHTML; } function t(key, params) { var s = config.t(key, params); if (params && typeof s === 'string') { for (var k in params) { s = s.replace('{' + k + '}', params[k]); } } return s; } function clampItemsPerPage(value) { var parsed = parseInt(value, 10); if (!Number.isFinite(parsed)) return 25; return Math.max(10, Math.min(500, parsed)); } function getItemsPerPage() { return clampItemsPerPage(config.itemsPerPage); } function paginationIcon(direction) { return direction === 'prev' ? Icons.chevronLeft : Icons.chevronRight; } function mediaSummaryText(total, from, to) { if (total <= 0) return t('lists.summary_empty'); return t('lists.summary_range', { from: from, to: to, total: total }); } function confirmAction(title, message, onYes) { if (typeof config.showConfirm === 'function') { config.showConfirm(title, message, onYes); } else { showFallbackConfirm(title, message, onYes); } } function showFallbackConfirm(title, message, onYes) { var overlay = document.createElement('div'); overlay.className = 'raiju-dialog-overlay'; overlay.innerHTML = ''; overlay.querySelector('h3').textContent = title || 'Bestätigen'; overlay.querySelector('p').textContent = message || ''; var style = document.createElement('style'); style.textContent = '.raiju-dialog-overlay{position:fixed;inset:0;z-index:100000;display:flex;align-items:center;justify-content:center;background:rgba(17,24,39,.55);padding:20px;font-family:var(--rj-font-sans,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif)}.raiju-dialog{width:min(420px,100%);background:#fff;border-radius:10px;box-shadow:0 18px 60px rgba(15,23,42,.25);padding:22px}.raiju-dialog h3{margin:0 0 10px;font-family:inherit;font-size:20px;letter-spacing:normal}.raiju-dialog p{margin:0 0 18px;color:#374151;line-height:1.45}.raiju-dialog__actions{display:flex;gap:10px;justify-content:flex-end}.raiju-dialog__btn{border:1px solid #d1d5db;border-radius:6px;cursor:pointer;font:inherit;padding:9px 14px}.raiju-dialog__btn--primary{background:#5aa6a6;border-color:#5aa6a6;color:#fff}.raiju-dialog__btn--secondary{background:#fff;color:#111827}'; overlay.appendChild(style); function close() { overlay.remove(); } overlay.addEventListener('click', function(event) { var action = event.target && event.target.getAttribute('data-action'); if (action === 'cancel' || event.target === overlay) close(); if (action === 'confirm') { close(); onYes(); } }); document.body.appendChild(overlay); overlay.querySelector('[data-action="confirm"]').focus(); } function getActiveUploadType() { if (state.activeType && state.activeType !== 'all') return state.activeType; return state.allowedTypes[0] || 'image'; } function mediaTypeLabel(type) { return t((mediaTypes[type] || mediaTypes.image).labelKey); } function isImageOnlyPicker() { return state.allowedTypes.length === 1 && state.allowedTypes[0] === 'image'; } function normalizeTypes(types) { var input = Array.isArray(types) ? types : [types || 'image']; var normalized = input.filter(function (type) { return !!mediaTypes[type]; }); return normalized.length ? normalized : ['image']; } function isImage(item) { return (item.type || 'image') === 'image'; } function isAudio(item) { return item.type === 'audio'; } function isVideo(item) { return item.type === 'video'; } function isDocument(item) { return item.type === 'document'; } function mediaDisplayName(item) { return item.basename || item.name || item.path || t('media.file'); } function mediaDownloadUrl(item) { var path = item && item.path ? String(item.path) : ''; if (!path) return '#'; return path.charAt(0) === '/' ? '..' + path : path; } function mediaDownloadActionHtml(item) { if (!isImage(item)) return ''; // With a stored original the download hands out the original, not the web version if (item.original && item.original.path) { var label = t('media.download_original', { size: item.original.size || '' }); return '' + Icons.download + ''; } return '' + Icons.download + ''; } // "Original" when a web version replaced the upload, "large" for a file that is big for a page function sizeBadgeHtml(item) { if (!item || !isImage(item)) return ''; if (item.original) { // A button: it opens the original's details (view, download, copy path) var title = t('media.original_title', { size: item.original.size || '', width: item.original.width || '?', height: item.original.height || '?' }); return ''; } if (item.large) { return '' + escapeHtml(t('media.large_badge')) + ''; } return ''; } function canOpenImageGenerator() { try { return typeof config.openImageGenerator === 'function' && typeof config.canGenerateImages === 'function' && config.canGenerateImages() === true; } catch (e) { return false; } } function mediaNameHtml(item, className) { var folder = item.folder || ''; return '
' + '' + escapeHtml(mediaDisplayName(item)) + '' + sizeBadgeHtml(item) + (folder ? '' + escapeHtml(folder) + '' : '') + '
'; } function mediaIcon(item) { if (isAudio(item)) return ''; if (isVideo(item)) return ''; return ''; } function detectTransparency(image) { if (!image) return; var surface = image.closest('.rj-imgmgr-thumb-btn, .rj-imgmgr-lightbox-stage'); if (!surface) return; var source = (image.currentSrc || image.src || '').split(/[?#]/)[0].toLowerCase(); if (/\.jpe?g$/.test(source)) { surface.classList.remove('rj-imgmgr-has-transparency'); return; } try { var naturalWidth = image.naturalWidth || 1; var naturalHeight = image.naturalHeight || 1; var scale = Math.min(1, 256 / Math.max(naturalWidth, naturalHeight)); var canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(naturalWidth * scale)); canvas.height = Math.max(1, Math.round(naturalHeight * scale)); var context = canvas.getContext('2d', { willReadFrequently: true }); if (!context) return; context.drawImage(image, 0, 0, canvas.width, canvas.height); var pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; var hasTransparency = false; for (var i = 3; i < pixels.length; i += 4) { if (pixels[i] < 255) { hasTransparency = true; break; } } surface.classList.toggle('rj-imgmgr-has-transparency', hasTransparency); } catch (e) { // If a browser cannot inspect the bitmap, keep the neutral default. surface.classList.remove('rj-imgmgr-has-transparency'); } } // Versioned preview URL: a replaced file keeps its name, so without the // modification time the browser would show the cached old image function previewSrc(item) { var path = item && item.path ? String(item.path) : ''; if (!path || !item.modified) return path; return path + (path.indexOf('?') === -1 ? '?' : '&') + 'v=' + encodeURIComponent(String(item.modified)); } // AI disclosure label (EU AI Act, Art. 50) for images and videos function canAiLabel(item) { return !!item && (isImage(item) || item.type === 'video'); } function aiLabelText(kind) { return t(kind === 'modified' ? 'media.ai_modified' : 'media.ai_generated'); } function aiBadgeHtml(item) { if (!item || !item.aiLabel) return ''; return '' + escapeHtml(t('media.ai_badge')) + ''; } // Tell the page about label changes, so the inline editor updates labels next to images function announceAiLabel(type, name, label) { if (!name) return; document.dispatchEvent(new CustomEvent('raiju:ai-label-change', { detail: { type: type || 'image', name: name, label: label || '' } })); } function aiLabelActionHtml(item) { if (!canAiLabel(item)) return ''; var title = t('media.ai_label') + (item.aiLabel ? ': ' + aiLabelText(item.aiLabel) : ''); return ''; } function mediaThumbHtml(item, className, previewAction) { var actionAttr = previewAction ? ' data-action="preview"' : ''; var previewAttr = ' data-preview-path="' + escapeHtml(item.path || '') + '" data-preview-name="' + escapeHtml(mediaDisplayName(item)) + '" onclick="window.NbImageManager && NbImageManager.preview(this.dataset.previewPath, this.dataset.previewName); return false;"'; if (isImage(item)) { return ''; } return ''; } // ============================================================ // MODAL CREATION // ============================================================ function createModal() { if (document.getElementById('rj-imgmgr-modal')) return; var modal = document.createElement('div'); modal.id = 'rj-imgmgr-modal'; modal.className = 'rj-imgmgr-modal'; modal.innerHTML = '' + ''; document.body.appendChild(modal); // Event wiring modal.querySelector('.rj-imgmgr-backdrop').addEventListener('click', close); modal.querySelector('.rj-imgmgr-close').addEventListener('click', close); modal.querySelector('[data-action="cancel"]').addEventListener('click', close); modal.querySelector('[data-action="confirm"]').addEventListener('click', confirmSelection); modal.querySelector('[data-action="empty-trash"]').addEventListener('click', emptyImageTrash); modal.querySelector('.rj-imgmgr-upload-input').addEventListener('change', handleUpload); modal.querySelector('.rj-imgmgr-ai-generator-btn').addEventListener('click', function () { if (!canOpenImageGenerator()) return; config.openImageGenerator('', 'auto'); }); modal.addEventListener('click', function (e) { var pageBtn = e.target.closest && e.target.closest('.rj-imgmgr-pagination-btn[data-page]'); if (pageBtn && modal.contains(pageBtn)) { e.preventDefault(); setMediaPage(pageBtn.dataset.page); return; } var thumb = e.target.closest && e.target.closest('.rj-imgmgr-thumb-btn'); if (!thumb || !modal.contains(thumb)) return; var itemEl = thumb.closest('.rj-imgmgr-item, .rj-imgmgr-row'); var path = itemEl ? itemEl.dataset.path : ''; var image = state.data.find(function (entry) { return entry.path === path; }); if (!image) return; e.preventDefault(); e.stopPropagation(); openLightbox(image); }, true); modal.querySelector('.rj-imgmgr-folder-create').addEventListener('click', createMediaFolder); var folderListEl = modal.querySelector('.rj-imgmgr-folder-list'); folderListEl.addEventListener('click', function(e) { var entry = e.target.closest('.rj-imgmgr-folder-item'); if (!entry || !modal.contains(entry)) return; var deleteBtn = e.target.closest('.rj-imgmgr-folder-item-delete'); if (deleteBtn) { e.stopPropagation(); deleteMediaFolder(entry.getAttribute('data-folder') || ''); return; } // Root folder uses an empty-string value, which is falsy — read the // attribute directly so "Main folder" is not coerced to "all". state.folderFilter = entry.getAttribute('data-folder') || ''; state.page = 1; filterAndRender(); updateFolderUI(); }); folderListEl.addEventListener('keydown', function(e) { if (e.key !== 'Enter' && e.key !== ' ') return; var entry = e.target.closest('.rj-imgmgr-folder-item'); if (!entry) return; e.preventDefault(); state.folderFilter = entry.getAttribute('data-folder') || ''; state.page = 1; filterAndRender(); updateFolderUI(); }); var dropzone = modal.querySelector('.rj-imgmgr-dropzone'); ['dragenter', 'dragover'].forEach(function (evt) { dropzone.addEventListener(evt, function (e) { e.preventDefault(); e.stopPropagation(); dropzone.classList.add('rj-imgmgr-dropzone--active'); }); }); ['dragleave', 'drop'].forEach(function (evt) { dropzone.addEventListener(evt, function (e) { e.preventDefault(); e.stopPropagation(); dropzone.classList.remove('rj-imgmgr-dropzone--active'); }); }); dropzone.addEventListener('drop', function (e) { var files = e.dataTransfer && e.dataTransfer.files; uploadFiles(files); }); modal.querySelector('.rj-imgmgr-search').addEventListener('input', function (e) { state.search = e.target.value.toLowerCase().trim(); state.page = 1; filterAndRender(); }); modal.querySelectorAll('.rj-imgmgr-view-btn').forEach(function (btn) { btn.addEventListener('click', function () { switchView(btn.dataset.view); }); }); modal.querySelectorAll('.rj-imgmgr-mode-btn').forEach(function (btn) { btn.addEventListener('click', function () { switchMode(btn.dataset.mode); }); }); modal.querySelector('.rj-imgmgr-upload-type').addEventListener('change', function (e) { state.activeType = e.target.value; state.page = 1; updateTypeUI(); loadImages(); }); modal.querySelectorAll('.rj-imgmgr-list-header-col.sortable').forEach(function (col) { col.addEventListener('click', function () { sort(col.dataset.sort); }); }); // ESC key closes the top-most dialog (replace first, then manager itself). // Uses capture phase to intercept before any outer modal's keydown handler runs. document.addEventListener('keydown', function (e) { if (e.key !== 'Escape') return; var replaceDialog = document.getElementById('rj-imgmgr-replace'); var moveDialog = document.getElementById('rj-imgmgr-move'); var renameDialog = document.getElementById('rj-imgmgr-rename'); var aiLabelDialog = document.getElementById('rj-imgmgr-ai-label'); var lightbox = document.getElementById('rj-imgmgr-lightbox'); if (replaceDialog && replaceDialog.classList.contains('active')) { e.stopPropagation(); e.preventDefault(); closeReplaceDialog(); } else if (moveDialog && moveDialog.classList.contains('active')) { e.stopPropagation(); e.preventDefault(); closeMoveDialog(); } else if (renameDialog && renameDialog.classList.contains('active')) { e.stopPropagation(); e.preventDefault(); closeRenameDialog(); } else if (aiLabelDialog && aiLabelDialog.classList.contains('active')) { e.stopPropagation(); e.preventDefault(); closeAiLabelDialog(); } else if (lightbox && lightbox.classList.contains('active')) { e.stopPropagation(); e.preventDefault(); closeLightbox(); } else if (modal.classList.contains('active')) { e.stopPropagation(); e.preventDefault(); close(); } }, true); // capture phase createLightbox(); createReplaceDialog(); createMoveDialog(); createRenameDialog(); } // ============================================================ // OPEN / CLOSE // ============================================================ function open(callback, currentPath, options) { options = options || {}; createModal(); var modal = document.getElementById('rj-imgmgr-modal'); if (modal.parentElement !== document.body) { document.body.appendChild(modal); } modal.classList.remove('rj-imgmgr-modal--embedded'); state.callback = typeof callback === 'function' ? callback : null; state.isPicker = typeof callback === 'function'; state.multiplePicker = !!options.multiple; state.allowedTypes = normalizeTypes(options.types || (state.isPicker ? ['image'] : ['image', 'audio', 'video', 'document'])); state.activeType = options.type && state.allowedTypes.indexOf(options.type) !== -1 ? options.type : (state.allowedTypes.length > 1 && !state.isPicker ? 'all' : state.allowedTypes[0]); state.selectedPaths = state.multiplePicker && Array.isArray(currentPath) ? currentPath.map(normalizeImagePath).filter(Boolean) : []; state.selectedPath = state.multiplePicker ? null : normalizeImagePath(currentPath); state.revealSelection = !!(state.selectedPath || state.selectedPaths.length); state.search = ''; state.mode = 'library'; state.folderFilter = 'all'; state.page = 1; modal.querySelector('.rj-imgmgr-search').value = ''; renderTypeControls(); updateModeUI(); updateTypeUI(); loadImages(); updateSelectionUI(); modal.classList.add('active'); document.body.style.overflow = 'hidden'; previousFocus = document.activeElement; setTimeout(function () { var search = modal.querySelector('.rj-imgmgr-search'); if (search) search.focus(); }, 0); } function mount(container, options) { if (!container) return; options = options || {}; createModal(); var modal = document.getElementById('rj-imgmgr-modal'); if (modal.parentElement !== container) { container.appendChild(modal); } modal.classList.add('rj-imgmgr-modal--embedded'); state.callback = null; state.multiplePicker = false; state.isPicker = false; state.allowedTypes = normalizeTypes(options.types || ['image', 'audio', 'video', 'document']); state.activeType = options.type && state.allowedTypes.indexOf(options.type) !== -1 ? options.type : (state.allowedTypes.length > 1 ? 'all' : state.allowedTypes[0]); state.selectedPath = null; state.search = ''; state.mode = 'library'; state.folderFilter = 'all'; state.page = 1; modal.querySelector('.rj-imgmgr-search').value = ''; renderTypeControls(); updateModeUI(); updateTypeUI(); loadImages(); updateSelectionUI(); modal.classList.add('active'); } function normalizeImagePath(path) { if (!path) return null; path = String(path).trim(); if (!path) return null; // Strip leading "../" segments and ensure leading "/" so we match state.data paths. path = path.replace(/^(\.\.\/)+/, '/'); if (path.charAt(0) !== '/') path = '/' + path; return path; } function close() { var modal = document.getElementById('rj-imgmgr-modal'); if (modal) modal.classList.remove('active'); document.body.style.overflow = ''; state.callback = null; if (previousFocus && document.contains(previousFocus)) previousFocus.focus(); previousFocus = null; } function confirmSelection() { if (typeof state.callback === 'function') { if (state.multiplePicker && state.selectedPaths.length) { state.callback(state.selectedPaths.slice()); } else if (state.selectedPath) { state.callback(state.selectedPath); } } close(); } function renderTypeControls() { var modal = document.getElementById('rj-imgmgr-modal'); if (!modal) return; var typeToggle = modal.querySelector('.rj-imgmgr-type-toggle'); var uploadType = modal.querySelector('.rj-imgmgr-upload-type'); var types = state.allowedTypes.slice(); typeToggle.innerHTML = ''; if (types.length > 1 && !state.isPicker) { var allBtn = document.createElement('button'); allBtn.type = 'button'; allBtn.className = 'rj-imgmgr-type-btn'; allBtn.dataset.type = 'all'; allBtn.textContent = t('media.type_all'); typeToggle.appendChild(allBtn); } types.forEach(function (type) { var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'rj-imgmgr-type-btn'; btn.dataset.type = type; btn.textContent = mediaTypeLabel(type); typeToggle.appendChild(btn); }); typeToggle.querySelectorAll('.rj-imgmgr-type-btn').forEach(function (btn) { btn.addEventListener('click', function () { state.activeType = btn.dataset.type; state.page = 1; updateTypeUI(); loadImages(); }); }); uploadType.innerHTML = ''; types.forEach(function (type) { var option = document.createElement('option'); option.value = type; option.textContent = mediaTypeLabel(type); uploadType.appendChild(option); }); } function updateTypeUI() { var modal = document.getElementById('rj-imgmgr-modal'); if (!modal) return; var uploadType = modal.querySelector('.rj-imgmgr-upload-type'); var uploadInput = modal.querySelector('.rj-imgmgr-upload-input'); var uploadLabel = modal.querySelector('.rj-imgmgr-upload-btn span'); var confirmBtn = modal.querySelector('[data-action="confirm"]'); var formats = modal.querySelector('.rj-imgmgr-formats'); var uploadTypeValue = getActiveUploadType(); var typeConfig = mediaTypes[uploadTypeValue] || mediaTypes.image; modal.querySelectorAll('.rj-imgmgr-type-btn').forEach(function (btn) { btn.classList.toggle('rj-imgmgr-type-btn--active', btn.dataset.type === state.activeType); }); uploadType.hidden = state.allowedTypes.length <= 1; uploadType.value = uploadTypeValue; uploadInput.accept = typeConfig.accept; uploadLabel.textContent = t(typeConfig.uploadLabelKey); if (confirmBtn) { confirmBtn.textContent = t(uploadTypeValue === 'image' ? 'image.select' : (uploadTypeValue === 'audio' ? 'audio.select' : 'media.select_file')); } formats.textContent = t(typeConfig.formatsKey); updateLimitHint(); var title = modal.querySelector('.rj-imgmgr-header h3'); if (title) title.textContent = t(isImageOnlyPicker() ? 'image_manager' : 'media_manager'); updateGeneratorButton(); updateFolderUI(); } function megabytes(bytes) { var mb = bytes / 1048576; return mb >= 1 && Math.abs(mb - Math.round(mb)) < 0.05 ? String(Math.round(mb)) : String(Math.round(mb * 10) / 10); } // "37 KB" below a megabyte, "1.4 MB" above function fileSize(bytes) { return bytes < 1048576 ? Math.max(1, Math.round(bytes / 1024)) + ' KB' : megabytes(bytes) + ' MB'; } // The limit that applies to the chosen type: Raiju's, or the server's when that is lower function effectiveLimit(type) { var typeConfig = mediaTypes[type] || mediaTypes.image; var serverMax = uploadLimits && uploadLimits.serverMaxBytes ? uploadLimits.serverMaxBytes : null; return { max: typeConfig.maxSize, serverMax: serverMax, serverBound: serverMax !== null && serverMax < typeConfig.maxSize }; } function updateLimitHint() { var modal = document.getElementById('rj-imgmgr-modal'); if (!modal) return; var formats = modal.querySelector('.rj-imgmgr-formats'); if (!formats) return; var type = getActiveUploadType(); var limit = effectiveLimit(type); var text = t((mediaTypes[type] || mediaTypes.image).formatsKey) + ' · ' + t('media.upload_limit', { max: megabytes(limit.serverBound ? limit.serverMax : limit.max) }); if (limit.serverBound) text += ' (' + t('media.server_limit', { max: megabytes(limit.serverMax) }) + ')'; formats.textContent = text; formats.classList.toggle('rj-imgmgr-formats--server-bound', limit.serverBound); } function updateGeneratorButton() { var modal = document.getElementById('rj-imgmgr-modal'); if (!modal) return; var button = modal.querySelector('.rj-imgmgr-ai-generator-btn'); var dropzone = modal.querySelector('.rj-imgmgr-dropzone'); if (!button || !dropzone) return; var visible = !state.isPicker && state.mode !== 'trash' && getActiveUploadType() === 'image' && canOpenImageGenerator(); button.hidden = !visible; dropzone.classList.toggle('rj-imgmgr-dropzone--with-ai', visible); } function folderItemCount(folderValue) { return state.data.reduce(function(total, item) { return total + ((item.folder || '') === folderValue ? 1 : 0); }, 0); } function updateFolderUI() { var modal = document.getElementById('rj-imgmgr-modal'); if (!modal) return; var sidebar = modal.querySelector('.rj-imgmgr-sidebar'); var list = modal.querySelector('.rj-imgmgr-folder-list'); if (!sidebar || !list) return; // The folder sidebar is a library-only concept; hide it in trash mode. var isLibrary = state.mode === 'library'; sidebar.hidden = !isLibrary; if (!isLibrary) return; var current = state.folderFilter || 'all'; if (current !== 'all' && current !== '' && state.folders.indexOf(current) === -1) { current = 'all'; state.folderFilter = current; } var entries = [ { value: 'all', label: t('media.folder_all'), icon: Icons.layers || Icons.folder, deletable: false }, { value: '', label: t('media.folder_root'), icon: Icons.folder, deletable: false } ].concat(state.folders.map(function(folder) { return { value: folder, label: folder, icon: Icons.folder, deletable: true }; })); list.innerHTML = entries.map(function(entry) { var active = entry.value === current; var count = entry.value === 'all' ? state.data.length : folderItemCount(entry.value); return '
' + '' + (entry.icon || '') + '' + '' + escapeHtml(entry.label) + '' + '' + count + '' + (entry.deletable ? '' : '') + '
'; }).join(''); } // ============================================================ // LOAD / RENDER // ============================================================ function loadImages() { var modal = document.getElementById('rj-imgmgr-modal'); var gridEl = modal.querySelector('.rj-imgmgr-grid'); var listBody = modal.querySelector('.rj-imgmgr-list-body'); var typeParam = state.activeType === 'all' ? state.allowedTypes.join(',') : state.activeType; var url = config.apiUrl + '?action=list-media&type=' + encodeURIComponent(typeParam) + '&trash=' + (state.mode === 'trash' ? '1' : '0') + '&_=' + Date.now(); gridEl.innerHTML = '

' + escapeHtml(t('image.loading')) + '

'; listBody.innerHTML = ''; fetch(url) .then(function (r) { return r.json(); }) .then(function (result) { var items = result.success && result.data ? (Array.isArray(result.data) ? result.data : (result.data.items || [])) : []; state.folders = result.success && result.data && Array.isArray(result.data.folders) ? result.data.folders : []; if (result.success && result.data && result.data.limits) { uploadLimits = result.data.limits; if (uploadLimits.imageMaxBytes) mediaTypes.image.maxSize = uploadLimits.imageMaxBytes; } if (result.success && result.data && Array.isArray(result.data.types)) { result.data.types.forEach(function (typeInfo) { if (mediaTypes[typeInfo.type] && typeInfo.maxSize) mediaTypes[typeInfo.type].maxSize = typeInfo.maxSize; }); } updateLimitHint(); if (items.length > 0) { state.data = items.map(function (img) { return Object.assign({}, img, { type: img.type || 'image', folder: img.folder || '', basename: img.basename || img.name || '', path: img.path.indexOf('api.php?') === 0 ? img.path : img.path.replace(/^\.\.\//, '/') }); }); updateFolderUI(); updateSortHeaderClasses(); applySortOrder(); filterAndRender(); if (state.revealSelection) revealSelection(); } else { state.data = []; state.filtered = []; state.page = 1; updateFolderUI(); renderPagination(); var emptyKey = state.mode === 'trash' ? (isImageOnlyPicker() ? 'image.trash_empty' : 'media.trash_empty') : 'media.no_files'; var empty = '

' + escapeHtml(t(emptyKey)) + '

'; gridEl.innerHTML = empty; listBody.innerHTML = empty; } }) .catch(function (err) { gridEl.innerHTML = '

' + escapeHtml(err.message) + '

'; }); } function updateSortHeaderClasses() { var modal = document.getElementById('rj-imgmgr-modal'); if (!modal) return; modal.querySelectorAll('.rj-imgmgr-list-header-col.sortable').forEach(function (col) { col.classList.remove('sorted-asc', 'sorted-desc'); if (col.dataset.sort === state.sort.field) { col.classList.add(state.sort.dir === 'asc' ? 'sorted-asc' : 'sorted-desc'); } }); } function applySortOrder() { state.data.sort(function (a, b) { var va, vb; switch (state.sort.field) { case 'name': va = (a.name || '').toLowerCase(); vb = (b.name || '').toLowerCase(); break; case 'size': va = a.sizeBytes || 0; vb = b.sizeBytes || 0; break; case 'date': va = a.modified || 0; vb = b.modified || 0; break; default: return 0; } if (va < vb) return state.sort.dir === 'asc' ? -1 : 1; if (va > vb) return state.sort.dir === 'asc' ? 1 : -1; return 0; }); } function filterAndRender() { state.filtered = state.data.filter(function (img) { var folder = img.folder || ''; var matchesFolder = state.folderFilter === 'all' || folder === state.folderFilter; var matchesSearch = !state.search || ((img.name || '') + ' ' + (img.basename || '') + ' ' + folder).toLowerCase().indexOf(state.search) !== -1; return matchesFolder && matchesSearch; }); var pageSize = getItemsPerPage(); var pages = Math.max(1, Math.ceil(state.filtered.length / pageSize)); state.page = Math.max(1, Math.min(state.page || 1, pages)); render(); } // Picker opened with the current file: show the page that holds it and scroll it into view (once per open) function revealSelection() { state.revealSelection = false; var path = state.multiplePicker ? state.selectedPaths[0] : state.selectedPath; var index = path ? state.filtered.findIndex(function (entry) { return entry.path === path; }) : -1; if (index === -1) return; var page = Math.floor(index / getItemsPerPage()) + 1; if (page !== state.page) { state.page = page; render(); } var modal = document.getElementById('rj-imgmgr-modal'); var selected = modal && modal.querySelector('.rj-imgmgr-item.selected, .rj-imgmgr-row.selected'); if (selected) selected.scrollIntoView({ block: 'nearest' }); } function render() { renderPagination(); if (state.view === 'grid') renderGrid(); else renderList(); } function currentPageItems() { var pageSize = getItemsPerPage(); var current = Math.max(1, state.page || 1); var start = (current - 1) * pageSize; return state.filtered.slice(start, start + pageSize); } function renderPagination() { var modal = document.getElementById('rj-imgmgr-modal'); if (!modal) return; var pageSize = getItemsPerPage(); var total = state.filtered.length; var pageCount = Math.max(1, Math.ceil(total / pageSize)); var current = Math.max(1, Math.min(state.page || 1, pageCount)); state.page = current; var from = total === 0 ? 0 : ((current - 1) * pageSize) + 1; var to = Math.min(total, current * pageSize); var summary = mediaSummaryText(total, from, to); var showPager = total > pageSize; var html = '
' + '' + escapeHtml(summary) + ''; if (showPager) { var nav = []; var addPage = function(page) { nav.push(''); }; nav.push(''); addPage(1); if (current > 3) nav.push('...'); for (var page = Math.max(2, current - 1); page <= Math.min(pageCount - 1, current + 1); page += 1) { addPage(page); } if (current < pageCount - 2) nav.push('...'); if (pageCount > 1) addPage(pageCount); nav.push(''); html += ''; } html += '
'; modal.querySelectorAll('.rj-imgmgr-pagination').forEach(function (footer) { var isTop = footer.classList.contains('rj-imgmgr-pagination--top'); footer.hidden = isTop && !showPager; footer.innerHTML = isTop && !showPager ? '' : html; }); } function setMediaPage(page) { var pageSize = getItemsPerPage(); var pages = Math.max(1, Math.ceil(state.filtered.length / pageSize)); state.page = Math.max(1, Math.min(parseInt(page, 10) || 1, pages)); render(); } function renderGrid() { var modal = document.getElementById('rj-imgmgr-modal'); var gridEl = modal.querySelector('.rj-imgmgr-grid'); if (state.filtered.length === 0) { var msg = state.search ? t('image.no_search_results', { term: state.search }) : t('media.no_files'); gridEl.innerHTML = '

' + escapeHtml(msg) + '

'; return; } gridEl.innerHTML = ''; currentPageItems().forEach(function (image) { var item = document.createElement('div'); var isSelected = state.isPicker && isSelectedPath(image.path); item.className = 'rj-imgmgr-item' + (isSelected ? ' selected' : ''); item.dataset.path = image.path; item.setAttribute('role', 'listitem'); item.setAttribute('aria-label', image.name || image.path || t('media.file')); item.innerHTML = state.mode === 'trash' ? mediaThumbHtml(image, 'rj-imgmgr-thumb', true) + mediaNameHtml(image, 'rj-imgmgr-name') + '
' + '' + '' + '' + '
' : mediaThumbHtml(image, 'rj-imgmgr-thumb', true) + mediaNameHtml(image, 'rj-imgmgr-name') + '
' + '' + mediaDownloadActionHtml(image) + '' + '' + '' + (isImage(image) ? '' : '') + aiLabelActionHtml(image) + '' + '
'; if (state.mode !== 'trash' && state.isPicker) { item.insertAdjacentHTML('afterbegin', '
'); } gridEl.appendChild(item); attachItemEvents(item, image); }); } function renderList() { var modal = document.getElementById('rj-imgmgr-modal'); var listBody = modal.querySelector('.rj-imgmgr-list-body'); if (state.filtered.length === 0) { var msg = state.search ? t('image.no_search_results', { term: state.search }) : t('media.no_files'); listBody.innerHTML = '

' + escapeHtml(msg) + '

'; return; } listBody.innerHTML = ''; currentPageItems().forEach(function (image) { appendMediaRow(listBody, image); }); } function appendMediaRow(listBody, image) { var row = document.createElement('div'); var isSelected = state.isPicker && isSelectedPath(image.path); row.className = 'rj-imgmgr-row' + (isSelected ? ' selected' : ''); if (image.folder) row.classList.add('rj-imgmgr-row--child'); row.dataset.path = image.path; row.setAttribute('aria-label', image.name || image.path || t('media.file')); row.innerHTML = state.mode === 'trash' ? '
' + mediaThumbHtml(image, 'rj-imgmgr-list-thumb', true) + mediaNameHtml(image, 'rj-imgmgr-list-name') + '
' + escapeHtml(image.size || '-') + '
' + '
' + escapeHtml(image.dateFormatted || '-') + '
' + '
' + '' + '' + '' + '
' : '
' + mediaThumbHtml(image, 'rj-imgmgr-list-thumb', true) + mediaNameHtml(image, 'rj-imgmgr-list-name') + '
' + escapeHtml(image.size || '-') + '
' + '
' + escapeHtml(image.dateFormatted || '-') + '
' + '
' + '' + mediaDownloadActionHtml(image) + '' + '' + '' + (isImage(image) ? '' : '') + aiLabelActionHtml(image) + '' + '
'; if (state.mode !== 'trash' && state.isPicker) { row.querySelector('div:first-child').outerHTML = '
'; } listBody.appendChild(row); attachItemEvents(row, image); } function attachItemEvents(element, image) { var check = element.querySelector('.rj-imgmgr-check'); if (check) { check.addEventListener('click', function (e) { e.stopPropagation(); toggleSelection(image.path); }); } var previewThumb = element.querySelector('.rj-imgmgr-thumb-btn'); if (previewThumb) { previewThumb.addEventListener('click', function (e) { e.stopPropagation(); openLightbox(image); }); } element.addEventListener('click', function (e) { var actionEl = e.target.closest('[data-action]'); if (actionEl) { e.stopPropagation(); var action = actionEl.dataset.action; if (action === 'preview') openLightbox(image); else if (action === 'download') return; else if (action === 'copy') copyPath(image.path); else if (action === 'move') openMoveDialog(image); else if (action === 'rename') openRenameDialog(image); else if (action === 'ai-label') openAiLabelDialog(image); else if (action === 'original') openOriginalDialog(image); else if (action === 'replace') openReplaceDialog(image.name, image.path); else if (action === 'delete') deleteMedia(image); else if (action === 'restore') restoreMedia(image); else if (action === 'delete-permanent') deleteMediaPermanently(image); return; } // Click elsewhere on item = toggle selection if (state.mode !== 'trash' && state.isPicker) toggleSelection(image.path); }); } function isSelectedPath(path) { return state.multiplePicker ? state.selectedPaths.indexOf(path) !== -1 : state.selectedPath === path; } function toggleSelection(path) { if (state.multiplePicker) { var idx = state.selectedPaths.indexOf(path); if (idx === -1) { state.selectedPaths.push(path); } else { state.selectedPaths.splice(idx, 1); } } else { state.selectedPath = state.selectedPath === path ? null : path; } updateSelectionUI(); } function updateSelectionUI() { var modal = document.getElementById('rj-imgmgr-modal'); if (!modal) return; modal.querySelectorAll('.rj-imgmgr-item, .rj-imgmgr-row').forEach(function (item) { var path = item.dataset.path; var isSelected = isSelectedPath(path); item.classList.toggle('selected', isSelected); var check = item.querySelector('.rj-imgmgr-check'); if (check) check.classList.toggle('checked', isSelected); }); var info = modal.querySelector('.rj-imgmgr-selection-info'); var confirmBtn = modal.querySelector('[data-action="confirm"]'); if (!state.isPicker) { info.textContent = ''; info.classList.remove('has-selection'); confirmBtn.hidden = true; confirmBtn.disabled = true; return; } if (state.mode === 'trash') { info.textContent = t('image.trash_hint'); info.classList.remove('has-selection'); confirmBtn.hidden = true; confirmBtn.disabled = true; return; } confirmBtn.hidden = false; var selectedCount = state.multiplePicker ? state.selectedPaths.length : (state.selectedPath ? 1 : 0); if (selectedCount) { info.textContent = state.multiplePicker ? t('media.selection_count', { count: selectedCount }) : state.selectedPath; info.classList.add('has-selection'); confirmBtn.disabled = false; } else { info.textContent = t(isImageOnlyPicker() ? 'image.no_selection' : 'media.no_selection'); info.classList.remove('has-selection'); confirmBtn.disabled = true; } } function updateModeUI() { var modal = document.getElementById('rj-imgmgr-modal'); if (!modal) return; modal.querySelectorAll('.rj-imgmgr-mode-btn').forEach(function (btn) { btn.classList.toggle('rj-imgmgr-mode-btn--active', btn.dataset.mode === state.mode); btn.setAttribute('aria-pressed', btn.dataset.mode === state.mode ? 'true' : 'false'); }); var dropzone = modal.querySelector('.rj-imgmgr-dropzone'); var emptyBtn = modal.querySelector('.rj-imgmgr-empty-trash-btn'); if (dropzone) dropzone.hidden = state.mode === 'trash'; if (emptyBtn) emptyBtn.hidden = state.mode !== 'trash'; updateGeneratorButton(); if (state.mode === 'trash') { state.selectedPath = null; state.folderFilter = 'all'; } updateFolderUI(); updateSelectionUI(); } // ============================================================ // VIEW / SORT // ============================================================ function switchMode(mode) { if (mode !== 'library' && mode !== 'trash') return; if (state.mode === mode) return; state.mode = mode; state.search = ''; state.page = 1; var modal = document.getElementById('rj-imgmgr-modal'); modal.querySelector('.rj-imgmgr-search').value = ''; updateModeUI(); loadImages(); } function switchView(view) { state.view = view; var modal = document.getElementById('rj-imgmgr-modal'); modal.querySelectorAll('.rj-imgmgr-view-btn').forEach(function (btn) { btn.classList.toggle('rj-imgmgr-view-btn--active', btn.dataset.view === view); btn.setAttribute('aria-pressed', btn.dataset.view === view ? 'true' : 'false'); }); var gridEl = modal.querySelector('.rj-imgmgr-grid'); var listEl = modal.querySelector('.rj-imgmgr-list'); if (view === 'grid') { gridEl.classList.remove('hidden'); listEl.classList.remove('active'); } else { gridEl.classList.add('hidden'); listEl.classList.add('active'); } render(); } function sort(field) { if (state.sort.field === field) { state.sort.dir = state.sort.dir === 'asc' ? 'desc' : 'asc'; } else { state.sort.field = field; state.sort.dir = 'asc'; } updateSortHeaderClasses(); applySortOrder(); state.page = 1; filterAndRender(); } // ============================================================ // ACTIONS // ============================================================ function copyPath(path) { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(path).then(function () { config.showToast(t('toast.copied'), 'success'); }).catch(function () { fallbackCopy(path); }); } else { fallbackCopy(path); } } function fallbackCopy(text) { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); config.showToast(t('toast.copied'), 'success'); } catch (e) { config.showToast(t('toast.error'), 'error'); } document.body.removeChild(ta); } function deleteMedia(item) { if (!item || !item.name) return; var formData = new FormData(); formData.append('action', 'delete-media'); formData.append('type', item.type || 'image'); formData.append('filename', item.name); formData.append('csrf_token', config.csrfToken); fetch(config.apiUrl, { method: 'POST', body: formData }) .then(function (r) { return r.json(); }) .then(function (result) { if (result.success) { config.showToast(t('image.trashed'), 'success'); if (state.selectedPath === item.path || (state.selectedPath && state.selectedPath.indexOf('/' + item.name) !== -1)) { state.selectedPath = null; } state.selectedPaths = state.selectedPaths.filter(function(path) { return path !== item.path; }); loadImages(); } else { config.showToast(result.message || t('toast.error'), 'error'); } }) .catch(function (err) { config.showToast(err.message || t('toast.error'), 'error'); }); } function restoreMedia(item) { if (!item || !item.name) return; var formData = new FormData(); formData.append('action', 'restore-media'); formData.append('type', item.type || 'image'); formData.append('filename', item.name); formData.append('csrf_token', config.csrfToken); fetch(config.apiUrl, { method: 'POST', body: formData }) .then(function (r) { return r.json(); }) .then(function (result) { if (result.success) { config.showToast(t('image.restored'), 'success'); loadImages(); } else { config.showToast(result.message || t('toast.error'), 'error'); } }) .catch(function (err) { config.showToast(err.message || t('toast.error'), 'error'); }); } function deleteMediaPermanently(item) { if (!item || !item.name) return; confirmAction( t('image.delete_permanently'), t('image.delete_permanently_confirm', { filename: item.name }), function () { var formData = new FormData(); formData.append('action', 'delete-media-trash'); formData.append('type', item.type || 'image'); formData.append('filename', item.name); formData.append('csrf_token', config.csrfToken); fetch(config.apiUrl, { method: 'POST', body: formData }) .then(function (r) { return r.json(); }) .then(function (result) { if (result.success) { config.showToast(t('image.deleted_permanently'), 'success'); loadImages(); } else { config.showToast(result.message || t('toast.error'), 'error'); } }) .catch(function (err) { config.showToast(err.message || t('toast.error'), 'error'); }); } ); } // In-app name prompt: window.prompt() is unavailable in embedded // webviews (Electron/VS Code) and clashes with the UI style anyway. function promptFolderName(onSubmit) { var existing = document.getElementById('rj-imgmgr-folder-prompt'); if (existing) existing.remove(); var dialog = document.createElement('div'); dialog.id = 'rj-imgmgr-folder-prompt'; dialog.className = 'rj-imgmgr-move active'; dialog.innerHTML = '
' + '
' + '
' + '

' + escapeHtml(t('media.folder_create')) + '

' + '' + '
' + '
' + '' + '' + '
' + '' + '
'; document.body.appendChild(dialog); var input = dialog.querySelector('.rj-imgmgr-folder-name-input'); var close = function () { dialog.remove(); }; var submit = function () { var name = input.value.trim(); if (!name) { input.focus(); return; } close(); onSubmit(name); }; dialog.querySelector('.rj-imgmgr-move-backdrop').addEventListener('click', close); dialog.querySelector('.rj-imgmgr-close').addEventListener('click', close); dialog.querySelector('[data-action="cancel"]').addEventListener('click', close); dialog.querySelector('[data-action="submit"]').addEventListener('click', submit); input.addEventListener('keydown', function (event) { if (event.key === 'Enter') { event.preventDefault(); submit(); } if (event.key === 'Escape') { event.stopPropagation(); close(); } }); input.focus(); } function createMediaFolder() { if (state.mode !== 'library') return; promptFolderName(submitCreateMediaFolder); } function submitCreateMediaFolder(name) { var formData = new FormData(); formData.append('action', 'create-media-folder'); formData.append('type', getActiveUploadType()); formData.append('folder', name); formData.append('csrf_token', config.csrfToken); fetch(config.apiUrl, { method: 'POST', body: formData }) .then(function (r) { return r.json(); }) .then(function (result) { if (result.success) { config.showToast(t('media.folder_created'), 'success'); state.folderFilter = result.data && result.data.folder ? result.data.folder : name; state.expandedFolders[state.folderFilter] = true; loadImages(); } else { config.showToast(result.message || t('toast.error'), 'error'); } }) .catch(function (err) { config.showToast(err.message || t('toast.error'), 'error'); }); } function deleteMediaFolder(folder) { folder = (folder || '').trim(); if (state.mode !== 'library' || !folder || folder === 'all') return; confirmAction( t('media.folder_delete'), t('media.folder_delete_confirm', { folder: folder }), function () { var formData = new FormData(); formData.append('action', 'delete-media-folder'); formData.append('type', getActiveUploadType()); formData.append('folder', folder); formData.append('csrf_token', config.csrfToken); fetch(config.apiUrl, { method: 'POST', body: formData }) .then(function (r) { return r.json(); }) .then(function (result) { if (result.success) { config.showToast(t('media.folder_deleted'), 'success'); delete state.expandedFolders[folder]; if (state.folderFilter === folder) state.folderFilter = 'all'; loadImages(); } else { config.showToast(result.message || t('toast.error'), 'error'); } }) .catch(function (err) { config.showToast(err.message || t('toast.error'), 'error'); }); } ); } function moveMedia(item, target) { if (!item || !item.name) return; target = (target || '').trim(); if (target === (item.folder || '')) return; var formData = new FormData(); formData.append('action', 'move-media'); formData.append('type', item.type || 'image'); formData.append('filename', item.name); formData.append('folder', target); formData.append('csrf_token', config.csrfToken); fetch(config.apiUrl, { method: 'POST', body: formData }) .then(function (r) { return r.json(); }) .then(function (result) { if (result.success) { config.showToast(t('media.file_moved'), 'success'); closeMoveDialog(); if (target) state.expandedFolders[target] = true; loadImages(); } else { config.showToast(result.message || t('toast.error'), 'error'); } }) .catch(function (err) { config.showToast(err.message || t('toast.error'), 'error'); }); } function emptyImageTrash() { if (!state.data.length) { config.showToast(t(isImageOnlyPicker() ? 'image.trash_empty' : 'media.trash_empty'), 'info'); return; } confirmAction( t('image.empty_trash'), t('image.empty_trash_confirm'), function () { var formData = new FormData(); formData.append('action', 'empty-media-trash'); formData.append('type', state.activeType === 'all' ? state.allowedTypes.join(',') : state.activeType); formData.append('csrf_token', config.csrfToken); fetch(config.apiUrl, { method: 'POST', body: formData }) .then(function (r) { return r.json(); }) .then(function (result) { if (result.success) { config.showToast(t('image.trash_emptied'), 'success'); loadImages(); } else { config.showToast(result.message || t('toast.error'), 'error'); } }) .catch(function (err) { config.showToast(err.message || t('toast.error'), 'error'); }); } ); } function handleUpload(e) { uploadFiles(e.target.files); e.target.value = ''; } function uploadFiles(files) { var queue = Array.prototype.slice.call(files || []).filter(Boolean); if (queue.length === 0) return; if (queue.length === 1) { uploadFile(queue[0]); return; } var uploaded = 0; var total = queue.length; config.showToast(t('media.uploading_multiple'), 'info'); queue.reduce(function (chain, file) { return chain.then(function () { return uploadFile(file, { deferReload: true, quietProgress: true, quietSuccess: true }).then(function (success) { if (success) uploaded += 1; }); }); }, Promise.resolve()).then(function () { if (uploaded > 0) { config.showToast(t(uploaded === total ? 'media.uploaded_multiple' : 'media.upload_partial', { uploaded: uploaded, total: total, count: uploaded }), uploaded === total ? 'success' : 'info'); loadImages(); setTimeout(updateSelectionUI, 300); } }); } function uploadFile(file, options) { options = options || {}; if (!file) return Promise.resolve(false); var type = getActiveUploadType(); var typeConfig = mediaTypes[type] || mediaTypes.image; var ext = (file.name.split('.').pop() || '').toLowerCase(); if (typeConfig.extensions.indexOf(ext) === -1) { config.showToast(t(type === 'image' ? 'image.format_error' : 'media.format_error'), 'error'); return Promise.resolve(false); } var limit = effectiveLimit(type); if (limit.serverMax !== null && file.size > limit.serverMax && limit.serverBound) { config.showToast(t('media.server_limit_error', { name: file.name, max: megabytes(limit.serverMax) }), 'error'); return Promise.resolve(false); } if (file.size > typeConfig.maxSize) { config.showToast(t('media.size_limit_error', { name: file.name, max: megabytes(typeConfig.maxSize) }), 'error'); return Promise.resolve(false); } var formData = new FormData(); formData.append('action', 'upload-media'); formData.append('type', type); formData.append('file', file); if (state.mode === 'library' && state.folderFilter && state.folderFilter !== 'all') { formData.append('folder', state.folderFilter); } formData.append('csrf_token', config.csrfToken); if (!options.quietProgress) { config.showToast(t('image.uploading'), 'info'); } return fetch(config.apiUrl, { method: 'POST', body: formData }) .then(function (r) { return r.json(); }) .then(function (result) { if (result.success) { if (!options.quietSuccess) { config.showToast(t('image.uploaded'), 'success'); } if (result.data && result.data.webVersion) { var wv = result.data.webVersion; config.showToast(t('media.web_version_created', { width: wv.width, height: wv.height, size: fileSize(wv.bytes) }), 'info'); } if (result.data && result.data.aiLabel) { config.showToast(t('media.ai_detected'), 'info'); announceAiLabel(result.data.type || type, result.data.name, result.data.aiLabel); } state.sort = { field: 'date', dir: 'desc' }; if (state.isPicker) { state.activeType = type; } if (!options.deferReload) { loadImages(); } if (result.data && result.data.path) { var uploadedPath = result.data.path.replace(/^\.\.\//, '/'); if (state.multiplePicker) { if (state.selectedPaths.indexOf(uploadedPath) === -1) state.selectedPaths.push(uploadedPath); } else { state.selectedPath = uploadedPath; } if (!options.deferReload) { setTimeout(updateSelectionUI, 300); } } return true; } else { config.showToast(result.message || t('toast.error'), 'error'); return false; } }) .catch(function (err) { config.showToast(err.message || t('toast.error'), 'error'); return false; }); } // ============================================================ // LIGHTBOX // ============================================================ function createLightbox() { if (document.getElementById('rj-imgmgr-lightbox')) return; var lb = document.createElement('div'); lb.id = 'rj-imgmgr-lightbox'; lb.className = 'rj-imgmgr-lightbox'; lb.innerHTML = '
' + '' + '' + '' + '
' + '
' + '
'; document.body.appendChild(lb); lb.addEventListener('click', function (e) { if (e.target === lb) closeLightbox(); }); lb.querySelector('.rj-imgmgr-lightbox-close').addEventListener('click', closeLightbox); lb.querySelector('[data-action="lightbox-prev"]').addEventListener('click', function(e) { e.stopPropagation(); showAdjacentLightboxImage(-1); }); lb.querySelector('[data-action="lightbox-next"]').addEventListener('click', function(e) { e.stopPropagation(); showAdjacentLightboxImage(1); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && lb.classList.contains('active')) closeLightbox(); if (lb.classList.contains('active') && e.key === 'ArrowLeft') { e.preventDefault(); showAdjacentLightboxImage(-1); } if (lb.classList.contains('active') && e.key === 'ArrowRight') { e.preventDefault(); showAdjacentLightboxImage(1); } }); } function openLightbox(itemOrPath, name) { var item = typeof itemOrPath === 'object' ? itemOrPath : { type: 'image', path: itemOrPath, name: name || '' }; state.lightboxItem = item; var lb = document.getElementById('rj-imgmgr-lightbox'); if (!lb) { createLightbox(); lb = document.getElementById('rj-imgmgr-lightbox'); } var stage = lb.querySelector('.rj-imgmgr-lightbox-stage'); var label = mediaDisplayName(item); stage.classList.toggle('rj-imgmgr-lightbox-stage--image', isImage(item)); stage.classList.remove('rj-imgmgr-has-transparency'); if (isImage(item)) { stage.innerHTML = ''; } else if (isAudio(item)) { stage.innerHTML = '
' + mediaIcon(item) + '' + '
'; } else if (isVideo(item)) { stage.innerHTML = ''; } else { stage.innerHTML = '
' + mediaIcon(item) + '' + escapeHtml(t('media.open_file')) + '' + '
'; } lb.querySelector('.rj-imgmgr-lightbox-info').textContent = label; updateLightboxNav(); lb.classList.add('active'); } function lightboxImages() { var items = (state.filtered && state.filtered.length ? state.filtered : state.data).filter(isImage); return items.length ? items : (state.lightboxItem && isImage(state.lightboxItem) ? [state.lightboxItem] : []); } function updateLightboxNav() { var lb = document.getElementById('rj-imgmgr-lightbox'); if (!lb) return; var items = lightboxImages(); var canNavigate = items.length > 1 && state.lightboxItem && isImage(state.lightboxItem); lb.querySelectorAll('.rj-imgmgr-lightbox-nav').forEach(function(btn) { btn.hidden = !canNavigate; }); } function showAdjacentLightboxImage(direction) { var items = lightboxImages(); if (items.length <= 1 || !state.lightboxItem) return; var index = items.findIndex(function(item) { return item.path === state.lightboxItem.path; }); if (index === -1) index = 0; var nextIndex = (index + direction + items.length) % items.length; openLightbox(items[nextIndex]); } function closeLightbox() { var lb = document.getElementById('rj-imgmgr-lightbox'); if (lb) { lb.querySelectorAll('audio, video').forEach(function (media) { media.pause(); }); lb.classList.remove('active'); } state.lightboxItem = null; } // ============================================================ // REPLACE DIALOG // ============================================================ function createReplaceDialog() { if (document.getElementById('rj-imgmgr-replace')) return; var dialog = document.createElement('div'); dialog.id = 'rj-imgmgr-replace'; dialog.className = 'rj-imgmgr-replace'; dialog.innerHTML = '
' + '
' + '
' + '

' + escapeHtml(t('image.replace')) + '

' + '' + '
' + '
' + '

' + escapeHtml(t('image.replacing')) + '

' + '
' + '' + '' + '
' + '
' + '' + '
' + '
' + '
' + '' + '
'; document.body.appendChild(dialog); dialog.querySelector('.rj-imgmgr-replace-backdrop').addEventListener('click', closeReplaceDialog); dialog.querySelector('.rj-imgmgr-close').addEventListener('click', closeReplaceDialog); dialog.querySelector('[data-action="cancel"]').addEventListener('click', closeReplaceDialog); dialog.querySelector('[data-action="submit"]').addEventListener('click', handleReplaceSubmit); dialog.querySelectorAll('.rj-imgmgr-replace-option').forEach(function (opt) { opt.addEventListener('click', function () { dialog.querySelectorAll('.rj-imgmgr-replace-option').forEach(function (o) { o.classList.remove('selected'); }); opt.classList.add('selected'); opt.querySelector('input[type="radio"]').checked = true; }); }); var fileInput = dialog.querySelector('input[type="file"]'); fileInput.addEventListener('change', function (e) { var file = e.target.files[0]; var filenameEl = dialog.querySelector('.rj-imgmgr-replace-filename'); var submitBtn = dialog.querySelector('[data-action="submit"]'); if (file) { filenameEl.textContent = file.name; submitBtn.disabled = false; } else { filenameEl.textContent = ''; submitBtn.disabled = true; } }); } function openReplaceDialog(filename, filepath) { replaceTarget = { name: filename, path: filepath }; var dialog = document.getElementById('rj-imgmgr-replace'); if (!dialog) { createReplaceDialog(); dialog = document.getElementById('rj-imgmgr-replace'); } dialog.querySelector('.rj-imgmgr-replace-target').textContent = filename; dialog.querySelector('input[type="file"]').value = ''; dialog.querySelector('.rj-imgmgr-replace-filename').textContent = ''; dialog.querySelector('[data-action="submit"]').disabled = true; dialog.querySelectorAll('.rj-imgmgr-replace-option').forEach(function (o, i) { o.classList.toggle('selected', i === 0); }); dialog.querySelector('input[name="rj-replace-option"][value="replace"]').checked = true; dialog.classList.add('active'); } function closeReplaceDialog() { var dialog = document.getElementById('rj-imgmgr-replace'); if (dialog) dialog.classList.remove('active'); replaceTarget = null; } function handleReplaceSubmit() { var dialog = document.getElementById('rj-imgmgr-replace'); var fileInput = dialog.querySelector('input[type="file"]'); var file = fileInput.files[0]; if (!file || !replaceTarget) return; var allowed = ['image/jpeg', 'image/png', 'image/webp']; if (allowed.indexOf(file.type) === -1) { config.showToast(t('image.format_error'), 'error'); return; } if (file.size > mediaTypes.image.maxSize) { config.showToast(t('media.size_limit_error', { name: file.name, max: megabytes(mediaTypes.image.maxSize) }), 'error'); return; } var option = dialog.querySelector('input[name="rj-replace-option"]:checked').value; var targetFilename = replaceTarget.name; if (option === 'new') { targetFilename = file.name; var existing = state.data.find(function (img) { return (img.name || '').toLowerCase() === targetFilename.toLowerCase(); }); if (existing) { config.showToast(t('image.exists', { filename: targetFilename }), 'error'); return; } } var formData = new FormData(); formData.append('action', 'upload-image'); formData.append('image', file); formData.append('filename', targetFilename); formData.append('replace', option === 'replace' ? '1' : '0'); formData.append('csrf_token', config.csrfToken); config.showToast(t('image.uploading'), 'info'); fetch(config.apiUrl, { method: 'POST', body: formData }) .then(function (r) { return r.json(); }) .then(function (result) { if (result.success) { config.showToast(option === 'replace' ? t('image.replaced') : t('image.uploaded'), 'success'); if (result.data) announceAiLabel('image', result.data.name, result.data.aiLabel); closeReplaceDialog(); state.sort = { field: 'date', dir: 'desc' }; loadImages(); } else { config.showToast(result.message || t('toast.error'), 'error'); } }) .catch(function (err) { config.showToast(err.message || t('toast.error'), 'error'); }); } // ============================================================ // MOVE DIALOG // ============================================================ function createMoveDialog() { if (document.getElementById('rj-imgmgr-move')) return; var dialog = document.createElement('div'); dialog.id = 'rj-imgmgr-move'; dialog.className = 'rj-imgmgr-move'; dialog.innerHTML = '
' + '
' + '
' + '

' + escapeHtml(t('media.move_file')) + '

' + '' + '
' + '
' + '

' + '' + '' + '
' + '' + '
'; document.body.appendChild(dialog); dialog.querySelector('.rj-imgmgr-move-backdrop').addEventListener('click', closeMoveDialog); dialog.querySelector('.rj-imgmgr-close').addEventListener('click', closeMoveDialog); dialog.querySelector('[data-action="cancel"]').addEventListener('click', closeMoveDialog); dialog.querySelector('[data-action="submit"]').addEventListener('click', function () { var select = dialog.querySelector('.rj-imgmgr-move-select'); moveMedia(moveTarget, select ? select.value : ''); }); } function openMoveDialog(item) { moveTarget = item; var dialog = document.getElementById('rj-imgmgr-move'); if (!dialog) { createMoveDialog(); dialog = document.getElementById('rj-imgmgr-move'); } dialog.querySelector('.rj-imgmgr-move-filename').textContent = mediaDisplayName(item); var select = dialog.querySelector('.rj-imgmgr-move-select'); select.innerHTML = '' + state.folders.map(function(folder) { return ''; }).join(''); select.value = item.folder || ''; dialog.classList.add('active'); select.focus(); } function closeMoveDialog() { var dialog = document.getElementById('rj-imgmgr-move'); if (dialog) dialog.classList.remove('active'); moveTarget = null; } // ============================================================ // ORIGINAL DIALOG: where the uploaded file went, and what to do with it // ============================================================ var originalTarget = null; function createOriginalDialog() { if (document.getElementById('rj-imgmgr-original')) return; var dialog = document.createElement('div'); dialog.id = 'rj-imgmgr-original'; dialog.className = 'rj-imgmgr-move'; dialog.innerHTML = '
' + ''; document.body.appendChild(dialog); dialog.querySelector('.rj-imgmgr-move-backdrop').addEventListener('click', closeOriginalDialog); dialog.querySelector('.rj-imgmgr-close').addEventListener('click', closeOriginalDialog); dialog.querySelector('[data-action="view"]').addEventListener('click', function () { if (!originalTarget) return; var original = originalTarget.original; closeOriginalDialog(); openLightbox(mediaDownloadUrl(original), original.name ? original.name.split('/').pop() : mediaDisplayName(originalTarget)); }); dialog.querySelector('[data-action="copy"]').addEventListener('click', function () { if (originalTarget) copyPath(String(originalTarget.original.path || '').replace(/^\.\.\//, '/')); }); } function openOriginalDialog(item) { if (!item || !item.original) return; originalTarget = item; var dialog = document.getElementById('rj-imgmgr-original'); if (!dialog) { createOriginalDialog(); dialog = document.getElementById('rj-imgmgr-original'); } var original = item.original; var originalName = original.name ? original.name.split('/').pop() : mediaDisplayName(item); dialog.querySelector('.rj-imgmgr-move-filename').textContent = originalName; dialog.querySelector('.rj-imgmgr-original-info').textContent = t('media.original_info', { size: original.size || '', width: original.width || '?', height: original.height || '?', web: item.size || '' }); dialog.querySelector('.rj-imgmgr-original-path code').textContent = String(original.path || '').replace(/^\.\.\//, '/'); var link = dialog.querySelector('[data-action="download"]'); link.href = mediaDownloadUrl(original); link.setAttribute('download', originalName); dialog.classList.add('active'); dialog.querySelector('[data-action="download"]').focus(); } function closeOriginalDialog() { var dialog = document.getElementById('rj-imgmgr-original'); if (dialog) dialog.classList.remove('active'); originalTarget = null; } // ============================================================ // RENAME DIALOG // ============================================================ function createRenameDialog() { if (document.getElementById('rj-imgmgr-rename')) return; var dialog = document.createElement('div'); dialog.id = 'rj-imgmgr-rename'; dialog.className = 'rj-imgmgr-move'; dialog.innerHTML = '
' + '
' + '
' + '

' + escapeHtml(t('media.rename_file')) + '

' + '' + '
' + '
' + '

' + '' + '' + '' + '

' + escapeHtml(t('media.rename_extension_hint')) + '

' + '' + '
' + '' + '
'; document.body.appendChild(dialog); dialog.querySelector('.rj-imgmgr-move-backdrop').addEventListener('click', closeRenameDialog); dialog.querySelector('.rj-imgmgr-close').addEventListener('click', closeRenameDialog); dialog.querySelector('[data-action="cancel"]').addEventListener('click', closeRenameDialog); dialog.querySelector('[data-action="submit"]').addEventListener('click', function () { submitRename(false); }); dialog.querySelector('.rj-imgmgr-rename-input').addEventListener('keydown', function (event) { if (event.key === 'Enter') { event.preventDefault(); submitRename(false); } }); } function openRenameDialog(item) { if (!item || !item.name || state.mode !== 'library') return; renameTarget = item; pendingRename = null; var dialog = document.getElementById('rj-imgmgr-rename'); if (!dialog) { createRenameDialog(); dialog = document.getElementById('rj-imgmgr-rename'); } dialog.querySelector('.rj-imgmgr-move-filename').textContent = item.name; dialog.querySelector('.rj-imgmgr-rename-input').value = mediaDisplayName(item); dialog.querySelector('.rj-imgmgr-rename-scan').checked = true; var refs = dialog.querySelector('.rj-imgmgr-rename-references'); refs.hidden = true; refs.innerHTML = ''; dialog.classList.add('active'); dialog.querySelector('.rj-imgmgr-rename-input').focus(); dialog.querySelector('.rj-imgmgr-rename-input').select(); } function closeRenameDialog() { var dialog = document.getElementById('rj-imgmgr-rename'); if (dialog) dialog.classList.remove('active'); renameTarget = null; pendingRename = null; } // ============================================================ // AI LABEL DIALOG // ============================================================ function createAiLabelDialog() { if (document.getElementById('rj-imgmgr-ai-label')) return; var option = function (value, title, desc) { return ''; }; var dialog = document.createElement('div'); dialog.id = 'rj-imgmgr-ai-label'; dialog.className = 'rj-imgmgr-move'; dialog.innerHTML = '
' + ''; document.body.appendChild(dialog); dialog.querySelector('.rj-imgmgr-move-backdrop').addEventListener('click', closeAiLabelDialog); dialog.querySelector('.rj-imgmgr-close').addEventListener('click', closeAiLabelDialog); dialog.querySelector('[data-action="cancel"]').addEventListener('click', closeAiLabelDialog); dialog.querySelector('[data-action="submit"]').addEventListener('click', submitAiLabel); dialog.querySelectorAll('input[name="rj-imgmgr-ai-label"]').forEach(function (input) { input.addEventListener('change', function () { selectAiLabelOption(input.value); }); }); } function selectAiLabelOption(value) { var dialog = document.getElementById('rj-imgmgr-ai-label'); if (!dialog) return; dialog.querySelectorAll('.rj-imgmgr-replace-option').forEach(function (option) { var active = option.dataset.option === value; option.classList.toggle('selected', active); option.querySelector('input').checked = active; }); } function openAiLabelDialog(item) { if (!canAiLabel(item) || !item.name || state.mode !== 'library') return; aiLabelTarget = item; var dialog = document.getElementById('rj-imgmgr-ai-label'); if (!dialog) { createAiLabelDialog(); dialog = document.getElementById('rj-imgmgr-ai-label'); } dialog.querySelector('.rj-imgmgr-move-filename').textContent = item.name; selectAiLabelOption(item.aiLabel || ''); dialog.classList.add('active'); var checked = dialog.querySelector('input[name="rj-imgmgr-ai-label"]:checked'); if (checked) checked.focus(); } function closeAiLabelDialog() { var dialog = document.getElementById('rj-imgmgr-ai-label'); if (dialog) dialog.classList.remove('active'); aiLabelTarget = null; } function submitAiLabel() { if (!aiLabelTarget || !aiLabelTarget.name) return; var dialog = document.getElementById('rj-imgmgr-ai-label'); var checked = dialog.querySelector('input[name="rj-imgmgr-ai-label"]:checked'); var submit = dialog.querySelector('[data-action="submit"]'); var formData = new FormData(); formData.append('action', 'set-media-ai-label'); formData.append('type', aiLabelTarget.type || 'image'); formData.append('filename', aiLabelTarget.name); formData.append('label', checked ? checked.value : ''); formData.append('csrf_token', config.csrfToken); var target = aiLabelTarget; submit.disabled = true; fetch(config.apiUrl, { method: 'POST', body: formData }) .then(function (r) { return r.json(); }) .then(function (result) { if (result.success) { config.showToast(t('media.ai_saved'), 'success'); announceAiLabel(target.type || 'image', target.name, result.data ? result.data.aiLabel : (checked ? checked.value : '')); closeAiLabelDialog(); loadImages(); return; } config.showToast(result.message || t('toast.error'), 'error'); }) .catch(function (err) { config.showToast(err.message || t('toast.error'), 'error'); }) .finally(function () { submit.disabled = false; }); } function renderRenameReferences(references) { var dialog = document.getElementById('rj-imgmgr-rename'); if (!dialog) return; var box = dialog.querySelector('.rj-imgmgr-rename-references'); references = Array.isArray(references) ? references : []; if (!references.length) { box.hidden = true; box.innerHTML = ''; return; } var count = references.reduce(function(total, item) { return total + (parseInt(item.count || 0, 10) || 0); }, 0); box.hidden = false; box.innerHTML = '' + escapeHtml(t('media.rename_references_found', { count: count })) + '' + '' + '

' + escapeHtml(t('media.rename_references_confirm')) + '

' + ''; box.querySelector('[data-action="confirm-rename"]').addEventListener('click', function () { submitRename(true); }); } function submitRename(confirmReferences) { if (!renameTarget || !renameTarget.name) return; var dialog = document.getElementById('rj-imgmgr-rename'); var input = dialog.querySelector('.rj-imgmgr-rename-input'); var submit = dialog.querySelector('[data-action="submit"]'); var newName = (input.value || '').trim(); if (!newName) { input.focus(); return; } var formData = new FormData(); formData.append('action', 'rename-media'); formData.append('type', renameTarget.type || 'image'); formData.append('filename', renameTarget.name); formData.append('newName', newName); formData.append('scanReferences', dialog.querySelector('.rj-imgmgr-rename-scan').checked ? '1' : '0'); formData.append('confirmReferences', confirmReferences ? '1' : '0'); formData.append('csrf_token', config.csrfToken); submit.disabled = true; fetch(config.apiUrl, { method: 'POST', body: formData }) .then(function (r) { return r.json(); }) .then(function (result) { if (result.success) { var oldPath = renameTarget.path; config.showToast(t('media.file_renamed'), 'success'); if (state.selectedPath === renameTarget.path && result.data && result.data.path) { state.selectedPath = result.data.path.replace(/^\.\.\//, '/'); } state.selectedPaths = state.selectedPaths.map(function(path) { return path === oldPath && result.data && result.data.path ? result.data.path.replace(/^\.\.\//, '/') : path; }); closeRenameDialog(); loadImages(); return; } if (result.data && result.data.requiresConfirmation) { pendingRename = result.data; renderRenameReferences(result.data.references || []); return; } config.showToast(result.message || t('toast.error'), 'error'); }) .catch(function (err) { config.showToast(err.message || t('toast.error'), 'error'); }) .finally(function () { submit.disabled = false; }); } // ============================================================ // PUBLIC API // ============================================================ window.NbImageManager = { init: function (options) { Object.assign(config, options || {}); }, open: open, mount: mount, preview: function (path, name) { var item = state.data.find(function (entry) { return entry.path === path; }); openLightbox(item || path, name || path); }, detectTransparency: detectTransparency, close: close, confirmSelection: confirmSelection, refresh: function () { if (!document.getElementById('rj-imgmgr-modal')) return; updateModeUI(); updateTypeUI(); filterAndRender(); }, }; })();