diff --git a/public/media-library-ux.js b/public/media-library-ux.js new file mode 100644 index 0000000..5fb0063 --- /dev/null +++ b/public/media-library-ux.js @@ -0,0 +1,406 @@ +(() => { + 'use strict'; + + const DENSITY_KEY = 'vendoo_media_density_v1'; + const DENSITIES = new Set(['compact', 'comfortable', 'large']); + let activePath = ''; + let refreshFrame = 0; + let gridObserver = null; + let countObserver = null; + + function root() { + return document.getElementById('media-library'); + } + + function createElement(tag, className, text) { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; + } + + function queueRefresh() { + if (refreshFrame) return; + refreshFrame = requestAnimationFrame(() => { + refreshFrame = 0; + refreshPresentation(); + }); + } + + function savedDensity() { + const value = localStorage.getItem(DENSITY_KEY) || 'comfortable'; + return DENSITIES.has(value) ? value : 'comfortable'; + } + + function applyDensity(value) { + const density = DENSITIES.has(value) ? value : 'comfortable'; + const section = root(); + if (!section) return; + section.dataset.mediaDensity = density; + localStorage.setItem(DENSITY_KEY, density); + section.querySelectorAll('[data-media-density-option]').forEach(button => { + const active = button.dataset.mediaDensityOption === density; + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', String(active)); + }); + } + + function createCommandCenter() { + const section = root(); + const summary = section?.querySelector('.media-summary'); + if (!section || !summary || section.querySelector('.ux-media-command')) return; + + const command = createElement('section', 'ux-media-command'); + command.setAttribute('aria-label', 'Galerieansicht'); + const copy = createElement('div', 'ux-media-command-copy'); + const kicker = createElement('span', '', 'Arbeitsansicht'); + const title = createElement('strong', '', 'Bilder schnell finden, prüfen und weiterverwenden'); + const result = createElement('small', '', 'Die Galerie wird geladen …'); + result.id = 'ux-media-result-summary'; + copy.append(kicker, title, result); + + const density = createElement('div', 'ux-media-density'); + density.setAttribute('role', 'group'); + density.setAttribute('aria-label', 'Kartengröße'); + const options = [ + ['compact', 'Klein', 'Kompakte Karten'], + ['comfortable', 'Mittel', 'Normale Karten'], + ['large', 'Groß', 'Große Karten'], + ]; + for (const [value, label, ariaLabel] of options) { + const button = createElement('button', '', label); + button.type = 'button'; + button.dataset.mediaDensityOption = value; + button.setAttribute('aria-label', ariaLabel); + button.addEventListener('click', () => applyDensity(value)); + density.append(button); + } + command.append(copy, density); + summary.before(command); + applyDensity(savedDensity()); + } + + function wrapSelect(select, labelText) { + if (!select || select.closest('.ux-media-filter-field')) return null; + const label = createElement('label', 'ux-media-filter-field'); + label.append(createElement('span', '', labelText), select); + return label; + } + + function reorganizeToolbar() { + const toolbar = root()?.querySelector('.media-toolbar'); + if (!toolbar || toolbar.querySelector('.ux-media-filter-details')) return; + + const source = document.getElementById('media-source'); + const sort = document.getElementById('media-sort'); + const favorites = document.getElementById('media-favorites-only')?.closest('.media-check'); + const pageSize = document.getElementById('media-page-size'); + + const details = createElement('details', 'ux-media-filter-details'); + const summary = createElement('summary', '', 'Filter & Anzeige'); + const panel = createElement('div', 'ux-media-filter-panel'); + const sourceField = wrapSelect(source, 'Quelle'); + const sortField = wrapSelect(sort, 'Sortierung'); + const pageField = wrapSelect(pageSize, 'Bilder pro Seite'); + [sourceField, sortField, favorites, pageField].filter(Boolean).forEach(node => panel.append(node)); + details.append(summary, panel); + + const viewSwitch = toolbar.querySelector('.media-view-switch'); + if (viewSwitch) toolbar.insertBefore(details, viewSwitch); + else toolbar.append(details); + + document.addEventListener('click', event => { + if (!details.open || details.contains(event.target)) return; + details.open = false; + }); + } + + function createActiveFilters() { + const toolbar = root()?.querySelector('.media-toolbar'); + if (!toolbar || document.getElementById('ux-media-active-filters')) return; + const filters = createElement('div', 'ux-media-active-filters is-empty'); + filters.id = 'ux-media-active-filters'; + filters.setAttribute('aria-live', 'polite'); + toolbar.after(filters); + } + + function controlEvent(control) { + return control?.tagName === 'INPUT' && control.type === 'search' ? 'input' : 'change'; + } + + function resetControl(control, value) { + if (!control) return; + if (control.type === 'checkbox') control.checked = Boolean(value); + else control.value = value; + control.dispatchEvent(new Event(controlEvent(control), { bubbles: true })); + queueRefresh(); + } + + function filterChip(label, control, resetValue) { + const chip = createElement('span', 'ux-media-filter-chip'); + chip.append(createElement('span', '', label)); + const clear = createElement('button', '', '×'); + clear.type = 'button'; + clear.setAttribute('aria-label', `${label} entfernen`); + clear.addEventListener('click', () => resetControl(control, resetValue)); + chip.append(clear); + return chip; + } + + function selectedText(select) { + if (!select?.value) return ''; + return select.options[select.selectedIndex]?.textContent?.trim() || select.value; + } + + function updateActiveFilters() { + const container = document.getElementById('ux-media-active-filters'); + if (!container) return; + const search = document.getElementById('media-search'); + const source = document.getElementById('media-source'); + const favorites = document.getElementById('media-favorites-only'); + const chips = []; + + if (search?.value.trim()) chips.push(filterChip(`Suche: ${search.value.trim()}`, search, '')); + if (source?.value && source.value !== 'all') chips.push(filterChip(`Quelle: ${selectedText(source)}`, source, 'all')); + if (favorites?.checked) chips.push(filterChip('Nur Favoriten', favorites, false)); + + container.replaceChildren(); + container.classList.toggle('is-empty', chips.length === 0); + if (!chips.length) return; + container.append(createElement('strong', '', 'Aktive Filter'), ...chips); + const reset = createElement('button', 'ux-media-filter-reset', 'Alle entfernen'); + reset.type = 'button'; + reset.addEventListener('click', () => { + resetControl(search, ''); + resetControl(source, 'all'); + resetControl(favorites, false); + }); + container.append(reset); + } + + function enhanceBatchBar() { + const bar = root()?.querySelector('.media-batchbar'); + if (!bar || bar.querySelector('.ux-media-batch-primary')) return; + const actions = bar.querySelector(':scope > div'); + if (!actions) return; + const primary = createElement('div', 'ux-media-batch-primary'); + const more = createElement('details', 'ux-media-batch-more'); + const summary = createElement('summary', '', 'Weitere Aktionen'); + const secondary = createElement('div', 'ux-media-batch-secondary'); + + for (const id of ['media-send-to-factory', 'media-download-selected']) { + const button = document.getElementById(id); + if (button) primary.append(button); + } + const remove = document.getElementById('media-delete-selected'); + if (remove) secondary.append(remove); + more.append(summary, secondary); + actions.replaceChildren(primary, more); + } + + function selectedCount() { + const value = document.getElementById('media-selected-count')?.textContent || '0'; + return Number.parseInt(value, 10) || 0; + } + + function updateSelectionState() { + const section = root(); + if (!section) return; + const count = selectedCount(); + section.toggleAttribute('data-has-media-selection', count > 0); + const more = section.querySelector('.ux-media-batch-more'); + if (!count && more) more.open = false; + } + + function annotateCards() { + const cards = root()?.querySelectorAll('#media-grid .media-card') || []; + cards.forEach(card => { + const preview = card.querySelector('[data-media-preview]'); + const title = card.querySelector('.media-card-content > strong')?.textContent?.trim() || 'Bild'; + if (preview) { + preview.setAttribute('title', `${title} groß öffnen`); + preview.setAttribute('aria-label', `${title} in großer Vorschau öffnen`); + } + card.querySelectorAll('.media-action').forEach(action => { + const label = action.querySelector('.media-action-label')?.textContent?.trim(); + if (label && !action.getAttribute('aria-label')) action.setAttribute('aria-label', label); + }); + }); + } + + function updateResultSummary() { + const target = document.getElementById('ux-media-result-summary'); + if (!target) return; + const summary = root()?.querySelector('.media-pagination-summary')?.textContent?.trim(); + const view = document.getElementById('media-view-list')?.classList.contains('active') ? 'Listenansicht' : 'Rasteransicht'; + target.textContent = `${summary || '0 Bilder'} · ${view}`; + } + + function updatePaginationState() { + const section = root(); + if (!section) return; + const pageInfo = section.querySelector('[data-media-page-info]')?.textContent?.trim() || ''; + const onePage = /von\s+1\b/i.test(pageInfo); + section.querySelector('.media-pagination-top')?.classList.toggle('is-redundant', onePage); + } + + function previewButtons() { + return [...(root()?.querySelectorAll('[data-media-preview]') || [])]; + } + + function currentPreviewIndex() { + const buttons = previewButtons(); + return buttons.findIndex(button => button.dataset.mediaPreview === activePath); + } + + function navigatePreview(direction) { + const buttons = previewButtons(); + if (!buttons.length) return; + const current = currentPreviewIndex(); + const next = current < 0 ? 0 : (current + direction + buttons.length) % buttons.length; + activePath = buttons[next].dataset.mediaPreview || ''; + buttons[next].click(); + queueRefresh(); + } + + function updateLightboxNavigation() { + const modal = document.getElementById('media-lightbox'); + const buttons = previewButtons(); + const index = currentPreviewIndex(); + const position = document.getElementById('ux-media-lightbox-position'); + if (position) position.textContent = index >= 0 ? `${index + 1} von ${buttons.length}` : `${buttons.length} Bilder`; + modal?.querySelectorAll('[data-ux-media-preview-nav]').forEach(button => { + button.disabled = buttons.length < 2; + }); + } + + function activeCard() { + if (!activePath) return null; + return previewButtons().find(button => button.dataset.mediaPreview === activePath)?.closest('.media-card') || null; + } + + function enhanceLightbox() { + const modal = document.getElementById('media-lightbox'); + const dialog = modal?.querySelector('.media-lightbox-dialog'); + const header = dialog?.querySelector(':scope > header'); + const footer = dialog?.querySelector(':scope > footer'); + const body = dialog?.querySelector('.media-lightbox-body'); + const meta = document.getElementById('media-lightbox-meta'); + if (!modal || !dialog || !header || !footer || !body) return; + + if (!header.querySelector('.ux-media-lightbox-nav')) { + const nav = createElement('div', 'ux-media-lightbox-nav'); + const previous = createElement('button', 'icon-button', '‹'); + previous.type = 'button'; + previous.dataset.uxMediaPreviewNav = 'previous'; + previous.setAttribute('aria-label', 'Vorheriges Bild'); + previous.addEventListener('click', () => navigatePreview(-1)); + const position = createElement('span', 'ux-media-lightbox-position', '0 Bilder'); + position.id = 'ux-media-lightbox-position'; + const next = createElement('button', 'icon-button', '›'); + next.type = 'button'; + next.dataset.uxMediaPreviewNav = 'next'; + next.setAttribute('aria-label', 'Nächstes Bild'); + next.addEventListener('click', () => navigatePreview(1)); + nav.append(previous, position, next); + header.insertBefore(nav, header.querySelector('.media-lightbox-close')); + } + + if (meta && !meta.closest('.media-lightbox-meta-details')) { + const details = createElement('details', 'media-lightbox-meta-details'); + details.open = true; + details.append(createElement('summary', '', 'Bildinformationen anzeigen'), meta); + body.append(details); + } + + if (!document.getElementById('ux-media-lightbox-use')) { + const use = createElement('button', 'vd-button primary ux-media-use', 'Im Artikel verwenden'); + use.type = 'button'; + use.id = 'ux-media-lightbox-use'; + use.addEventListener('click', () => activeCard()?.querySelector('[data-media-use]')?.click()); + footer.prepend(use); + } + } + + function bindControls() { + const section = root(); + if (!section || section.dataset.uxMediaBound === 'true') return; + section.dataset.uxMediaBound = 'true'; + + for (const id of ['media-search', 'media-source', 'media-sort', 'media-favorites-only', 'media-page-size']) { + const control = document.getElementById(id); + control?.addEventListener(controlEvent(control), queueRefresh); + } + document.getElementById('media-view-grid')?.addEventListener('click', queueRefresh); + document.getElementById('media-view-list')?.addEventListener('click', queueRefresh); + + section.addEventListener('click', event => { + const preview = event.target.closest?.('[data-media-preview]'); + if (preview) { + activePath = preview.dataset.mediaPreview || ''; + queueRefresh(); + } + if (event.target.closest?.('[data-media-source], [data-media-select], [data-media-favorite]')) queueRefresh(); + }, true); + + document.addEventListener('keydown', event => { + const modal = document.getElementById('media-lightbox'); + if (!modal || modal.classList.contains('hidden')) return; + if (event.target.closest('input, textarea, select')) return; + if (event.key === 'ArrowLeft') { + event.preventDefault(); + navigatePreview(-1); + } + if (event.key === 'ArrowRight') { + event.preventDefault(); + navigatePreview(1); + } + }); + } + + function observeDynamicContent() { + const grid = document.getElementById('media-grid'); + const count = document.getElementById('media-selected-count'); + if (grid && !gridObserver) { + gridObserver = new MutationObserver(queueRefresh); + gridObserver.observe(grid, { childList: true, subtree: true }); + } + if (count && !countObserver) { + countObserver = new MutationObserver(queueRefresh); + countObserver.observe(count, { childList: true, characterData: true, subtree: true }); + } + window.addEventListener('beforeunload', () => { + gridObserver?.disconnect(); + countObserver?.disconnect(); + if (refreshFrame) cancelAnimationFrame(refreshFrame); + }, { once: true }); + } + + function refreshPresentation() { + updateActiveFilters(); + updateSelectionState(); + annotateCards(); + updateResultSummary(); + updatePaginationState(); + updateLightboxNavigation(); + } + + function initializeMediaUx() { + if (!root() || root().dataset.uxMediaReady === 'true') return; + root().dataset.uxMediaReady = 'true'; + createCommandCenter(); + reorganizeToolbar(); + createActiveFilters(); + enhanceBatchBar(); + enhanceLightbox(); + bindControls(); + observeDynamicContent(); + refreshPresentation(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeMediaUx, { once: true }); + } else { + initializeMediaUx(); + } +})();