Files
hd-commerce/public/media-picker.js
T
till 50dfca59e1 v2.4: Medienbibliothek+WebP, Varianten-Matrix, Litestream-Backups, intelligentere Analytics
P1 Medien: eigener Admin-Bereich /admin/medien (Grid, Mehrfach-Upload, Drag&Drop, Alt-Text, URL kopieren, Loeschen). Upload konvertiert JPG/PNG via sharp zu WebP (Qualitaet 82, max 2000px), Original wird verworfen; WebP/SVG/GIF/AVIF unveraendert; Konvertierungsfehler -> Original behalten statt 500. media um alt/width/height erweitert. Wiederverwendbarer Medien-Picker (public/media-picker.js) ersetzt den URL-Prompt im Block-Editor, Produkt-Editor (Karte/Galerie/Varianten-Bild), Slides und Popups. JSON-Quelle /api/admin/media (session-gesichert).

P2 Varianten: products.options_json + Tabelle product_variants. Produkt-Editor mit Options-Definition + Matrix-Generator (Preis-Override/Bestand/SKU/Bild/aktiv je Variante). PDP-Selektoren -> Variante; Cart/Checkout tragen sku+Options, Order-Item bekommt sku/variant, Variantenpreis serverseitig verifiziert. Produkte ohne Optionen unveraendert.

P3 Litestream: Binary im Dockerfile, docker-entrypoint.sh (Restore+replicate nur bei LITESTREAM_REPLICA_URL, sonst reiner Node-Start), litestream.yml, Backup-Status unter Einstellungen, README + .env.example.

P4 Analytics: Bestseller, Top-Suchbegriffe, Umsatz/Quelle, Umsatz-Zeitreihe, AOV, Wiederkaufrate, Lager-Warnungen.

Neue Dep sharp. +19 Unit-Tests (49 gesamt gruen), Build + Smoke (P1-P4) gruen.
2026-06-18 08:09:57 +00:00

