Inhalte-Bilder + musenalm stage dockerfile

This commit is contained in:
Simon Martens
2026-01-19 16:47:07 +01:00
parent d3ffa5f90d
commit 3017d4164b
9 changed files with 1661 additions and 758 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,11 @@
{{- $content := index . "content" -}}
{{- $entry := index . "entry" -}}
{{- $csrf := index . "csrf_token" -}}
{{- $isNew := index . "is_new" -}}
{{- template "_content_images_panel" (Dict
"content" $content
"entry" $entry
"csrf_token" $csrf
"is_new" $isNew
) -}}

View File

@@ -0,0 +1,48 @@
{{- $content := index . "content" -}}
{{- $entry := index . "entry" -}}
{{- $csrf := index . "csrf_token" -}}
{{- $isNew := index . "is_new" -}}
{{- if or $content.ImagePaths (not $isNew) -}}
<div class="w-full md:w-56 lg:w-72 shrink-0" data-role="content-images-panel">
<div class="flex flex-wrap items-start gap-2">
{{- if $content.ImagePaths -}}
<content-images
data-images='[{{- range $i, $scan := $content.ImagePaths -}}{{- if $i }},{{ end -}}{{ printf "%q" $scan }}{{- end -}}]'
data-files='[{{- range $i, $scan := $content.Scans -}}{{- if $i }},{{ end -}}{{ printf "%q" $scan }}{{- end -}}]'
data-delete-endpoint="/almanach/{{ $entry.MusenalmID }}/contents/scan/delete"
data-content-id="{{ $content.Id }}"
data-csrf-token="{{ $csrf }}">
</content-images>
{{- end -}}
{{- if not $isNew -}}
<form
class="flex"
method="POST"
action="/almanach/{{ $entry.MusenalmID }}/contents/upload"
hx-post="/almanach/{{ $entry.MusenalmID }}/contents/upload"
hx-trigger="change"
hx-target="closest [data-role='content-images-panel']"
hx-swap="outerHTML"
hx-encoding="multipart/form-data"
data-loading-label="Digitalisat wird hochgeladen">
<input type="hidden" name="csrf_token" value="{{ $csrf }}" />
<input type="hidden" name="content_id" value="{{ $content.Id }}" />
<label
for="content-{{ $content.Id }}-scan-upload"
class="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"
aria-label="Bilder hinzufuegen">
<i class="ri-upload-2-line"></i>
</label>
<input
id="content-{{ $content.Id }}-scan-upload"
type="file"
name="scans"
multiple
accept="image/*"
class="sr-only" />
</form>
{{- end -}}
</div>
</div>
{{- end -}}

View File

