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 }} {{- if $i }},{{ end }}
"{{ $content.Id }}": { "{{ $content.Id }}": {
extent: "{{ $content.Extent }}", extent: "{{ $content.Extent }}",
pagination: "{{ $content.MusenalmPagination }}",
musenalmType: [{{- range $j, $t := $content.MusenalmType -}}{{- if $j }},{{ end }}"{{ $t }}"{{- end -}}], musenalmType: [{{- range $j, $t := $content.MusenalmType -}}{{- if $j }},{{ end }}"{{ $t }}"{{- end -}}],
preferredTitle: "{{ $content.PreferredTitle }}", preferredTitle: "{{ $content.PreferredTitle }}",
titleStmt: "{{ $content.TitleStmt }}", titleStmt: "{{ $content.TitleStmt }}",
@@ -296,7 +295,6 @@
// Field labels for display // Field labels for display
const fieldLabels = { const fieldLabels = {
extent: 'Seite', extent: 'Seite',
pagination: 'Paginierung',
musenalmType: 'Typ', musenalmType: 'Typ',
titleStmt: 'Titel', titleStmt: 'Titel',
subtitleStmt: 'Untertitel', subtitleStmt: 'Untertitel',
@@ -311,6 +309,51 @@
annotation: 'Anmerkung' 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 performFilter = () => {
const query = normalizeText(filterInput.value); const query = normalizeText(filterInput.value);
const pageQuery = normalizeText(filterPage?.value || ''); const pageQuery = normalizeText(filterPage?.value || '');
@@ -401,8 +444,6 @@
}; };
// Check text search fields (excluding language, status, preferredTitle) // 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); checkArrayField('musenalmType', contentData.musenalmType, fieldLabels.musenalmType);
checkField('titleStmt', contentData.titleStmt, fieldLabels.titleStmt); checkField('titleStmt', contentData.titleStmt, fieldLabels.titleStmt);
checkField('subtitleStmt', contentData.subtitleStmt, fieldLabels.subtitleStmt); checkField('subtitleStmt', contentData.subtitleStmt, fieldLabels.subtitleStmt);
@@ -463,6 +504,8 @@
? `${totalCount} Einträge` ? `${totalCount} Einträge`
: `${visibleCount} von ${totalCount} Einträgen`; : `${visibleCount} von ${totalCount} Einträgen`;
} }
updateMarks();
}; };
const debounceFilter = () => { const debounceFilter = () => {
@@ -522,6 +565,15 @@
} }
syncIndicator.classList.toggle("hidden", !active); 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 // Shared delete dialog
const deleteDialog = document.getElementById("content-delete-dialog"); const deleteDialog = document.getElementById("content-delete-dialog");
const deleteDialogTitle = document.getElementById("content-delete-dialog-title"); const deleteDialogTitle = document.getElementById("content-delete-dialog-title");
@@ -665,8 +717,59 @@
if (list.dataset.pageInit !== "true") { if (list.dataset.pageInit !== "true") {
list.dataset.pageInit = "true"; list.dataset.pageInit = "true";
let draggedItem = null; let draggedItem = null;
let pointerDrag = null;
let lastDragOverTime = 0; let lastDragOverTime = 0;
const DRAG_THROTTLE_MS = 100; 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) => { list.addEventListener("click", (event) => {
const moveUp = event.target.closest("[data-role='content-move-up']"); const moveUp = event.target.closest("[data-role='content-move-up']");
const moveDown = event.target.closest("[data-role='content-move-down']"); const moveDown = event.target.closest("[data-role='content-move-down']");
@@ -699,6 +802,10 @@
}); });
list.addEventListener("dragstart", (event) => { 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']")) { if (event.target.closest("[data-role='content-move-up']") || event.target.closest("[data-role='content-move-down']")) {
return; return;
} }
@@ -717,6 +824,7 @@
draggedItem = item; draggedItem = item;
item.dataset.dragging = "true"; item.dataset.dragging = "true";
draggedItem.classList.add("opacity-60"); draggedItem.classList.add("opacity-60");
setDraggingState(true);
event.dataTransfer.effectAllowed = "move"; event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", "move"); event.dataTransfer.setData("text/plain", "move");
}); });
@@ -745,8 +853,14 @@
draggedItem.dataset.dragging = ""; draggedItem.dataset.dragging = "";
} }
draggedItem = null; draggedItem = null;
setDraggingState(false);
syncOrder(); 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 -}}]'> data-initial-values='[{{- range $i, $lang := $model.content.Language -}}{{- if $i }},{{ end -}}{{ printf "%q" $lang }}{{- end -}}]'>
</multi-select-simple> </multi-select-simple>
</div> </div>
{{- template "_content_images_panel" (Dict
"content" $model.content
"entry" $model.result.Entry
"csrf_token" $model.csrf_token
"is_new" false
) -}}
</div> </div>
</div> </div>

