feat(ux): add media library workflow and preview navigation
This commit is contained in:
@@ -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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user