const CONTENT_IMAGES_LIST_ROLE = "content-images-list"; const CONTENT_IMAGES_DIALOG_ROLE = "content-images-dialog"; const CONTENT_IMAGES_CLOSE_ROLE = "content-images-close"; const CONTENT_IMAGES_FULL_ROLE = "content-images-full"; const CONTENT_IMAGES_DELETE_DIALOG_ROLE = "content-images-delete-dialog"; const CONTENT_IMAGES_DELETE_CONFIRM_ROLE = "content-images-delete-confirm"; const CONTENT_IMAGES_DELETE_CANCEL_ROLE = "content-images-delete-cancel"; const CONTENT_IMAGES_DELETE_NAME_ROLE = "content-images-delete-name"; const THUMB_PARAM = "300x0"; const FULL_PARAM = "0x1000"; const buildThumbUrl = (rawUrl, size) => { if (!rawUrl) { return ""; } if (rawUrl.includes("thumb=")) { return rawUrl; } const separator = rawUrl.includes("?") ? "&" : "?"; return `${rawUrl}${separator}thumb=${size}`; }; const buildFullUrl = (rawUrl) => { return buildThumbUrl(rawUrl, FULL_PARAM); }; const extractFileName = (rawUrl) => { if (!rawUrl) { return ""; } const cleanUrl = rawUrl.split("?")[0] || ""; const parts = cleanUrl.split("/"); return parts[parts.length - 1] || ""; }; const normalizeImages = (rawImages, rawFiles) => { const files = Array.isArray(rawFiles) ? rawFiles : []; return (Array.isArray(rawImages) ? rawImages : []).map((item, index) => { if (typeof item === "string") { const name = files[index] || extractFileName(item); return { url: item, name }; } if (item && typeof item === "object") { const url = item.url || ""; const name = item.name || files[index] || extractFileName(url); return { url, name }; } return { url: "", name: "" }; }); }; export class ContentImages extends HTMLElement { connectedCallback() { if (this.dataset.init === "true") { return; } this.dataset.init = "true"; this._pendingFiles = []; this._pendingUrls = []; this._pendingDeletes = new Set(); this._pendingIds = []; this._pendingIdCounter = 0; this._scanOrder = []; this._wireUpload(); const raw = this.getAttribute("data-images") || "[]"; const rawFiles = this.getAttribute("data-files") || "[]"; let images = []; let files = []; try { images = JSON.parse(raw); } catch { images = []; } try { files = JSON.parse(rawFiles); } catch { files = []; } const normalized = normalizeImages(images, files); this._render(normalized); } _wireUpload() { const panel = this.closest("[data-role='content-images-panel']"); if (!panel) { return; } const uploadInput = panel.querySelector("[data-role='content-images-upload-input']"); if (!uploadInput || uploadInput.dataset.bound === "true") { return; } uploadInput.dataset.bound = "true"; uploadInput.addEventListener("change", () => { this._setPendingFiles(Array.from(uploadInput.files || [])); }); } _setPendingFiles(files) { const incoming = Array.isArray(files) ? files : []; if (incoming.length === 0) { return; } if (!Array.isArray(this._pendingFiles)) { this._pendingFiles = []; } if (!Array.isArray(this._pendingUrls)) { this._pendingUrls = []; } if (!Array.isArray(this._pendingIds)) { this._pendingIds = []; } const newIds = []; incoming.forEach((file) => { this._pendingFiles.push(file); this._pendingUrls.push(URL.createObjectURL(file)); const id = `p${Date.now()}_${this._pendingIdCounter++}`; this._pendingIds.push(id); newIds.push(id); }); if (!Array.isArray(this._scanOrder)) { this._scanOrder = []; } this._scanOrder = this._scanOrder.concat(newIds.map((id) => `pending:${id}`)); this._render(this._currentImages || []); } _render(images) { this._currentImages = images; this.classList.add("block"); this.style.display = "block"; this.style.width = "100%"; const list = this._ensureList(); const uploadProxy = this._ensureUploadProxy(); if (uploadProxy && uploadProxy.parentElement === list) { uploadProxy.remove(); } list.querySelectorAll("[data-role='content-images-item'], [data-role='content-images-pending']").forEach((node) => { node.remove(); }); const deleteEndpoint = this.getAttribute("data-delete-endpoint") || ""; const contentId = this.getAttribute("data-content-id") || ""; const csrfToken = this.getAttribute("data-csrf-token") || ""; const canDelete = deleteEndpoint && contentId && csrfToken; const imagesByName = new Map(); images.forEach((image) => { if (image && image.name) { imagesByName.set(image.name, image); } }); if (!Array.isArray(this._scanOrder) || this._scanOrder.length === 0) { this._scanOrder = images.map((image) => `existing:${image.name}`); this._scanOrder = this._scanOrder.concat(this._pendingIds.map((id) => `pending:${id}`)); } const pendingById = new Map(); this._pendingIds.forEach((id, index) => { pendingById.set(id, { url: this._pendingUrls[index] }); }); const order = []; this._scanOrder.forEach((token) => { if (token.startsWith("existing:")) { const name = token.slice("existing:".length); if (imagesByName.has(name)) { order.push({ type: "existing", name, image: imagesByName.get(name) }); } return; } if (token.startsWith("pending:")) { const id = token.slice("pending:".length); if (pendingById.has(id)) { order.push({ type: "pending", id, url: pendingById.get(id).url }); } } }); order.forEach((item, index) => { if (item.type === "pending") { const wrapper = document.createElement("div"); wrapper.className = "group relative"; wrapper.dataset.role = "content-images-pending"; wrapper.dataset.scanKey = `pending:${item.id}`; wrapper.draggable = true; const button = document.createElement("button"); button.type = "button"; button.className = [ "rounded", "border", "border-dashed", "border-slate-300", "bg-stone-50", "p-1", "shadow-sm", ].join(" "); button.dataset.imageUrl = item.url; button.dataset.imageIndex = `pending-${index}`; const img = document.createElement("img"); img.src = item.url; img.alt = "Digitalisat (neu)"; img.loading = "lazy"; img.className = "h-28 w-28 object-cover opacity-70"; button.appendChild(img); const badge = document.createElement("span"); badge.className = "absolute left-1 top-1 rounded bg-amber-200 px-1.5 py-0.5 text-[10px] font-semibold text-amber-900"; badge.textContent = "Neu"; wrapper.appendChild(button); wrapper.appendChild(badge); const removeButton = document.createElement("button"); removeButton.type = "button"; removeButton.className = "absolute right-1 top-1 hidden rounded-full border border-red-200 bg-white/90 px-2 py-1 text-xs font-semibold text-red-700 shadow-sm transition group-hover:flex hover:text-red-900 hover:border-red-300"; removeButton.innerHTML = 'Entfernen'; removeButton.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); this._removePendingFileById(item.id); }); wrapper.appendChild(removeButton); list.appendChild(wrapper); return; } const image = item.image; const wrapper = document.createElement("div"); wrapper.className = "group relative"; wrapper.dataset.role = "content-images-item"; wrapper.dataset.scanKey = `existing:${item.name}`; wrapper.draggable = true; const isPendingDelete = this._pendingDeletes.has(image.name); if (isPendingDelete) { wrapper.classList.add("content-image-pending"); } const button = document.createElement("button"); button.type = "button"; button.className = [ "relative", "rounded", "border", "border-slate-200", "bg-white", "p-1", "shadow-sm", "transition", "hover:border-slate-400", "hover:shadow-md", ].join(" "); button.dataset.imageUrl = image.url; button.dataset.imageIndex = String(index); if (isPendingDelete) { button.setAttribute("aria-disabled", "true"); button.classList.add("content-image-pending-button"); } const img = document.createElement("img"); img.src = buildThumbUrl(image.url, THUMB_PARAM); img.alt = "Digitalisat"; img.loading = "lazy"; img.className = "h-28 w-28 object-cover"; button.appendChild(img); wrapper.appendChild(button); if (canDelete && image.name) { const deleteButton = document.createElement("button"); deleteButton.type = "button"; deleteButton.className = [ "absolute", "right-1", "top-1", "hidden", "rounded-full", "border", "border-red-200", "bg-white/90", "px-2", "py-1", "text-xs", "font-semibold", "text-red-700", "z-20", "shadow-sm", "transition", "group-hover:flex", "hover:text-red-900", "hover:border-red-300", ].join(" "); if (isPendingDelete) { deleteButton.classList.remove("border-red-200", "text-red-700"); deleteButton.classList.add("border-amber-300", "bg-amber-100", "text-amber-900", "hover:border-amber-400", "hover:text-amber-950"); deleteButton.innerHTML = 'Rueckgaengig'; } else { deleteButton.innerHTML = 'Entfernen'; } deleteButton.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); this._togglePendingDelete(image.name); }); wrapper.appendChild(deleteButton); } list.appendChild(wrapper); }); if (uploadProxy && uploadProxy.parentElement !== list) { list.appendChild(uploadProxy); } const dialog = this._ensureDialog(); const fullImage = dialog.querySelector(`[data-role='${CONTENT_IMAGES_FULL_ROLE}']`); list.addEventListener("click", (event) => { const target = event.target.closest("button[data-image-url]"); if (!target || !fullImage) { return; } const url = target.dataset.imageUrl || ""; const fullUrl = url.startsWith("blob:") ? url : buildFullUrl(url); fullImage.src = fullUrl; fullImage.alt = "Digitalisat"; if (dialog.showModal) { dialog.showModal(); } else { dialog.setAttribute("open", "true"); } }); this._wireDrag(list); } _ensureList() { let list = this.querySelector(`[data-role='${CONTENT_IMAGES_LIST_ROLE}']`); if (!list) { list = document.createElement("div"); list.dataset.role = CONTENT_IMAGES_LIST_ROLE; this.appendChild(list); } list.className = "grid gap-2"; list.style.gridTemplateColumns = "repeat(auto-fill, minmax(7rem, 1fr))"; list.style.width = "100%"; return list; } _ensureUploadProxy() { const panel = this.closest("[data-role='content-images-panel']"); if (!panel) { return null; } const uploadInput = panel.querySelector("[data-role='content-images-upload-input']"); if (!uploadInput) { return null; } let proxy = panel.querySelector("[data-role='content-images-upload-proxy']"); if (!proxy) { proxy = document.createElement("button"); proxy.type = "button"; proxy.dataset.role = "content-images-upload-proxy"; proxy.className = "flex h-28 w-28 items-center justify-center rounded-xs border-2 border-dashed border-slate-300 bg-stone-50 text-lg font-semibold text-slate-600 transition hover:border-slate-400 hover:text-slate-800"; proxy.setAttribute("aria-label", "Bilder hinzufuegen"); proxy.innerHTML = ''; proxy.addEventListener("click", () => { uploadInput.click(); }); } return proxy; } _togglePendingDelete(fileName) { if (!fileName) { return; } if (this._pendingDeletes.has(fileName)) { this._pendingDeletes.delete(fileName); } else { this._pendingDeletes.add(fileName); } this._render(this._currentImages || []); } _removePendingFileById(id) { const index = this._pendingIds.indexOf(id); if (index < 0) { return; } const url = this._pendingUrls[index]; if (url) { URL.revokeObjectURL(url); } this._pendingFiles.splice(index, 1); this._pendingUrls.splice(index, 1); this._pendingIds.splice(index, 1); this._scanOrder = this._scanOrder.filter((token) => token !== `pending:${id}`); this._render(this._currentImages || []); } getPendingFiles() { return Array.isArray(this._pendingFiles) ? this._pendingFiles : []; } getPendingIds() { return Array.isArray(this._pendingIds) ? this._pendingIds : []; } getPendingDeletes() { return Array.from(this._pendingDeletes || []); } getScanOrder() { if (!Array.isArray(this._scanOrder)) { return []; } return this._scanOrder.slice(); } _clearPendingPreviews() { if (Array.isArray(this._pendingUrls)) { this._pendingUrls.forEach((url) => URL.revokeObjectURL(url)); } this._pendingUrls = []; } disconnectedCallback() { this._clearPendingPreviews(); } _wireDrag(list) { if (!list || list.dataset.dragInit === "true") { return; } list.dataset.dragInit = "true"; let draggingKey = null; list.addEventListener("dragstart", (event) => { const item = event.target.closest("[data-role='content-images-item'], [data-role='content-images-pending']"); if (!item) { event.preventDefault(); return; } draggingKey = item.dataset.scanKey || null; item.classList.add("opacity-60"); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", "move"); }); list.addEventListener("dragover", (event) => { if (!draggingKey) { return; } event.preventDefault(); const target = event.target.closest("[data-role='content-images-item'], [data-role='content-images-pending']"); if (!target || target.dataset.scanKey === draggingKey) { return; } const rect = target.getBoundingClientRect(); const before = event.clientY - rect.top < rect.height / 2; const dragged = list.querySelector(`[data-scan-key="${CSS.escape(draggingKey)}"]`); if (!dragged) { return; } if (before) { target.before(dragged); } else { target.after(dragged); } }); list.addEventListener("dragend", () => { const dragged = draggingKey ? list.querySelector(`[data-scan-key="${CSS.escape(draggingKey)}"]`) : null; if (dragged) { dragged.classList.remove("opacity-60"); } draggingKey = null; const newOrder = []; list.querySelectorAll("[data-role='content-images-item'], [data-role='content-images-pending']").forEach((item) => { if (item.dataset.scanKey) { newOrder.push(item.dataset.scanKey); } }); this._scanOrder = newOrder; }); } _ensureDialog() { let dialog = this.querySelector(`[data-role='${CONTENT_IMAGES_DIALOG_ROLE}']`); if (dialog) { return dialog; } dialog = document.createElement("dialog"); dialog.dataset.role = CONTENT_IMAGES_DIALOG_ROLE; dialog.className = [ "fixed", "inset-0", "m-auto", "w-full", "max-w-5xl", "rounded-md", "border", "border-slate-200", "bg-white", "p-0", "shadow-xl", "backdrop:bg-black/60", ].join(" "); dialog.innerHTML = `
Digitalisat
Digitalisat
`; const closeButton = dialog.querySelector(`[data-role='${CONTENT_IMAGES_CLOSE_ROLE}']`); if (closeButton) { closeButton.addEventListener("click", () => { dialog.close(); }); } dialog.addEventListener("cancel", (event) => { event.preventDefault(); dialog.close(); }); dialog.addEventListener("click", (event) => { if (event.target === dialog) { dialog.close(); } }); this.appendChild(dialog); return dialog; } _openDeleteDialog(payload) { const dialog = this._ensureDeleteDialog(); if (!dialog) { return; } dialog.dataset.endpoint = payload.endpoint; dialog.dataset.contentId = payload.contentId; dialog.dataset.csrfToken = payload.csrfToken; dialog.dataset.fileName = payload.fileName; const nameEl = dialog.querySelector(`[data-role='${CONTENT_IMAGES_DELETE_NAME_ROLE}']`); if (nameEl) { nameEl.textContent = payload.fileName; } if (dialog.showModal) { dialog.showModal(); } else { dialog.setAttribute("open", "true"); } } _ensureDeleteDialog() { let dialog = this.querySelector(`[data-role='${CONTENT_IMAGES_DELETE_DIALOG_ROLE}']`); if (dialog) { return dialog; } dialog = document.createElement("dialog"); dialog.dataset.role = CONTENT_IMAGES_DELETE_DIALOG_ROLE; dialog.className = [ "dbform", "fixed", "inset-0", "m-auto", "rounded-md", "border", "border-slate-200", "p-0", "shadow-xl", "backdrop:bg-black/40", ].join(" "); dialog.innerHTML = `
Digitalisat loeschen?

Das Digitalisat wird dauerhaft entfernt.

`; const cancelButton = dialog.querySelector(`[data-role='${CONTENT_IMAGES_DELETE_CANCEL_ROLE}']`); const confirmButton = dialog.querySelector(`[data-role='${CONTENT_IMAGES_DELETE_CONFIRM_ROLE}']`); const closeDialog = () => { if (dialog.open) { dialog.close(); } }; if (cancelButton) { cancelButton.addEventListener("click", closeDialog); } dialog.addEventListener("cancel", (event) => { event.preventDefault(); closeDialog(); }); if (confirmButton) { confirmButton.addEventListener("click", () => { this._performDelete(dialog); }); } this.appendChild(dialog); return dialog; } _performDelete(dialog) { const endpoint = dialog.dataset.endpoint || ""; const csrfToken = dialog.dataset.csrfToken || ""; const contentId = dialog.dataset.contentId || ""; const fileName = dialog.dataset.fileName || ""; if (!endpoint || !csrfToken || !contentId || !fileName) { dialog.close(); return; } const panel = this.closest("[data-role='content-images-panel']"); if (window.htmx?.ajax && panel) { window.htmx.ajax("POST", endpoint, { target: panel, swap: "outerHTML", values: { csrf_token: csrfToken, content_id: contentId, scan: fileName, }, }); dialog.close(); return; } const payload = new URLSearchParams(); payload.set("csrf_token", csrfToken); payload.set("content_id", contentId); payload.set("scan", fileName); fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "HX-Request": "true", }, body: payload.toString(), }) .then((response) => { if (!response.ok || !panel) { return null; } return response.text(); }) .then((html) => { if (!html || !panel) { return; } this._applyServerResponse(html, panel); }) .catch(() => null) .finally(() => { dialog.close(); }); } _applyServerResponse(html, panel) { const template = document.createElement("template"); template.innerHTML = html.trim(); const oobNodes = Array.from(template.content.querySelectorAll("[hx-swap-oob]")); oobNodes.forEach((node) => { const swapRaw = node.getAttribute("hx-swap-oob") || ""; const [swapTypeRaw, selector] = swapRaw.split(":"); const swapType = swapTypeRaw || "outerHTML"; const target = selector ? document.querySelector(selector) : (node.id ? document.getElementById(node.id) : null); if (target) { if (swapType === "innerHTML") { target.innerHTML = node.innerHTML; } else { target.outerHTML = node.outerHTML; } } node.remove(); }); const replacement = template.content.firstElementChild; if (replacement) { panel.replaceWith(replacement); } } }