View File

@@ -4,30 +4,18 @@
{{- $isNew := index . "is_new" -}} {{- $isNew := index . "is_new" -}}
{{- if or $content.ImagePaths (not $isNew) -}} {{- 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="w-full" data-role="content-images-panel">
<div class="flex flex-wrap items-start gap-2"> <div class="flex flex-col items-start gap-2">
{{- if $content.ImagePaths -}} <content-images
<content-images class="w-full"
data-images='[{{- range $i, $scan := $content.ImagePaths -}}{{- if $i }},{{ end -}}{{ printf "%q" $scan }}{{- end -}}]' 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-files='[{{- range $i, $scan := $content.Scans -}}{{- if $i }},{{ end -}}{{ printf "%q" $scan }}{{- end -}}]'
data-delete-endpoint="/almanach/{{ $entry.MusenalmID }}/contents/scan/delete" data-delete-endpoint="/almanach/{{ $entry.MusenalmID }}/contents/scan/delete"
data-content-id="{{ $content.Id }}" data-content-id="{{ $content.Id }}"
data-csrf-token="{{ $csrf }}"> data-csrf-token="{{ $csrf }}">
</content-images> </content-images>
{{- end -}}
{{- if not $isNew -}} {{- if not $isNew -}}
<form <div class="flex" data-role="content-images-upload">
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 <label
for="content-{{ $content.Id }}-scan-upload" 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" 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" name="scans"
multiple multiple
accept="image/*" accept="image/*"
class="sr-only" /> class="sr-only"
</form> 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 -}} {{- end -}}
</div> </div>
</div> </div>

View File

