50dfca59e1
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.
149 lines
7.3 KiB
JavaScript
149 lines
7.3 KiB
JavaScript
/* 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();
|
||
}
|
||
};
|
||
})();
|