some list things; image uplaod

This commit is contained in:
Simon Martens
2026-01-21 21:12:05 +01:00
parent bd4d6571e0
commit 1aa24b97cc
9 changed files with 1582 additions and 1193 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

@@ -223,7 +223,6 @@
{{- if $i }},{{ end }}
"{{ $content.Id }}": {
extent: "{{ $content.Extent }}",
pagination: "{{ $content.MusenalmPagination }}",
musenalmType: [{{- range $j, $t := $content.MusenalmType -}}{{- if $j }},{{ end }}"{{ $t }}"{{- end -}}],
preferredTitle: "{{ $content.PreferredTitle }}",
titleStmt: "{{ $content.TitleStmt }}",
@@ -296,7 +295,6 @@
// Field labels for display
const fieldLabels = {
extent: 'Seite',
pagination: 'Paginierung',
musenalmType: 'Typ',
titleStmt: 'Titel',
subtitleStmt: 'Untertitel',
@@ -311,6 +309,51 @@
annotation: 'Anmerkung'
};
const getMarkTargets = () => {
if (!list) {
return [];
}
const visibleItems = Array.from(list.querySelectorAll("[data-role='content-item']")).filter((item) => item.style.display !== "none");
const targets = [];
visibleItems.forEach((item) => {
targets.push(...item.querySelectorAll(".content-search-text"));
});
return targets;
};
const updateMarks = () => {
if (typeof Mark !== "function") {
return;
}
const terms = [];
const rawQuery = (filterInput.value || "").trim();
const rawPage = (filterPage?.value || "").trim();
const rawType = (filterType?.value || "").trim();
if (rawQuery) {
terms.push(rawQuery);
}
if (rawPage) {
terms.push(rawPage);
}
if (rawType) {
terms.push(rawType);
}
const targets = getMarkTargets();
if (targets.length === 0) {
return;
}
const markInstance = new Mark(targets);
markInstance.unmark({
done: () => {
if (terms.length > 0) {
markInstance.mark(terms, {
separateWordSearch: true,
});
}
},
});
};
const performFilter = () => {
const query = normalizeText(filterInput.value);
const pageQuery = normalizeText(filterPage?.value || '');
@@ -401,8 +444,6 @@
};
// Check text search fields (excluding language, status, preferredTitle)
checkField('extent', contentData.extent, fieldLabels.extent);
checkField('pagination', contentData.pagination, fieldLabels.pagination);
checkArrayField('musenalmType', contentData.musenalmType, fieldLabels.musenalmType);
checkField('titleStmt', contentData.titleStmt, fieldLabels.titleStmt);
checkField('subtitleStmt', contentData.subtitleStmt, fieldLabels.subtitleStmt);
@@ -463,6 +504,8 @@
? `${totalCount} Einträge`
: `${visibleCount} von ${totalCount} Einträgen`;
}
updateMarks();
};
const debounceFilter = () => {
@@ -522,6 +565,15 @@
}
syncIndicator.classList.toggle("hidden", !active);
};
const setDraggingState = (active) => {
if (active) {
document.body.dataset.dragging = "true";
} else {
delete document.body.dataset.dragging;
}
window.__toolTipDragging = active;
window.dispatchEvent(new CustomEvent("contentsdragging", { detail: { active } }));
};
// Shared delete dialog
const deleteDialog = document.getElementById("content-delete-dialog");
const deleteDialogTitle = document.getElementById("content-delete-dialog-title");
@@ -665,8 +717,59 @@
if (list.dataset.pageInit !== "true") {
list.dataset.pageInit = "true";
let draggedItem = null;
let pointerDrag = null;
let lastDragOverTime = 0;
const DRAG_THROTTLE_MS = 100;
const startPointerDrag = (event) => {
const handle = event.target.closest("[data-role='content-drag-handle']");
if (!handle || event.button !== 0) {
return;
}
const item = handle.closest("[data-role='content-item']");
if (!item) {
return;
}
event.preventDefault();
pointerDrag = {
item,
pointerId: event.pointerId,
};
item.dataset.dragging = "true";
item.classList.add("opacity-60");
setDraggingState(true);
if (handle.setPointerCapture) {
handle.setPointerCapture(event.pointerId);
}
};
const movePointerDrag = (event) => {
if (!pointerDrag || event.pointerId !== pointerDrag.pointerId) {
return;
}
const item = pointerDrag.item;
const targetItem = document.elementFromPoint(event.clientX, event.clientY)?.closest("[data-role='content-item']");
if (!targetItem || targetItem === item) {
return;
}
const rect = targetItem.getBoundingClientRect();
const before = event.clientY - rect.top < rect.height / 2;
if (before) {
targetItem.before(item);
} else {
targetItem.after(item);
}
};
const endPointerDrag = (event) => {
if (!pointerDrag || event.pointerId !== pointerDrag.pointerId) {
return;
}
pointerDrag.item.classList.remove("opacity-60");
pointerDrag.item.dataset.dragging = "";
pointerDrag = null;
setDraggingState(false);
syncOrder();
};
list.addEventListener("click", (event) => {
const moveUp = event.target.closest("[data-role='content-move-up']");
const moveDown = event.target.closest("[data-role='content-move-down']");
@@ -699,6 +802,10 @@
});
list.addEventListener("dragstart", (event) => {
if (pointerDrag) {
event.preventDefault();
return;
}
if (event.target.closest("[data-role='content-move-up']") || event.target.closest("[data-role='content-move-down']")) {
return;
}
@@ -717,6 +824,7 @@
draggedItem = item;
item.dataset.dragging = "true";
draggedItem.classList.add("opacity-60");
setDraggingState(true);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", "move");
});
@@ -745,8 +853,14 @@
draggedItem.dataset.dragging = "";
}
draggedItem = null;
setDraggingState(false);
syncOrder();
});
list.addEventListener("pointerdown", startPointerDrag);
list.addEventListener("pointermove", movePointerDrag);
list.addEventListener("pointerup", endPointerDrag);
list.addEventListener("pointercancel", endPointerDrag);
}
};