@@ -37,21 +37,25 @@
</tool-tip> </tool-tip>
</div> </div>
{{- if $content.Extent -}} {{- 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 -}} {{- end -}}
{{- if $content.MusenalmType -}} {{- if $content.MusenalmType -}}
<span class="flex flex-nowrap gap-1 text-gray-700 font-normal overflow-hidden"> <span class="flex flex-nowrap gap-1 text-gray-700 font-normal overflow-hidden">
{{- range $i, $t := $content.MusenalmType -}} {{- 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 -}} {{- end -}}
</span> </span>
{{- end -}} {{- 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"> <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">
{{- if $content.PreferredTitle -}} <a
<span class="text-lg font-normal truncate min-w-0 overflow-hidden" data-role="content-header-title-text">{{- $content.PreferredTitle -}}</span> href="/almanach/{{ $entry.MusenalmID }}/contents/{{ $content.MusenalmID }}/edit"
{{- else if $content.TitleStmt -}} class="no-underline hover:text-slate-900 cursor-pointer">
<span class="text-lg font-normal italic truncate min-w-0 overflow-hidden" data-role="content-header-title-text">{{- $content.TitleStmt -}}</span> {{- if $content.PreferredTitle -}}
{{- end -}} <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="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> </div>
<div class="flex items-center gap-2 flex-nowrap whitespace-nowrap shrink-0"> <div class="flex items-center gap-2 flex-nowrap whitespace-nowrap shrink-0">
@@ -79,7 +83,7 @@
</button> </button>
</div> </div>
</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> </div>
</div> </div>

View File

@@ -57,6 +57,8 @@ export class ContentImages extends HTMLElement {
} }
this.dataset.init = "true"; this.dataset.init = "true";
this._wireUpload();
const raw = this.getAttribute("data-images") || "[]"; const raw = this.getAttribute("data-images") || "[]";
const rawFiles = this.getAttribute("data-files") || "[]"; const rawFiles = this.getAttribute("data-files") || "[]";
let images = []; let images = [];
@@ -82,8 +84,62 @@ export class ContentImages extends HTMLElement {
this._render(normalized); 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) { _render(images) {
this.classList.add("inline-flex"); this.classList.add("block");
this.style.display = "block";
this.style.width = "100%";
const list = this._ensureList(); const list = this._ensureList();
list.innerHTML = ""; list.innerHTML = "";
@@ -161,6 +217,11 @@ export class ContentImages extends HTMLElement {
list.appendChild(wrapper); list.appendChild(wrapper);
}); });
const uploadTile = this._findUploadTile();
if (uploadTile) {
list.appendChild(uploadTile);
}
const dialog = this._ensureDialog(); const dialog = this._ensureDialog();
const fullImage = dialog.querySelector(`[data-role='${CONTENT_IMAGES_FULL_ROLE}']`); const fullImage = dialog.querySelector(`[data-role='${CONTENT_IMAGES_FULL_ROLE}']`);
@@ -185,12 +246,26 @@ export class ContentImages extends HTMLElement {
if (!list) { if (!list) {
list = document.createElement("div"); list = document.createElement("div");
list.dataset.role = CONTENT_IMAGES_LIST_ROLE; list.dataset.role = CONTENT_IMAGES_LIST_ROLE;
list.className = "inline-flex flex-wrap gap-2";
this.appendChild(list); this.appendChild(list);
} }
list.className = "grid gap-2";
list.style.gridTemplateColumns = "repeat(auto-fill, minmax(7rem, 1fr))";
list.style.width = "100%";
return list; 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() { _ensureDialog() {
let dialog = this.querySelector(`[data-role='${CONTENT_IMAGES_DIALOG_ROLE}']`); let dialog = this.querySelector(`[data-role='${CONTENT_IMAGES_DIALOG_ROLE}']`);
if (dialog) { if (dialog) {
@@ -377,11 +452,35 @@ export class ContentImages extends HTMLElement {
if (!html || !panel) { if (!html || !panel) {
return; return;
} }
panel.outerHTML = html; this._applyServerResponse(html, panel);
}) })
.catch(() => null) .catch(() => null)
.finally(() => { .finally(() => {
dialog.close(); 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; @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; @apply normal-nums;
} }

View File

@@ -3,6 +3,58 @@ export class ToolTip extends HTMLElement {
return ["position", "timeout"]; 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() { constructor() {
super(); super();
this._tooltipBox = null; this._tooltipBox = null;
@@ -14,6 +66,7 @@ export class ToolTip extends HTMLElement {
} }
connectedCallback() { connectedCallback() {
ToolTip._ensureDragGuard();
this.classList.add("relative", "block", "leading-none", "[&>*]:leading-normal"); this.classList.add("relative", "block", "leading-none", "[&>*]:leading-normal");
this._dataTipElem = this.querySelector(".data-tip"); this._dataTipElem = this.querySelector(".data-tip");
const tipContent = this._dataTipElem ? this._dataTipElem.innerHTML : "Tooltip"; const tipContent = this._dataTipElem ? this._dataTipElem.innerHTML : "Tooltip";
@@ -25,6 +78,7 @@ export class ToolTip extends HTMLElement {
this._tooltipBox = document.createElement("div"); this._tooltipBox = document.createElement("div");
this._tooltipBox.innerHTML = tipContent; this._tooltipBox.innerHTML = tipContent;
this._tooltipBox.className = [ this._tooltipBox.className = [
"tooltip-box",
"opacity-0", "opacity-0",
"hidden", "hidden",
"absolute", "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() { _showTooltip() {
if (this._isDragging()) {
this._forceHide();
return;
}
clearTimeout(this._hideTimeout); clearTimeout(this._hideTimeout);
clearTimeout(this._hiddenTimeout); clearTimeout(this._hiddenTimeout);
this._tooltipBox.classList.remove("hidden"); this._tooltipBox.classList.remove("hidden");