@@ -67,6 +67,12 @@
</div>
</div>
<div class="flex items-center gap-2 flex-nowrap whitespace-nowrap shrink-0">
<span
id="content-{{ $content.Id }}-images-count"
class="inline-flex items-center gap-1 text-sm font-semibold text-slate-600 mr-2.5 {{ if eq (len $content.Scans) 0 }}hidden{{ end }}">
<i class="ri-image-line"></i>
<span>{{ len $content.Scans }}</span>
</span>
<span class="status-badge text-xs shadow-sm" data-status="{{ $content.EditState }}">
<i class="status-icon {{- if eq $content.EditState "Edited" }} ri-checkbox-circle-line{{- else if eq $content.EditState "Seen" }} ri-information-line{{- else if eq $content.EditState "Review" }} ri-search-line{{- else if eq $content.EditState "ToDo" }} ri-list-check{{- else }} ri-forbid-2-line{{- end }}"></i>
{{- if eq $content.EditState "Edited" -}}Erfasst{{- else if eq $content.EditState "Review" -}}Überprüfen{{- else if eq $content.EditState "ToDo" -}}Zu erledigen{{- else if eq $content.EditState "Seen" -}}Autopsiert{{- else -}}Unbekannt{{- end -}}
@@ -115,84 +121,92 @@
</div>
</div>
</dialog>
<div class="grid gap-2 grid-cols-[8rem_1fr] items-baseline px-3 py-2" data-role="content-view-body">
{{- if or $content.Extent $content.MusenalmPagination -}}
<div class="text-sm font-bold text-gray-700">Seite</div>
<div class="text-base">
{{ if $content.Extent }}{{ $content.Extent }}{{ end }}{{ if and $content.Extent $content.MusenalmPagination }}, {{ end }}{{ if $content.MusenalmPagination }}{{ $content.MusenalmPagination }}{{ end }}
</div>
{{- end -}}
{{- if $content.TitleStmt -}}
<div class="text-sm font-bold text-gray-700">Titel</div>
<div class="text-base italic">{{- $content.TitleStmt -}}</div>
{{- end -}}
{{- if $content.SubtitleStmt -}}
<div class="text-sm font-bold text-gray-700">Untertitel</div>
<div class="text-base italic">{{- $content.SubtitleStmt -}}</div>
{{- end -}}
{{- if $content.ParallelTitle -}}
<div class="text-sm font-bold text-gray-700">Paralleltitel</div>
<div class="text-base italic">{{- $content.ParallelTitle -}}</div>
{{- end -}}
{{- if $content.VariantTitle -}}
<div class="text-sm font-bold text-gray-700">Titelvarianten</div>
<div class="text-base italic">{{- $content.VariantTitle -}}</div>
{{- end -}}
{{- if $content.PlaceStmt -}}
<div class="text-sm font-bold text-gray-700">Ortsangabe</div>
<div class="text-base italic">{{- $content.PlaceStmt -}}</div>
{{- end -}}
{{- if gt (len $content.Language) 0 -}}
<div class="text-sm font-bold text-gray-700">Sprache</div>
<div class="text-base">
{{- range $i, $lang := $content.Language -}}{{- if $i }}, {{ end -}}{{- $lang -}}{{- end -}}
</div>
{{- end -}}
{{- if gt (len $content.ContentType) 0 -}}
<div class="text-sm font-bold text-gray-700">Beitragstyp</div>
<div class="text-base">
{{- range $i, $t := $content.ContentType -}}{{- if $i }}, {{ end -}}{{- $t -}}{{- end -}}
</div>
{{- end -}}
{{- if $content.IncipitStmt -}}
<div class="text-sm font-bold text-gray-700">Incipit</div>
<div class="text-base italic">{{ $content.IncipitStmt }}…</div>
{{- end -}}
{{- if $content.ResponsibilityStmt -}}
<div class="text-sm font-bold text-gray-700">Autorangabe</div>
<div class="text-base italic">{{- $content.ResponsibilityStmt -}}</div>
{{- end -}}
{{- if $content.Comment -}}
<div class="text-sm font-bold text-gray-700">Kommentar</div>
<div class="text-base italic">{{- $content.Comment -}}</div>
{{- end -}}
{{- if $contentAgents -}}
<div class="text-sm font-bold text-gray-700">Personen</div>
<div class="text-base">
<div class="flex flex-col">
{{- range $_, $rel := $contentAgents -}}
{{- $agent := index $agents $rel.Agent -}}
{{- if $agent -}}
<div class="font-sans w-max">
<a href="/person/{{- $agent.Id -}}" class="no-underline hover:text-slate-900">
{{- $agent.Name -}}
</a>
{{- if $agent.BiographicalData -}}
<span> ({{ $agent.BiographicalData }})</span>
{{- end -}}
</div>
{{- end -}}
{{- end -}}
<div class="flex flex-col gap-3 px-3 py-2 md:flex-row md:items-start" data-role="content-view-body">
<div class="grid flex-1 gap-2 grid-cols-[8rem_1fr] items-baseline">
{{- if or $content.Extent $content.MusenalmPagination -}}
<div class="text-sm font-bold text-gray-700">Seite</div>
<div class="text-base">
{{ if $content.Extent }}{{ $content.Extent }}{{ end }}{{ if and $content.Extent $content.MusenalmPagination }}, {{ end }}{{ if $content.MusenalmPagination }}{{ $content.MusenalmPagination }}{{ end }}
</div>
</div>
{{- end -}}
{{- if $content.Annotation -}}
{{- $link := printf "%s%s" "/almanach/" $entry.MusenalmIDString -}}
<div class="text-sm font-bold text-gray-700">Anmerkung</div>
<div class="text-base">
{{- Safe (LinksAnnotation (ReplaceSlashParen $content.Annotation) $link) -}}
</div>
{{- end -}}
{{- end -}}
{{- if $content.TitleStmt -}}
<div class="text-sm font-bold text-gray-700">Titel</div>
<div class="text-base italic">{{- $content.TitleStmt -}}</div>
{{- end -}}
{{- if $content.SubtitleStmt -}}
<div class="text-sm font-bold text-gray-700">Untertitel</div>
<div class="text-base italic">{{- $content.SubtitleStmt -}}</div>
{{- end -}}
{{- if $content.ParallelTitle -}}
<div class="text-sm font-bold text-gray-700">Paralleltitel</div>
<div class="text-base italic">{{- $content.ParallelTitle -}}</div>
{{- end -}}
{{- if $content.VariantTitle -}}
<div class="text-sm font-bold text-gray-700">Titelvarianten</div>
<div class="text-base italic">{{- $content.VariantTitle -}}</div>
{{- end -}}
{{- if $content.PlaceStmt -}}
<div class="text-sm font-bold text-gray-700">Ortsangabe</div>
<div class="text-base italic">{{- $content.PlaceStmt -}}</div>
{{- end -}}
{{- if gt (len $content.Language) 0 -}}
<div class="text-sm font-bold text-gray-700">Sprache</div>
<div class="text-base">
{{- range $i, $lang := $content.Language -}}{{- if $i }}, {{ end -}}{{- $lang -}}{{- end -}}
</div>
{{- end -}}
{{- if gt (len $content.ContentType) 0 -}}
<div class="text-sm font-bold text-gray-700">Beitragstyp</div>
<div class="text-base">
{{- range $i, $t := $content.ContentType -}}{{- if $i }}, {{ end -}}{{- $t -}}{{- end -}}
</div>
{{- end -}}
{{- if $content.IncipitStmt -}}
<div class="text-sm font-bold text-gray-700">Incipit</div>
<div class="text-base italic">{{ $content.IncipitStmt }}…</div>
{{- end -}}
{{- if $content.ResponsibilityStmt -}}
<div class="text-sm font-bold text-gray-700">Autorangabe</div>
<div class="text-base italic">{{- $content.ResponsibilityStmt -}}</div>
{{- end -}}
{{- if $content.Comment -}}
<div class="text-sm font-bold text-gray-700">Kommentar</div>
<div class="text-base italic">{{- $content.Comment -}}</div>
{{- end -}}
{{- if $contentAgents -}}
<div class="text-sm font-bold text-gray-700">Personen</div>
<div class="text-base">
<div class="flex flex-col">
{{- range $_, $rel := $contentAgents -}}
{{- $agent := index $agents $rel.Agent -}}
{{- if $agent -}}
<div class="font-sans w-max">
<a href="/person/{{- $agent.Id -}}" class="no-underline hover:text-slate-900">
{{- $agent.Name -}}
</a>
{{- if $agent.BiographicalData -}}
<span> ({{ $agent.BiographicalData }})</span>
{{- end -}}
</div>
{{- end -}}
{{- end -}}
</div>
</div>
{{- end -}}
{{- if $content.Annotation -}}
{{- $link := printf "%s%s" "/almanach/" $entry.MusenalmIDString -}}
<div class="text-sm font-bold text-gray-700">Anmerkung</div>
<div class="text-base">
{{- Safe (LinksAnnotation (ReplaceSlashParen $content.Annotation) $link) -}}
</div>
{{- end -}}
</div>
{{- template "_content_images_panel" (Dict
"content" $content
"entry" $entry
"csrf_token" $csrf
"is_new" $isNew
) -}}
</div>
</div>
</div>