View File

@@ -209,6 +209,12 @@
data-initial-values='[{{- range $i, $lang := $model.content.Language -}}{{- if $i }},{{ end -}}{{ printf "%q" $lang }}{{- end -}}]'>
</multi-select-simple>
</div>
{{- template "_content_images_panel" (Dict
"content" $model.content
"entry" $model.result.Entry
"csrf_token" $model.csrf_token
"is_new" false
) -}}
</div>
</div>

View File

@@ -4,30 +4,18 @@
{{- $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 -}}
<div class="w-full" data-role="content-images-panel">
<div class="flex flex-col items-start gap-2">
<content-images
class="w-full"
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 }}" />
<div class="flex" data-role="content-images-upload">
<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"
@@ -40,8 +28,12 @@
name="scans"
multiple
accept="image/*"
class="sr-only" />
</form>
class="sr-only"
data-role="content-images-upload-input"
data-upload-endpoint="/almanach/{{ $entry.MusenalmID }}/contents/upload"
data-content-id="{{ $content.Id }}"
data-csrf-token="{{ $csrf }}" />
</div>
{{- end -}}
</div>
</div>

View File

@@ -37,21 +37,25 @@
</tool-tip>
</div>
{{- if $content.Extent -}}
<span class="bg-slate-200 text-slate-900 px-1.5 py-0.5 rounded text-sm font-semibold shadow-sm shrink-0" data-role="content-page-pill">S. {{- $content.Extent -}}</span>
<span class="content-search-text bg-slate-200 text-slate-900 px-1.5 py-0.5 rounded text-sm font-semibold shadow-sm shrink-0" data-role="content-page-pill">S. {{- $content.Extent -}}</span>
{{- end -}}
{{- if $content.MusenalmType -}}
<span class="flex flex-nowrap gap-1 text-gray-700 font-normal overflow-hidden">
{{- range $i, $t := $content.MusenalmType -}}
<span class="bg-slate-200 text-slate-900 px-1.5 py-0.5 rounded text-base font-semibold shadow-sm" data-role="content-type-pill">{{- $t -}}</span>
<span class="content-search-text bg-slate-200 text-slate-900 px-1.5 py-0.5 rounded text-base font-semibold shadow-sm" data-role="content-type-pill">{{- $t -}}</span>
{{- end -}}
</span>
{{- end -}}
<div class="flex items-baseline gap-2 text-gray-800 min-w-0 flex-1 overflow-hidden flex-nowrap whitespace-nowrap" data-role="content-header-title">
<a
href="/almanach/{{ $entry.MusenalmID }}/contents/{{ $content.MusenalmID }}/edit"
class="no-underline hover:text-slate-900 cursor-pointer">
{{- if $content.PreferredTitle -}}
<span class="text-lg font-normal truncate min-w-0 overflow-hidden" data-role="content-header-title-text">{{- $content.PreferredTitle -}}</span>
<span class="content-search-text text-lg font-normal truncate min-w-0 overflow-hidden" data-role="content-header-title-text">{{- $content.PreferredTitle -}}</span>
{{- else if $content.TitleStmt -}}
<span class="text-lg font-normal italic truncate min-w-0 overflow-hidden" data-role="content-header-title-text">{{- $content.TitleStmt -}}</span>
<span class="content-search-text text-lg font-normal italic truncate min-w-0 overflow-hidden" data-role="content-header-title-text">{{- $content.TitleStmt -}}</span>
{{- end -}}
</a>
</div>
</div>
<div class="flex items-center gap-2 flex-nowrap whitespace-nowrap shrink-0">
@@ -79,7 +83,7 @@
</button>
</div>
</div>
<div data-role="content-match-display" class="hidden px-2 py-1 text-sm text-gray-600 bg-amber-50 border-l-2 border-amber-400"></div>
<div data-role="content-match-display" class="content-search-text hidden px-2 py-1 text-sm text-gray-600 bg-amber-50 border-l-2 border-amber-400"></div>
</div>
</div>
</div>

