Files
musenalm/views/routes/components/_image_uploader.gohtml
2026-01-27 13:52:33 +01:00

526 lines
20 KiB
Plaintext
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.

{{ $model := . }}
<image-uploader
class="block"
data-upload-url="/redaktion/images/upload/"
data-list-url="/redaktion/images/list/"
data-delete-url="/redaktion/images/delete/"
data-prefix="{{ $model.prefix }}"
data-csrf="{{ $model.csrf_token }}">
<button type="button" class="w-full flex items-center justify-between rounded-md border border-slate-300 bg-white px-4 py-3 text-lg font-semibold text-slate-900 hover:bg-slate-50" data-role="collapse-toggle">
<span>Bilder</span>
<i class="ri-arrow-down-s-line transition-transform" data-role="collapse-icon"></i>
</button>
<div class="mt-4 hidden" data-role="collapse-panel">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-slate-900">Bilder</h2>
<p class="text-sm text-slate-600 mt-1">
Bilder sind öffentlich erreichbar. Der Schlüssel wird automatisch aus dem Titel gebildet.
</p>
</div>
<button type="button" class="inline-flex items-center gap-2 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" data-role="toggle-upload">
<i class="ri-add-line"></i> Bild hinzufügen
</button>
</div>
<div class="mt-4 hidden grid grid-cols-1 md:grid-cols-12 gap-3 items-end" data-role="upload-form">
<div class="md:col-span-4 flex flex-col gap-1">
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Bild</label>
<div class="border border-slate-300 rounded-md px-3 py-2 text-sm text-slate-600 bg-white min-h-[2.5rem] flex items-center" data-role="image-dropzone">
<div class="flex items-center justify-between gap-3 w-full">
<span data-role="image-dropzone-text">Bild ablegen oder auswählen</span>
<button type="button" class="text-sm font-semibold text-slate-700 hover:text-slate-900" data-role="image-choose">Auswählen</button>
</div>
<input type="file" class="hidden" data-role="image-input" />
</div>
</div>
<div class="md:col-span-4 flex flex-col gap-1">
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Vorschau</label>
<div class="border border-slate-300 rounded-md px-3 py-2 text-sm text-slate-600 bg-white min-h-[2.5rem] flex items-center" data-role="preview-dropzone">
<div class="flex items-center justify-between gap-3 w-full">
<span data-role="preview-dropzone-text">Optional Vorschau ablegen</span>
<button type="button" class="text-sm font-semibold text-slate-700 hover:text-slate-900" data-role="preview-choose">Auswählen</button>
</div>
<input type="file" class="hidden" data-role="preview-input" />
</div>
</div>
<div class="md:col-span-4 flex flex-col gap-1">
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Titel</label>
<input type="text" class="block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-slate-500 focus:ring-2 focus:ring-slate-400/30 min-h-[2.5rem]" data-role="title-input" autocomplete="off" />
</div>
<div class="md:col-span-12 flex flex-col gap-1">
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Beschreibung</label>
<trix-toolbar data-role="description-toolbar">
<div class="trix-toolbar-container">
<span class="trix-toolbar-group">
<button type="button" class="trix-toolbar-button" data-trix-attribute="bold" data-trix-key="b" title="Fett">
<i class="ri-bold"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-attribute="italic" data-trix-key="i" title="Kursiv">
<i class="ri-italic"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-attribute="strike" title="Gestrichen">
<i class="ri-strikethrough"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="Link">
<i class="ri-links-line"></i>
</button>
</span>
<span class="trix-toolbar-group">
<button type="button" class="trix-toolbar-button" data-trix-attribute="bullet" title="Liste">
<i class="ri-list-unordered"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-attribute="number" title="Aufzählung">
<i class="ri-list-ordered"></i>
</button>
</span>
<span class="trix-toolbar-group">
<button type="button" class="trix-toolbar-button" data-trix-action="undo" data-trix-key="z" title="Rückgängig">
<i class="ri-arrow-go-back-line"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-action="redo" data-trix-key="shift+z" title="Wiederholen">
<i class="ri-arrow-go-forward-line"></i>
</button>
</span>
</div>
<div class="trix-dialogs" data-trix-dialogs>
<div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href">
<div class="trix-dialog__link-fields flex flex-row">
<input type="url" name="href" class="trix-input trix-input--dialog" placeholder="URL eingeben…" aria-label="URL" required data-trix-input>
<div class="trix-button-group flex-row">
<input type="button" class="trix-button trix-button--dialog" value="Link" data-trix-method="setAttribute">
<input type="button" class="trix-button trix-button--dialog" value="Unlink" data-trix-method="removeAttribute">
</div>
</div>
</div>
</div>
</trix-toolbar>
<textarea hidden data-role="description-input" autocomplete="off"></textarea>
<trix-editor data-role="description-editor" class="rounded-md border border-slate-300 bg-white p-2"></trix-editor>
</div>
<div class="md:col-span-12 flex items-center justify-end">
<tool-tip position="top" class="inline-block w-full">
<button type="button" class="inline-flex items-center justify-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50 disabled:opacity-60" data-role="upload-button">
<i class="ri-upload-2-line"></i>
</button>
<div class="data-tip">Bild hochladen</div>
</tool-tip>
</div>
<div class="md:col-span-12">
<span class="text-sm text-slate-600" data-role="status"></span>
</div>
</div>
<div class="mt-6 max-h-96 overflow-auto rounded-sm border border-slate-200 bg-white p-3" data-role="image-list">
{{ template "_image_uploader_list" $model }}
</div>
</div>
</image-uploader>
<script>
(() => {
if (window.ImageUploaderDefined) {
return;
}
window.ImageUploaderDefined = true;
class ImageUploader extends HTMLElement {
connectedCallback() {
this.uploadUrl = this.dataset.uploadUrl || "";
this.listUrl = this.dataset.listUrl || "";
this.deleteUrl = this.dataset.deleteUrl || "";
this.imagePrefix = this.dataset.prefix || "";
this.csrf = this.dataset.csrf || "";
this.form = this.querySelector("[data-role='upload-form']");
this.list = this.querySelector("[data-role='image-list']");
this.status = this.querySelector("[data-role='status']");
this.uploadButton = this.querySelector("[data-role='upload-button']");
this.collapseToggle = this.querySelector("[data-role='collapse-toggle']");
this.collapsePanel = this.querySelector("[data-role='collapse-panel']");
this.collapseIcon = this.querySelector("[data-role='collapse-icon']");
this.toggleButton = this.querySelector("[data-role='toggle-upload']");
this.titleInput = this.querySelector("[data-role='title-input']");
this.descriptionInput = this.querySelector("[data-role='description-input']");
this.descriptionEditor = this.querySelector("[data-role='description-editor']");
this.descriptionToolbar = this.querySelector("[data-role='description-toolbar']");
this.imageInput = this.querySelector("[data-role='image-input']");
this.previewInput = this.querySelector("[data-role='preview-input']");
this.imageDropzone = this.querySelector("[data-role='image-dropzone']");
this.previewDropzone = this.querySelector("[data-role='preview-dropzone']");
this.imageDropText = this.querySelector("[data-role='image-dropzone-text']");
this.previewDropText = this.querySelector("[data-role='preview-dropzone-text']");
this.imageChoose = this.querySelector("[data-role='image-choose']");
this.previewChoose = this.querySelector("[data-role='preview-choose']");
if (this.uploadButton) {
this.uploadButton.addEventListener("click", (event) => this.handleUpload(event));
}
if (this.collapseToggle) {
this.collapseToggle.addEventListener("click", () => this.togglePanel());
}
if (this.toggleButton && this.form) {
this.toggleButton.addEventListener("click", () => this.toggleForm());
}
if (this.imageChoose && this.imageInput) {
this.imageChoose.addEventListener("click", () => this.imageInput.click());
}
if (this.previewChoose && this.previewInput) {
this.previewChoose.addEventListener("click", () => this.previewInput.click());
}
if (this.imageInput) {
this.imageInput.addEventListener("change", () => this.updateDropzone());
}
if (this.previewInput) {
this.previewInput.addEventListener("change", () => this.updateDropzone());
}
if (this.imageDropzone) {
this.imageDropzone.addEventListener("dragover", (event) => this.onDragOver(event, this.imageDropzone));
this.imageDropzone.addEventListener("dragleave", (event) => this.onDragLeave(event, this.imageDropzone));
this.imageDropzone.addEventListener("drop", (event) => this.onDrop(event, this.imageInput, "image"));
}
if (this.previewDropzone) {
this.previewDropzone.addEventListener("dragover", (event) => this.onDragOver(event, this.previewDropzone));
this.previewDropzone.addEventListener("dragleave", (event) => this.onDragLeave(event, this.previewDropzone));
this.previewDropzone.addEventListener("drop", (event) => this.onDrop(event, this.previewInput, "preview"));
}
this.addEventListener("click", (event) => this.handleClick(event));
this.addEventListener("submit", (event) => {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
}, true);
this.initTrix();
this.initSort();
this.applyDefaultSort();
this.normalizeUrls();
}
setStatus(message, isError) {
if (!this.status) return;
this.status.textContent = message || "";
this.status.classList.remove("text-red-600", "text-green-600");
if (isError) {
this.status.classList.add("text-red-600");
} else if (message) {
this.status.classList.add("text-green-600");
}
}
async handleUpload(event) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (!this.uploadUrl || !this.form) return;
if (!this.imagePrefix) {
this.setStatus("Prefix fehlt.", true);
return;
}
this.setStatus("Upload läuft…");
if (this.uploadButton) this.uploadButton.disabled = true;
if (!this.imageInput || !this.imageInput.files || this.imageInput.files.length === 0) {
this.setStatus("Bitte ein Bild auswählen.", true);
if (this.uploadButton) this.uploadButton.disabled = false;
return;
}
const payload = new FormData();
payload.append("image", this.imageInput.files[0]);
if (this.previewInput && this.previewInput.files && this.previewInput.files[0]) {
payload.append("preview", this.previewInput.files[0]);
}
payload.append("prefix", this.imagePrefix);
if (this.titleInput && this.titleInput.value) {
payload.append("title", this.titleInput.value);
}
if (this.descriptionInput && this.descriptionInput.value) {
payload.append("description", this.descriptionInput.value);
}
payload.set("csrf_token", this.csrf);
const response = await fetch(this.uploadUrl, {
method: "POST",
body: payload,
credentials: "same-origin",
});
if (!response.ok) {
const message = await this.extractError(response);
this.setStatus(message || "Upload fehlgeschlagen.", true);
if (this.uploadButton) this.uploadButton.disabled = false;
return;
}
const json = await this.safeJson(response);
if (json && json.error) {
this.setStatus(json.error, true);
if (this.uploadButton) this.uploadButton.disabled = false;
return;
}
if (this.imageInput) this.imageInput.value = "";
if (this.previewInput) this.previewInput.value = "";
this.updateDropzone();
if (this.titleInput) this.titleInput.value = "";
if (this.descriptionInput) this.descriptionInput.value = "";
if (this.descriptionEditor && this.descriptionEditor.editor) {
this.descriptionEditor.editor.setSelectedRange([0, this.descriptionEditor.editor.getDocument().getLength()]);
this.descriptionEditor.editor.deleteInDirection("forward");
}
this.setStatus((json && json.message) || "Bild gespeichert.");
this.lastUploadedId = json && json.id ? json.id : "";
await this.refreshList();
if (this.form) this.form.classList.add("hidden");
if (this.uploadButton) this.uploadButton.disabled = false;
}
async handleClick(event) {
const target = event.target.closest("[data-action]");
if (!target) return;
const action = target.getAttribute("data-action");
if (action === "copy") {
const url = target.getAttribute("data-url");
if (!url) return;
try {
await navigator.clipboard.writeText(url);
this.setStatus("Link kopiert.");
} catch {
this.setStatus("Link kopieren fehlgeschlagen.", true);
}
}
if (action === "delete") {
const id = target.getAttribute("data-id");
if (!id || !this.deleteUrl) return;
if (!confirm("Bild wirklich löschen?")) return;
const payload = new FormData();
payload.set("csrf_token", this.csrf);
const response = await fetch(`${this.deleteUrl}${id}`, {
method: "POST",
body: payload,
credentials: "same-origin",
});
if (!response.ok) {
const message = await this.extractError(response);
this.setStatus(message || "Löschen fehlgeschlagen.", true);
return;
}
const json = await this.safeJson(response);
if (json && json.error) {
this.setStatus(json.error, true);
return;
}
this.setStatus((json && json.message) || "Bild gelöscht.");
await this.refreshList();
}
}
async refreshList() {
if (!this.listUrl || !this.list) return;
const url = `${this.listUrl}?prefix=${encodeURIComponent(this.imagePrefix)}`;
if (window.htmx && typeof window.htmx.ajax === "function") {
window.htmx.ajax("GET", url, {
target: this.list,
swap: "innerHTML",
}).then(() => {
this.initSort();
this.applyDefaultSort();
this.normalizeUrls();
this.highlightNew();
});
return;
}
const response = await fetch(url, { credentials: "same-origin" });
if (!response.ok) {
this.setStatus("Aktualisieren fehlgeschlagen.", true);
return;
}
const html = await response.text();
this.list.innerHTML = html;
this.initSort();
this.applyDefaultSort();
this.normalizeUrls();
this.highlightNew();
}
initSort() {
if (!this.list) return;
const buttons = Array.from(this.list.querySelectorAll("[data-role='image-sort']"));
if (buttons.length === 0) return;
buttons.forEach((button) => {
button.addEventListener("click", () => this.sortBy(button));
});
}
sortBy(button) {
if (!this.list) return;
const key = button.getAttribute("data-sort-key");
const table = this.list.querySelector("table");
const tbody = table ? table.querySelector("tbody") : null;
if (!tbody || !key) return;
const current = button.getAttribute("data-sort-dir") || "desc";
const next = current === "asc" ? "desc" : "asc";
button.setAttribute("data-sort-dir", next);
const rows = Array.from(tbody.querySelectorAll("tr"));
rows.sort((a, b) => {
if (key === "created") {
const av = parseInt(a.getAttribute("data-created") || "0", 10);
const bv = parseInt(b.getAttribute("data-created") || "0", 10);
return next === "asc" ? av - bv : bv - av;
}
const at = (a.getAttribute("data-title") || "").toLowerCase();
const bt = (b.getAttribute("data-title") || "").toLowerCase();
if (at === bt) return 0;
const cmp = at < bt ? -1 : 1;
return next === "asc" ? cmp : -cmp;
});
rows.forEach((row) => tbody.appendChild(row));
}
applyDefaultSort() {
if (!this.list) return;
const createdButton = this.list.querySelector("[data-role='image-sort'][data-sort-key='created']");
if (!createdButton) return;
createdButton.setAttribute("data-sort-dir", "asc");
this.sortBy(createdButton);
}
highlightNew() {
if (!this.list || !this.lastUploadedId) return;
const row = this.list.querySelector(`tr[data-id="${this.lastUploadedId}"]`);
if (!row) return;
row.classList.add("bg-yellow-50");
setTimeout(() => row.classList.remove("bg-yellow-50"), 2000);
row.scrollIntoView({ block: "nearest" });
this.lastUploadedId = "";
}
normalizeUrls() {
if (!this.list) return;
const origin = window.location.origin;
const withOrigin = (url) => {
if (!url) return url;
if (url.startsWith("http://") || url.startsWith("https://")) return url;
try {
return new URL(url, origin).toString();
} catch {
return url;
}
};
const urlNodes = Array.from(this.list.querySelectorAll("[data-url]"));
urlNodes.forEach((node) => {
const raw = node.getAttribute("data-url");
const abs = withOrigin(raw);
node.setAttribute("data-url", abs);
if (node.tagName === "A") {
node.setAttribute("href", abs);
}
});
}
toggleForm() {
if (!this.form) return;
this.form.classList.toggle("hidden");
}
togglePanel() {
if (!this.collapsePanel) return;
const isHidden = this.collapsePanel.classList.contains("hidden");
this.collapsePanel.classList.toggle("hidden");
if (this.collapseIcon) {
this.collapseIcon.classList.toggle("rotate-180", isHidden);
}
}
updateDropzone() {
if (this.imageDropText && this.imageInput) {
const file = this.imageInput.files && this.imageInput.files[0];
this.imageDropText.textContent = file
? `${file.name} (${this.formatBytes(file.size) || "?"})`
: "Bild ablegen oder auswählen";
}
if (this.previewDropText && this.previewInput) {
const file = this.previewInput.files && this.previewInput.files[0];
this.previewDropText.textContent = file
? `${file.name} (${this.formatBytes(file.size) || "?"})`
: "Optional Vorschau ablegen";
}
}
initTrix() {
if (!this.descriptionInput || !this.descriptionEditor || !this.descriptionToolbar) return;
if (!this.descriptionInput.id) {
const id = "image-desc-" + Math.random().toString(36).slice(2);
this.descriptionInput.id = id;
this.descriptionToolbar.id = id + "-toolbar";
this.descriptionEditor.setAttribute("input", id);
this.descriptionEditor.setAttribute("toolbar", this.descriptionToolbar.id);
}
}
onDragOver(event, zone) {
event.preventDefault();
if (zone) {
zone.classList.add("border-slate-500", "bg-slate-50");
}
}
onDragLeave(event, zone) {
event.preventDefault();
if (zone) {
zone.classList.remove("border-slate-500", "bg-slate-50");
}
}
onDrop(event, input, kind) {
event.preventDefault();
if (!input) return;
const files = event.dataTransfer?.files;
if (!files || files.length === 0) return;
input.files = files;
this.updateDropzone();
this.onDragLeave(event, kind === "preview" ? this.previewDropzone : this.imageDropzone);
}
formatBytes(bytes) {
if (!bytes || Number.isNaN(bytes)) return "";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
const precision = unit === 0 ? 0 : 1;
return `${size.toFixed(precision)} ${units[unit]}`;
}
async safeJson(response) {
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
return null;
}
try {
return await response.json();
} catch {
return null;
}
}
async extractError(response) {
const json = await this.safeJson(response);
if (json) {
if (json.error) {
return json.error;
}
if (json.message) {
return json.message;
}
}
const text = await response.text().catch(() => "");
return text.trim();
}
}
customElements.define("image-uploader", ImageUploader);
})();
</script>