149 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* hd-commerce — Wiederverwendbarer Medien-Picker (Admin).
API: window.HDCMedia.pick({ multiple, onPick(url|urls) }) öffnet ein Modal mit
Bibliothek (/api/admin/media), Inline-Upload (WebP) und Auswahl. */
(function () {
var overlay = null, listEl = null, state = { multiple: false, onPick: null, selected: [] };
function el(tag, cls, html) { var e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }
function build() {
overlay = el('div', 'mp-overlay');
overlay.innerHTML =
'<div class="mp-modal" role="dialog" aria-modal="true" aria-label="Medien auswählen">' +
'<div class="mp-head">' +
'<strong>Medien-Bibliothek</strong>' +
'<div class="mp-head-actions">' +
'<label class="mp-upbtn">+ Hochladen<input type="file" accept="image/*" multiple hidden></label>' +
'<button type="button" class="mp-close" aria-label="Schließen">✕</button>' +
'</div>' +
'</div>' +
'<div class="mp-drop" data-drop>Bilder hierher ziehen oder „Hochladen" — JPG/PNG werden automatisch zu WebP.</div>' +
'<div class="mp-msg" data-msg></div>' +
'<div class="mp-grid" data-grid><div class="mp-empty">Lädt …</div></div>' +
'<div class="mp-foot">' +
'<span class="mp-sel" data-sel></span>' +
'<div style="flex:1"></div>' +
'<button type="button" class="mp-btn mp-cancel">Abbrechen</button>' +
'<button type="button" class="mp-btn mp-primary mp-confirm">Auswählen</button>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
listEl = overlay.querySelector('[data-grid]');
overlay.querySelector('.mp-close').addEventListener('click', close);
overlay.querySelector('.mp-cancel').addEventListener('click', close);
overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); });
overlay.querySelector('.mp-confirm').addEventListener('click', confirmSel);
var fileInput = overlay.querySelector('.mp-upbtn input');
fileInput.addEventListener('change', function () { upload(fileInput.files); fileInput.value = ''; });
var drop = overlay.querySelector('[data-drop]');
['dragover', 'dragenter'].forEach(function (ev) { drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.add('over'); }); });
['dragleave', 'drop'].forEach(function (ev) { drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.remove('over'); }); });
drop.addEventListener('drop', function (e) { if (e.dataTransfer && e.dataTransfer.files) upload(e.dataTransfer.files); });
document.addEventListener('keydown', function (e) { if (overlay && overlay.classList.contains('open') && e.key === 'Escape') close(); });
}
function msg(text, kind) {
var m = overlay.querySelector('[data-msg]');
m.textContent = text || '';
m.className = 'mp-msg' + (kind ? ' ' + kind : '') + (text ? ' show' : '');
}
function load() {
listEl.innerHTML = '<div class="mp-empty">Lädt …</div>';
fetch('/api/admin/media', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (d) { renderGrid((d && d.media) || []); })
.catch(function () { listEl.innerHTML = '<div class="mp-empty">Konnte Medien nicht laden.</div>'; });
}
function renderGrid(media) {
if (!media.length) { listEl.innerHTML = '<div class="mp-empty">Noch keine Medien. Lade oben welche hoch.</div>'; return; }
listEl.innerHTML = '';
media.forEach(function (m) {
var card = el('div', 'mp-card');
if (state.selected.indexOf(m.url) > -1) card.classList.add('sel');
card.innerHTML =
'<div class="mp-thumb"><img src="' + m.url + '" alt="" loading="lazy"></div>' +
'<div class="mp-meta"><span class="mp-name" title="' + (m.filename || '') + '">' + (m.filename || '') + '</span>' +
'<span class="mp-size">' + Math.round((m.size || 0) / 1024) + ' KB' + (m.width ? ' · ' + m.width + '×' + m.height : '') + '</span></div>' +
'<div class="mp-card-acts">' +
'<button type="button" class="mp-ico" data-copy="' + m.url + '" title="URL kopieren">⧉</button>' +
'<button type="button" class="mp-ico mp-del" data-del="' + m.id + '" title="Löschen">🗑</button>' +
'</div>';
card.querySelector('.mp-thumb').addEventListener('click', function () { toggle(m.url, card); });
card.querySelector('[data-copy]').addEventListener('click', function (e) {
e.stopPropagation();
try { navigator.clipboard.writeText(location.origin + m.url); msg('URL kopiert.', 'ok'); } catch (x) {}
});
card.querySelector('[data-del]').addEventListener('click', function (e) {
e.stopPropagation();
if (!confirm('Dieses Medium löschen?')) return;
fetch('/api/admin/media?id=' + m.id, { method: 'DELETE' }).then(function (r) { return r.json(); })
.then(function (d) { if (d.ok) { state.selected = state.selected.filter(function (u) { return u !== m.url; }); load(); } else msg(d.error || 'Löschen fehlgeschlagen.', 'err'); });
});
listEl.appendChild(card);
});
updateSelCount();
}
function toggle(url, card) {
if (state.multiple) {
var i = state.selected.indexOf(url);
if (i > -1) { state.selected.splice(i, 1); card.classList.remove('sel'); }
else { state.selected.push(url); card.classList.add('sel'); }
} else {
state.selected = [url];
Array.prototype.forEach.call(listEl.querySelectorAll('.mp-card'), function (c) { c.classList.remove('sel'); });
card.classList.add('sel');
}
updateSelCount();
}
function updateSelCount() {
var s = overlay.querySelector('[data-sel]');
s.textContent = state.selected.length ? state.selected.length + ' ausgewählt' : '';
}
function upload(files) {
if (!files || !files.length) return;
var fd = new FormData();
Array.prototype.forEach.call(files, function (f) { fd.append('files', f); });
msg('Lädt ' + files.length + ' Datei(en) hoch …');
fetch('/api/upload', { method: 'POST', body: fd }).then(function (r) { return r.json(); })
.then(function (d) {
if (d && d.results) {
var conv = d.results.filter(function (r) { return r.converted; }).length;
msg('Hochgeladen' + (conv ? ' (' + conv + '× zu WebP konvertiert)' : '') + '.', 'ok');
// Neu hochgeladene direkt vorauswählen
d.results.forEach(function (r) { if (r.ok && r.url && state.selected.indexOf(r.url) < 0) { if (state.multiple) state.selected.push(r.url); else state.selected = [r.url]; } });
load();
} else { msg((d && d.error) || 'Upload fehlgeschlagen.', 'err'); }
})
.catch(function () { msg('Upload fehlgeschlagen.', 'err'); });
}
function confirmSel() {
if (!state.selected.length) { close(); return; }
var cb = state.onPick;
var sel = state.selected.slice();
close();
if (cb) cb(state.multiple ? sel : sel[0]);
}
function open() { if (!overlay) build(); overlay.classList.add('open'); msg(''); load(); }
function close() { if (overlay) overlay.classList.remove('open'); }
window.HDCMedia = {
pick: function (opts) {
opts = opts || {};
state.multiple = !!opts.multiple;
state.onPick = opts.onPick || null;
state.selected = [];
open();
}
};
})();