+Mor almanach edit, finished

This commit is contained in:
Simon Martens
2026-01-09 13:04:18 +01:00
parent 69d8ec71b3
commit 5b75456439
7 changed files with 1691 additions and 1184 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -105,7 +105,8 @@ type AlmanachResult struct {
x-target="changealmanachform user-message almanach-header-data"
hx-boost="false"
method="POST"
data-save-endpoint="/almanach/{{ $model.result.Entry.MusenalmID }}/edit/save">
data-save-endpoint="/almanach/{{ $model.result.Entry.MusenalmID }}/edit/save"
data-delete-endpoint="/almanach/{{ $model.result.Entry.MusenalmID }}/edit/delete">
<input type="hidden" name="csrf_token" value="{{ $model.csrf_token }}" />
<input type="hidden" name="last_edited" value="{{ $model.result.Entry.Updated }}" />
@@ -282,7 +283,7 @@ type AlmanachResult struct {
</div>
<hr class="border-slate-400 mt-2 mb-3" />
<div class="mt-3">
<relations-editor data-prefix="entries_series" data-link-base="/reihe/" data-new-label="(Neu)" data-add-toggle-id="series-add-toggle">
<relations-editor data-prefix="entries_series" data-link-base="/reihe/" data-new-label="(Neu)" data-add-toggle-id="series-add-toggle" data-preferred-label="Bevorzugter Reihentitel">
<div class="inputwrapper">
<label class="inputlabel" for="series-section">Reihen</label>
<div id="series-section" class="rel-section-container">
@@ -887,11 +888,46 @@ type AlmanachResult struct {
</div>
<div class="w-full flex items-end justify-between gap-4 mt-6 flex-wrap">
<p id="almanach-save-feedback" class="text-sm text-gray-600" aria-live="polite"></p>
<button type="button" class="submitbutton flex items-center gap-2 self-end" data-role="almanach-save">
<i class="ri-save-line"></i>
<span>Speichern</span>
</button>
<div class="flex items-center gap-3 self-end flex-wrap">
<a href="/almanach/{{ $model.result.Entry.MusenalmID }}" class="resetbutton w-40 flex items-center gap-2 justify-center">
<i class="ri-close-line"></i>
<span>Abbrechen</span>
</a>
<button type="button" class="resetbutton w-40 flex items-center gap-2 justify-center" data-role="almanach-reset">
<i class="ri-loop-left-line"></i>
<span>Reset</span>
</button>
<button
type="button"
class="resetbutton w-40 flex items-center gap-2 justify-center bg-red-50 text-red-800 hover:bg-red-100 hover:text-red-900"
data-role="almanach-delete">
<i class="ri-delete-bin-line"></i>
<span>Eintrag löschen</span>
</button>
<button type="button" class="submitbutton w-40 flex items-center gap-2 justify-center" data-role="almanach-save">
<i class="ri-save-line"></i>
<span>Speichern</span>
</button>
</div>
</div>
<dialog data-role="almanach-delete-dialog" class="fixed inset-0 m-auto rounded-md border border-slate-200 p-0 shadow-xl backdrop:bg-black/40">
<div class="p-5 w-[22rem]">
<div class="text-base font-bold text-gray-900">Eintrag löschen?</div>
<div class="text-sm font-bold text-gray-900 mt-1">{{ $model.result.Entry.PreferredTitle }}</div>
<p class="text-sm text-gray-700 mt-2">
Der Eintrag wird dauerhaft gelöscht. Verknüpfungen, Exemplare und Inhalte werden 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="almanach-delete-cancel">Abbrechen</button>
<button
type="button"
class="submitbutton w-auto bg-red-700 hover:bg-red-800 px-3 py-1 text-sm"
data-role="almanach-delete-confirm">
Löschen
</button>
</div>
</div>
</dialog>
</form>
</div>
</almanach-edit-page>

View File

@@ -6,10 +6,20 @@ export class AlmanachEditPage extends HTMLElement {
this._pendingAgent = null;
this._form = null;
this._saveButton = null;
this._resetButton = null;
this._deleteButton = null;
this._deleteDialog = null;
this._deleteConfirmButton = null;
this._deleteCancelButton = null;
this._statusEl = null;
this._saveEndpoint = "";
this._deleteEndpoint = "";
this._isSaving = false;
this._handleSaveClick = this._handleSaveClick.bind(this);
this._handleResetClick = this._handleResetClick.bind(this);
this._handleDeleteClick = this._handleDeleteClick.bind(this);
this._handleDeleteConfirmClick = this._handleDeleteConfirmClick.bind(this);
this._handleDeleteCancelClick = this._handleDeleteCancelClick.bind(this);
}
connectedCallback() {
@@ -86,19 +96,60 @@ export class AlmanachEditPage extends HTMLElement {
this._teardownSaveHandling();
this._form = this.querySelector("#changealmanachform");
this._saveButton = this.querySelector("[data-role='almanach-save']");
this._resetButton = this.querySelector("[data-role='almanach-reset']");
this._deleteButton = this.querySelector("[data-role='almanach-delete']");
this._deleteDialog = this.querySelector("[data-role='almanach-delete-dialog']");
this._deleteConfirmButton = this.querySelector("[data-role='almanach-delete-confirm']");
this._deleteCancelButton = this.querySelector("[data-role='almanach-delete-cancel']");
this._statusEl = this.querySelector("#almanach-save-feedback");
if (!this._form || !this._saveButton) {
return;
}
this._saveEndpoint = this._form.getAttribute("data-save-endpoint") || this._deriveSaveEndpoint();
this._deleteEndpoint = this._form.getAttribute("data-delete-endpoint") || "";
this._saveButton.addEventListener("click", this._handleSaveClick);
if (this._resetButton) {
this._resetButton.addEventListener("click", this._handleResetClick);
}
if (this._deleteButton) {
this._deleteButton.addEventListener("click", this._handleDeleteClick);
}
if (this._deleteConfirmButton) {
this._deleteConfirmButton.addEventListener("click", this._handleDeleteConfirmClick);
}
if (this._deleteCancelButton) {
this._deleteCancelButton.addEventListener("click", this._handleDeleteCancelClick);
}
if (this._deleteDialog) {
this._deleteDialog.addEventListener("cancel", this._handleDeleteCancelClick);
}
}
_teardownSaveHandling() {
if (this._saveButton) {
this._saveButton.removeEventListener("click", this._handleSaveClick);
}
if (this._resetButton) {
this._resetButton.removeEventListener("click", this._handleResetClick);
}
if (this._deleteButton) {
this._deleteButton.removeEventListener("click", this._handleDeleteClick);
}
if (this._deleteConfirmButton) {
this._deleteConfirmButton.removeEventListener("click", this._handleDeleteConfirmClick);
}
if (this._deleteCancelButton) {
this._deleteCancelButton.removeEventListener("click", this._handleDeleteCancelClick);
}
if (this._deleteDialog) {
this._deleteDialog.removeEventListener("cancel", this._handleDeleteCancelClick);
}
this._saveButton = null;
this._resetButton = null;
this._deleteButton = null;
this._deleteDialog = null;
this._deleteConfirmButton = null;
this._deleteCancelButton = null;
this._statusEl = null;
}
@@ -157,6 +208,81 @@ export class AlmanachEditPage extends HTMLElement {
}
}
async _handleResetClick(event) {
event.preventDefault();
if (this._isSaving) {
return;
}
this._clearStatus();
try {
await this._reloadForm("");
} catch (error) {
this._showStatus(error instanceof Error ? error.message : "Formular konnte nicht aktualisiert werden.", "error");
}
}
async _handleDeleteClick(event) {
event.preventDefault();
if (this._isSaving) {
return;
}
if (this._deleteDialog && typeof this._deleteDialog.showModal === "function") {
this._deleteDialog.showModal();
}
}
_handleDeleteCancelClick(event) {
if (event) {
event.preventDefault();
}
if (this._deleteDialog && this._deleteDialog.open) {
this._deleteDialog.close();
}
}
async _handleDeleteConfirmClick(event) {
event.preventDefault();
if (!this._form || !this._deleteEndpoint || this._isSaving) {
return;
}
if (this._deleteDialog && this._deleteDialog.open) {
this._deleteDialog.close();
}
this._clearStatus();
this._setSavingState(true);
try {
const formData = new FormData(this._form);
const payload = {
csrf_token: this._readValue(formData, "csrf_token"),
last_edited: this._readValue(formData, "last_edited"),
};
const response = await fetch(this._deleteEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(payload),
});
let data = null;
try {
data = await response.clone().json();
} catch {
data = null;
}
if (!response.ok) {
const message = data?.error || `Löschen fehlgeschlagen (${response.status}).`;
throw new Error(message);
}
const redirect = data?.redirect || "/suche/baende";
window.location.assign(redirect);
} catch (error) {
this._showStatus(error instanceof Error ? error.message : "Löschen fehlgeschlagen.", "error");
} finally {
this._setSavingState(false);
}
}
_buildPayload() {
if (!this._form) {
throw new Error("Formular konnte nicht gefunden werden.");
@@ -206,12 +332,15 @@ export class AlmanachEditPage extends HTMLElement {
targetField: "series",
});
const newSeriesRelations = this._collectNewRelations("entries_series");
const hasPreferredSeries = [...seriesRelations, ...newSeriesRelations].some(
const preferredCount = [...seriesRelations, ...newSeriesRelations].filter(
(relation) => relation.type === PREFERRED_SERIES_RELATION,
);
if (!hasPreferredSeries) {
).length;
if (preferredCount === 0) {
throw new Error("Mindestens ein bevorzugter Reihentitel muss verknüpft sein.");
}
if (preferredCount > 1) {
throw new Error("Es darf nur ein bevorzugter Reihentitel gesetzt sein.");
}
const {
relations: agentRelations,
@@ -375,6 +504,12 @@ export class AlmanachEditPage extends HTMLElement {
if (label) {
label.textContent = isSaving ? "Speichern..." : "Speichern";
}
if (this._resetButton) {
this._resetButton.disabled = isSaving;
}
if (this._deleteButton) {
this._deleteButton.disabled = isSaving;
}
}
_clearStatus() {

View File

@@ -253,6 +253,12 @@ export class DivManager extends HTMLElement {
// Small delay to ensure element is visible before measuring
setTimeout(() => {
textareas.forEach((textarea) => {
if (textarea.dataset.dmResizeBound !== "true") {
textarea.dataset.dmResizeBound = "true";
textarea.addEventListener("input", () => {
window.TextareaAutoResize(textarea);
});
}
window.TextareaAutoResize(textarea);
});
}, 10);

View File

@@ -24,9 +24,11 @@ export class RelationsEditor extends HTMLElement {
this._linkBase = this.getAttribute("data-link-base") || "";
this._newLabel = this.getAttribute("data-new-label") || "(Neu)";
this._addToggleId = this.getAttribute("data-add-toggle-id") || "";
this._preferredLabel = (this.getAttribute("data-preferred-label") || "").trim();
this._emptyText = this.querySelector(".rel-empty-text");
this._setupAddPanel();
this._setupDeleteToggles();
this._setupPreferredOptionHandling();
}
_getExistingIds() {
@@ -230,6 +232,7 @@ export class RelationsEditor extends HTMLElement {
typeSelect.innerHTML = this._typeSelect.innerHTML;
typeSelect.value = this._typeSelect.value;
typeSelect.name = `${this._prefix}_new_type`;
typeSelect.addEventListener("change", () => this._updatePreferredOptions());
}
const uncertain = fragment.querySelector("[data-rel-input='uncertain']");
@@ -271,6 +274,7 @@ export class RelationsEditor extends HTMLElement {
this._addPanel.classList.add("hidden");
}
this._updateEmptyTextVisibility();
this._updatePreferredOptions();
}
_setupDeleteToggles() {
@@ -328,6 +332,8 @@ export class RelationsEditor extends HTMLElement {
icon.classList.remove("ri-arrow-go-back-line");
}
}
this._updatePreferredOptions();
});
button.addEventListener("mouseenter", () => {
@@ -374,4 +380,79 @@ export class RelationsEditor extends HTMLElement {
});
});
}
_setupPreferredOptionHandling() {
if (this._prefix !== "entries_series" || !this._preferredLabel) {
return;
}
this.querySelectorAll(`select[name^="${this._prefix}_type["]`).forEach((select) => {
select.addEventListener("change", () => this._updatePreferredOptions());
});
if (this._typeSelect) {
this._typeSelect.addEventListener("change", () => this._updatePreferredOptions());
}
this._updatePreferredOptions();
}
_updatePreferredOptions() {
if (this._prefix !== "entries_series" || !this._preferredLabel) {
return;
}
const preferredLabel = this._preferredLabel.trim();
const selects = [];
this.querySelectorAll(`select[name^="${this._prefix}_type["]`).forEach((select) => {
selects.push({ select, row: select.closest(ROLE_REL_ROW), isAddPanel: false });
});
if (this._addRow) {
this._addRow.querySelectorAll(`select[name='${this._prefix}_new_type']`).forEach((select) => {
selects.push({ select, row: select.closest(ROLE_REL_ROW), isAddPanel: false });
});
}
if (this._typeSelect) {
selects.push({ select: this._typeSelect, row: this._typeSelect.closest(ROLE_REL_ROW), isAddPanel: true });
}
const hasPreferred = selects.some(({ select, row, isAddPanel }) => {
if (isAddPanel) {
return false;
}
const currentValue = (select?.value || "").trim();
if (!select || currentValue !== preferredLabel) {
return false;
}
if (!row) {
return true;
}
const deleteInput = row.querySelector(`input[name^="${this._prefix}_delete["]`);
return !(deleteInput && deleteInput.checked);
});
selects.forEach(({ select, row, isAddPanel }) => {
if (!select) {
return;
}
const option = Array.from(select.options).find((opt) => opt.value.trim() === preferredLabel);
if (!option) {
return;
}
const deleteInput = row ? row.querySelector(`input[name^="${this._prefix}_delete["]`) : null;
const rowDeleted = Boolean(deleteInput && deleteInput.checked);
const currentValue = (select.value || "").trim();
const keepVisible = !hasPreferred || (currentValue === preferredLabel && !rowDeleted);
if (isAddPanel && hasPreferred && currentValue === preferredLabel) {
const fallback = Array.from(select.options).find((opt) => opt.value.trim() !== preferredLabel);
if (fallback) {
select.value = fallback.value;
}
}
const shouldHide = !keepVisible || (isAddPanel && hasPreferred);
option.hidden = shouldHide;
option.disabled = shouldHide;
option.style.display = shouldHide ? "none" : "";
});
}
}