View File

@@ -57,6 +57,8 @@ export class ContentImages extends HTMLElement {
}
this.dataset.init = "true";
this._wireUpload();
const raw = this.getAttribute("data-images") || "[]";
const rawFiles = this.getAttribute("data-files") || "[]";
let images = [];
@@ -82,8 +84,62 @@ export class ContentImages extends HTMLElement {
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._uploadFiles(uploadInput, panel);
});
}
_uploadFiles(input, panel) {
const endpoint = input.getAttribute("data-upload-endpoint") || "";
const contentId = input.getAttribute("data-content-id") || "";
const csrfToken = input.getAttribute("data-csrf-token") || "";
const files = Array.from(input.files || []);
if (!endpoint || !contentId || !csrfToken || files.length === 0) {
return;
}
const payload = new FormData();
payload.append("csrf_token", csrfToken);
payload.append("content_id", contentId);
files.forEach((file) => payload.append("scans", file));
fetch(endpoint, {
method: "POST",
headers: {
"HX-Request": "true",
},
body: payload,
})
.then((response) => {
if (!response.ok) {
return null;
}
return response.text();
})
.then((html) => {
if (!html || !panel) {
return;
}
this._applyServerResponse(html, panel);
})
.catch(() => null)
.finally(() => {
input.value = "";
});
}
_render(images) {
this.classList.add("inline-flex");
this.classList.add("block");
this.style.display = "block";
this.style.width = "100%";
const list = this._ensureList();
list.innerHTML = "";
@@ -161,6 +217,11 @@ export class ContentImages extends HTMLElement {
list.appendChild(wrapper);
});
const uploadTile = this._findUploadTile();
if (uploadTile) {
list.appendChild(uploadTile);
}
const dialog = this._ensureDialog();
const fullImage = dialog.querySelector(`[data-role='${CONTENT_IMAGES_FULL_ROLE}']`);
@@ -185,12 +246,26 @@ export class ContentImages extends HTMLElement {
if (!list) {
list = document.createElement("div");
list.dataset.role = CONTENT_IMAGES_LIST_ROLE;
list.className = "inline-flex flex-wrap gap-2";
this.appendChild(list);
}
list.className = "grid gap-2";
list.style.gridTemplateColumns = "repeat(auto-fill, minmax(7rem, 1fr))";
list.style.width = "100%";
return list;
}
_findUploadTile() {
const panel = this.closest("[data-role='content-images-panel']");
if (!panel) {
return null;
}
const upload = panel.querySelector("[data-role='content-images-upload']");
if (!upload) {
return null;
}
return upload;
}
_ensureDialog() {
let dialog = this.querySelector(`[data-role='${CONTENT_IMAGES_DIALOG_ROLE}']`);
if (dialog) {
@@ -377,11 +452,35 @@ export class ContentImages extends HTMLElement {
if (!html || !panel) {
return;
}
panel.outerHTML = html;
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);
}
}
}