View File

@@ -0,0 +1,387 @@
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";
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);
if (!Array.isArray(normalized) || normalized.length === 0) {
this.classList.add("hidden");
return;
}
this._render(normalized);
}
_render(images) {
this.classList.add("inline-flex");
const list = this._ensureList();
list.innerHTML = "";
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;
images.forEach((image, index) => {
const wrapper = document.createElement("div");
wrapper.className = "group relative";
const button = document.createElement("button");
button.type = "button";
button.className = [
"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);
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",
"h-8",
"w-8",
"rounded-full",
"border",
"border-red-200",
"bg-white/90",
"flex",
"items-center",
"justify-center",
"text-red-700",
"shadow-sm",
"transition",
"group-hover:flex",
"hover:text-red-900",
"hover:border-red-300",
].join(" ");
deleteButton.innerHTML = '<i class="ri-delete-bin-line"></i>';
deleteButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
this._openDeleteDialog({
endpoint: deleteEndpoint,
contentId,
csrfToken,
fileName: image.name,
});
});
wrapper.appendChild(deleteButton);
}
list.appendChild(wrapper);
});
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 || "";
fullImage.src = buildFullUrl(url);
fullImage.alt = "Digitalisat";
if (dialog.showModal) {
dialog.showModal();
} else {
dialog.setAttribute("open", "true");
}
});
}
_ensureList() {
let list = this.querySelector(`[data-role='${CONTENT_IMAGES_LIST_ROLE}']`);
if (!list) {
list = document.createElement("div");
list.dataset.role = CONTENT_IMAGES_LIST_ROLE;
list.className = "inline-flex flex-wrap gap-2";
this.appendChild(list);
}
return list;
}
_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 = `
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<div class="text-sm font-semibold text-gray-800">Digitalisat</div>
<button
type="button"
class="rounded-xs border border-slate-300 bg-stone-100 px-3 py-1 text-sm font-semibold text-gray-700 hover:bg-stone-200"
data-role="${CONTENT_IMAGES_CLOSE_ROLE}">
Schliessen
</button>
</div>
<div class="p-4">
<img data-role="${CONTENT_IMAGES_FULL_ROLE}" class="max-h-[75vh] w-full object-contain" alt="Digitalisat" />
</div>
`;
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 = `
<div class="p-5 w-[22rem]">
<div class="text-base font-bold text-gray-900">Digitalisat loeschen?</div>
<div class="text-sm font-bold text-gray-900 mt-1" data-role="${CONTENT_IMAGES_DELETE_NAME_ROLE}"></div>
<p class="text-sm text-gray-700 mt-2">
Das Digitalisat wird dauerhaft entfernt.
</p>
<div class="flex items-center justify-end gap-3 mt-4">
<button type="button" class="resetbutton w-auto px-3 py-1 text-sm" data-role="${CONTENT_IMAGES_DELETE_CANCEL_ROLE}">Abbrechen</button>
<button type="button" class="submitbutton w-auto bg-red-700 hover:bg-red-800 px-3 py-1 text-sm" data-role="${CONTENT_IMAGES_DELETE_CONFIRM_ROLE}">
Loeschen
</button>
</div>
</div>
`;
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;
}
panel.outerHTML = html;
})
.catch(() => null)
.finally(() => {
dialog.close();
});
}
}