View File

@@ -66,6 +66,22 @@
@apply bg-stone-50;
}
body[data-dragging="true"] tool-tip {
pointer-events: none;
}
body[data-dragging="true"] .tooltip-box {
display: none !important;
}
html.dragging tool-tip {
pointer-events: none;
}
html.dragging .tooltip-box {
display: none !important;
}
* {
@apply normal-nums;
}

View File

@@ -3,6 +3,58 @@ export class ToolTip extends HTMLElement {
return ["position", "timeout"];
}
static _dragGuardInitialized = false;
static _setDragging(active) {
window.__toolTipDragging = active;
if (document.documentElement) {
document.documentElement.classList.toggle("dragging", active);
}
if (document.body) {
if (active) {
document.body.dataset.dragging = "true";
} else {
delete document.body.dataset.dragging;
}
}
if (active) {
document.querySelectorAll(".tooltip-box").forEach((box) => {
box.classList.remove("opacity-100");
box.classList.add("opacity-0");
box.classList.add("hidden");
});
}
}
static _ensureDragGuard() {
if (ToolTip._dragGuardInitialized) {
return;
}
ToolTip._dragGuardInitialized = true;
const start = (event) => {
const handle = event.target?.closest?.("[data-role='content-drag-handle']");
if (handle || event.type === "dragstart") {
ToolTip._setDragging(true);
}
};
const end = () => {
ToolTip._setDragging(false);
};
document.addEventListener("pointerdown", start, true);
document.addEventListener("mousedown", start, true);
document.addEventListener("dragstart", start, true);
document.addEventListener("pointerup", end, true);
document.addEventListener("mouseup", end, true);
document.addEventListener("pointercancel", end, true);
document.addEventListener("dragend", end, true);
document.addEventListener("drop", end, true);
window.addEventListener("blur", end);
window.addEventListener("contentsdragging", (event) => {
const active = Boolean(event.detail?.active);
ToolTip._setDragging(active);
});
}
constructor() {
super();
this._tooltipBox = null;
@@ -14,6 +66,7 @@ export class ToolTip extends HTMLElement {
}
connectedCallback() {
ToolTip._ensureDragGuard();
this.classList.add("relative", "block", "leading-none", "[&>*]:leading-normal");
this._dataTipElem = this.querySelector(".data-tip");
const tipContent = this._dataTipElem ? this._dataTipElem.innerHTML : "Tooltip";
@@ -25,6 +78,7 @@ export class ToolTip extends HTMLElement {
this._tooltipBox = document.createElement("div");
this._tooltipBox.innerHTML = tipContent;
this._tooltipBox.className = [
"tooltip-box",
"opacity-0",
"hidden",
"absolute",
@@ -78,7 +132,32 @@ export class ToolTip extends HTMLElement {
}
}
_forceHide() {
clearTimeout(this._hideTimeout);
clearTimeout(this._hiddenTimeout);
if (!this._tooltipBox) {
return;
}
this._tooltipBox.classList.remove("opacity-100");
this._tooltipBox.classList.add("opacity-0");
this._tooltipBox.classList.add("hidden");
}
_isDragging() {
if (window.__toolTipDragging) {
return true;
}
if (document.body?.dataset?.dragging === "true") {
return true;
}
return Boolean(document.querySelector("[data-dragging='true']"));
}
_showTooltip() {
if (this._isDragging()) {
this._forceHide();
return;
}
clearTimeout(this._hideTimeout);
clearTimeout(this._hiddenTimeout);
this._tooltipBox.classList.remove("hidden");