View File

@@ -28,6 +28,7 @@ import { RelationsEditor } from "./relations-editor.js";
import { EditPage } from "./edit-page.js";
import { FabMenu } from "./fab-menu.js";
import { DuplicateWarningChecker } from "./duplicate-warning.js";
import { ContentImages } from "./content-images.js";
const FILTER_LIST_ELEMENT = "filter-list";
const FAB_MENU_ELEMENT = "fab-menu";
@@ -49,6 +50,7 @@ const ALMANACH_EDIT_PAGE_ELEMENT = "almanach-edit-page";
const RELATIONS_EDITOR_ELEMENT = "relations-editor";
const EDIT_PAGE_ELEMENT = "edit-page";
const DUPLICATE_WARNING_ELEMENT = "duplicate-warning-checker";
const CONTENT_IMAGES_ELEMENT = "content-images";
customElements.define(INT_LINK_ELEMENT, IntLink);
customElements.define(ABBREV_TOOLTIPS_ELEMENT, AbbreviationTooltips);
@@ -70,6 +72,7 @@ customElements.define(RELATIONS_EDITOR_ELEMENT, RelationsEditor);
customElements.define(EDIT_PAGE_ELEMENT, EditPage);
customElements.define(FAB_MENU_ELEMENT, FabMenu);
customElements.define(DUPLICATE_WARNING_ELEMENT, DuplicateWarningChecker);
customElements.define(CONTENT_IMAGES_ELEMENT, ContentImages);
function PathPlusQuery() {
const path = window.location.pathname;