Some frontend validation logic

This commit is contained in:
Simon Martens
2026-01-22 16:46:31 +01:00
parent 1749d0e224
commit 17ab271de3
13 changed files with 2787 additions and 1532 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

@@ -17,6 +17,22 @@ type AlmanachResult struct {
}
-->
{{- $preferredSeriesTitle := "" -}}
{{- $preferredSeriesId := "" -}}
{{- $preferredSeriesMusenalmID := "" -}}
{{- $preferredRelationId := "" -}}
{{- if and $model.result $model.result.Series -}}
{{- range $i, $s := $model.result.Series -}}
{{- $rel := index $model.result.EntriesSeries $s.Id -}}
{{- if and $rel (eq $rel.Type "Bevorzugter Reihentitel") -}}
{{- $preferredSeriesTitle = $s.Title -}}
{{- $preferredSeriesId = $s.Id -}}
{{- $preferredSeriesMusenalmID = $s.MusenalmID -}}
{{- $preferredRelationId = $rel.Id -}}
{{- end -}}
{{- end -}}
{{- end -}}
<almanach-edit-page>
<duplicate-warning-checker></duplicate-warning-checker>
@@ -187,18 +203,62 @@ type AlmanachResult struct {
<div class="inputwrapper">
<div class="inputlabelrow">
<div class="flex items-center gap-1">
<label for="preferred_title" class="inputlabel">Kurztitel</label>
<label for="preferred-title-field-input" class="inputlabel">Kurztitel</label>
<tool-tip position="top" class="!inline">
<div class="data-tip">{{ help "entries" "preferred_title" }}</div>
<i class="ri-question-line"></i>
</tool-tip>
</div>
</div>
<textarea name="preferred_title" id="preferred_title" class="inputinput no-enter" placeholder="" required autocomplete="off" rows="1" data-duplicate-check data-duplicate-endpoint="/api/entries/search" data-duplicate-result-key="entries" data-duplicate-current-id="{{ if not $model.is_new }}{{ $model.result.Entry.Id }}{{ end }}">{{- $model.result.Entry.PreferredTitle -}}</textarea>
<div class="duplicate-warning hidden" data-duplicate-warning-for="preferred_title">
<i class="ri-information-line"></i>
<span data-duplicate-count></span>
<lookup-field
id="preferred-title-field"
name="preferred_title"
data-multiline="true"
data-no-enter="true"
data-required="true"
data-autocomplete="false"
data-valid-fn="lookupRequiredText"
data-dup-endpoint="/api/entries/search"
data-dup-result-key="entries"
data-dup-current-id="{{ if not $model.is_new }}{{ $model.result.Entry.Id }}{{ end }}"
value="{{- $model.result.Entry.PreferredTitle -}}">
</lookup-field>
</div>
<div class="inputwrapper">
<div class="inputlabelrow">
<div class="flex items-center gap-1">
<label for="preferred-series-field-input" class="inputlabel">Reihentitel</label>
<tool-tip position="top" class="!inline">
<div class="data-tip">{{ helpOr "entries" "series" "Bevorzugter Reihentitel für diesen Almanach." }}</div>
<i class="ri-question-line"></i>
</tool-tip>
</div>
<div class="flex items-center gap-3 ml-auto pr-2">
<a href="/reihen/new/" class="text-sm font-bold text-gray-700 hover:text-slate-950 no-underline" target="_blank" rel="noreferrer">
<i class="ri-add-line"></i> Neue Reihe anlegen
</a>
</div>
</div>
<lookup-field
id="preferred-series-field"
name="preferred_series_id"
data-value-name="preferred_series_id"
data-text-name=""
data-valid-fn="lookupRequiredId"
data-endpoint="/api/series/search"
data-result-key="series"
data-minchars="1"
data-limit="15"
data-link-fn="lookupSeriesLink"
data-value-fn="lookupSeriesValue"
data-required="true"
data-initial-id="{{ $preferredSeriesId }}"
data-initial-name="{{ $preferredSeriesTitle }}"
data-initial-musenalm-id="{{ $preferredSeriesMusenalmID }}"
data-preferred-relation-id="{{ $preferredRelationId }}"
data-preferred-series-id="{{ $preferredSeriesId }}">
</lookup-field>
</div>
<div class="mt-3">
@@ -438,7 +498,7 @@ type AlmanachResult struct {
{{- if $model.result.Series -}}
{{- range $i, $s := $model.result.Series -}}
{{- $rel := index $model.result.EntriesSeries $s.Id -}}
{{- if $rel -}}
{{- if and $rel (ne $rel.Type "Bevorzugter Reihentitel") -}}
<div data-rel-row class="entries-series-row rel-row">
<div class="rel-grid">
<div data-rel-strike class="relation-strike rel-name-col">
@@ -452,7 +512,9 @@ type AlmanachResult struct {
<div data-rel-strike class="relation-strike">
<select name="entries_series_type[{{ $rel.Id }}]" id="entries_series_type_{{ $rel.Id }}" autocomplete="off" class="inputselect font-bold w-full">
{{- range $t := $model.series_relations -}}
<option value="{{- $t -}}" {{ if eq $rel.Type $t }}selected{{ end }}>{{- $t -}}</option>
{{- if ne $t "Bevorzugter Reihentitel" -}}
<option value="{{- $t -}}" {{ if eq $rel.Type $t }}selected{{ end }}>{{- $t -}}</option>
{{- end -}}
{{- end -}}
</select>
</div>
@@ -505,7 +567,9 @@ type AlmanachResult struct {
<label for="entries_series_new_type" class="sr-only">Beziehung</label>
<select data-role="relation-type-select" name="entries_series_new_type" id="entries_series_new_type" autocomplete="off" class="inputselect font-bold w-full">
{{- range $t := $model.series_relations -}}
<option value="{{- $t -}}">{{- $t -}}</option>
{{- if ne $t "Bevorzugter Reihentitel" -}}
<option value="{{- $t -}}">{{- $t -}}</option>
{{- end -}}
{{- end -}}
</select>
</div>
@@ -1142,9 +1206,9 @@ type AlmanachResult struct {
</div>
<!-- End Right Column -->
</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>
<div class="flex items-center gap-3 self-end flex-wrap">
<div class="w-full flex flex-col gap-3 mt-6 items-end">
<p id="almanach-save-feedback" class="save-feedback hidden text-right" aria-live="polite"></p>
<div class="flex items-center gap-3 flex-wrap justify-end">
<a href="{{ if $model.is_new }}/suche/baende{{ else }}/almanach/{{ $model.result.Entry.MusenalmID }}{{ end }}" class="resetbutton w-40 flex items-center gap-2 justify-center">
<i class="ri-close-line"></i>
<span>Abbrechen</span>

View File

@@ -12,31 +12,28 @@
<div data-role="content-item" class="relative odd:bg-stone-100" data-content-id="{{ $contentID }}">
<div data-role="content-view">
<div class="bg-transparent overflow-visible">
<div class="flex items-center justify-between gap-3 bg-transparent px-2 py-0 flex-nowrap whitespace-nowrap" data-content-header="true">
<div class="flex items-center gap-2 text-sm font-bold text-gray-800 flex-1 min-w-0 flex-nowrap whitespace-nowrap">
<div class="flex items-center justify-between gap-2 bg-transparent px-2 py-0.5 flex-nowrap whitespace-nowrap" data-content-header="true">
<div class="flex items-center gap-1.5 text-sm font-bold text-gray-800 flex-1 min-w-0 flex-nowrap whitespace-nowrap">
<div class="flex items-center gap-1">
<button
type="button"
class="text-slate-600 rounded-sm px-2 py-1 text-sm cursor-grab"
class="text-slate-600 rounded-sm px-1 py-0.5 text-xs cursor-grab"
data-role="content-drag-handle"
draggable="true"
aria-label="Beitrag verschieben">
<i class="ri-draggable"></i>
<i class="ri-draggable text-xs"></i>
</button>
<button type="button" class="text-slate-600 rounded-sm px-2 py-1 text-sm transition-colors hover:bg-stone-300" data-role="content-move-up" aria-label="Beitrag nach oben">
<i class="ri-arrow-up-line"></i>
<button type="button" class="text-slate-600 rounded-sm px-1 py-0.5 text-xs transition-colors hover:bg-stone-300" data-role="content-move-up" aria-label="Beitrag nach oben">
<i class="ri-arrow-up-line text-xs"></i>
</button>
<button type="button" class="text-slate-600 rounded-sm px-2 py-1 text-sm transition-colors hover:bg-stone-300" data-role="content-move-down" aria-label="Beitrag nach unten">
<i class="ri-arrow-down-line"></i>
<button type="button" class="text-slate-600 rounded-sm px-1 py-0.5 text-xs transition-colors hover:bg-stone-300" data-role="content-move-down" aria-label="Beitrag nach unten">
<i class="ri-arrow-down-line text-xs"></i>
</button>
</div>
{{- if $content.Extent -}}
<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="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>
<span class="content-search-text bg-slate-200 text-slate-900 px-1.5 py-0.5 rounded text-xs font-semibold shadow-sm" data-role="content-type-pill">{{- $t -}}</span>
{{- end -}}
</span>
{{- end -}}
@@ -52,28 +49,42 @@
</a>
</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>
<div class="flex items-center gap-1.5 flex-nowrap whitespace-nowrap shrink-0">
{{- $scans := $content.ImagePaths -}}
{{- if $scans -}}
<span class="inline-flex items-center gap-1 mr-2.5">
{{- range $i, $scan := $scans -}}
{{- if lt $i 3 -}}
<popup-image data-image-url="{{- $scan -}}" class="inline-flex items-center">
<img
src="{{- $scan -}}"
alt="Scan Vorschau"
class="h-5 w-5 rounded-full object-cover border border-slate-500 shadow-sm" />
</popup-image>
{{- end -}}
{{- end -}}
</span>
{{- end -}}
{{- if or $content.Extent (not $scans) -}}
<span class="content-search-text {{ if $content.Extent }}bg-blue-900 text-white shadow-sm{{ else }}bg-transparent text-transparent shadow-none{{ end }} px-1.5 py-0.5 rounded-xs text-xs font-semibold shrink-0 inline-flex items-center justify-end min-w-[5ch]" data-role="content-page-pill">
{{- if $content.Extent -}}S.&thinsp;{{- $content.Extent -}}{{- else -}}&nbsp;&nbsp;&nbsp;{{- end -}}
</span>
{{- end -}}
<span class="status-badge text-xs shadow-sm ml-2" data-status="{{ $content.EditState }}">
<i class="status-icon text-xs {{- 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>
</span>
<a
href="/almanach/{{ $entry.MusenalmID }}/contents/{{ $content.MusenalmID }}/edit"
class="resetbutton w-9 h-9 flex items-center justify-center rounded-sm cursor-pointer hover:bg-stone-300"
class="resetbutton w-8 h-8 flex items-center justify-center rounded-sm cursor-pointer hover:bg-stone-300"
aria-label="Beitrag bearbeiten">
<i class="ri-edit-2-line"></i>
<i class="ri-edit-2-line text-sm"></i>
</a>
<button
type="button"
class="resetbutton w-9 h-9 flex items-center justify-center text-red-700 hover:text-red-900 hover:bg-red-100 rounded-sm"
class="resetbutton w-8 h-8 flex items-center justify-center text-red-700 hover:text-red-900 hover:bg-red-100 rounded-sm"
data-role="content-delete-view"
aria-label="Beitrag löschen">
<i class="ri-delete-bin-line"></i>
<i class="ri-delete-bin-line text-sm"></i>
</button>
</div>
</div>

View File

@@ -130,11 +130,19 @@
</tool-tip>
</div>
</div>
<textarea name="name" id="name" class="inputinput no-enter" autocomplete="off" rows="1" data-duplicate-check data-duplicate-endpoint="/api/places/search" data-duplicate-result-key="places" data-duplicate-current-id="{{ if not $model.is_new }}{{ $place.Id }}{{ end }}">{{- $place.Name -}}</textarea>
<div class="duplicate-warning hidden" data-duplicate-warning-for="name">
<i class="ri-information-line"></i>
<span data-duplicate-count></span>
</div>
<lookup-field
id="place-name-field"
name="name"
data-multiline="true"
data-no-enter="true"
data-required="true"
data-autocomplete="false"
data-valid-fn="lookupRequiredText"
data-dup-endpoint="/api/places/search"
data-dup-result-key="places"
data-dup-current-id="{{ if not $model.is_new }}{{ $place.Id }}{{ end }}"
value="{{- $place.Name -}}">
</lookup-field>
</div>
<div class="inputwrapper">
<div class="inputlabelrow">

View File

@@ -132,11 +132,19 @@
</tool-tip>
</div>
</div>
<textarea name="name" id="name" class="inputinput no-enter" autocomplete="off" rows="1" data-duplicate-check data-duplicate-endpoint="/api/agents/search" data-duplicate-result-key="agents" data-duplicate-current-id="{{ if not $model.is_new }}{{ $agent.Id }}{{ end }}">{{- $agent.Name -}}</textarea>
<div class="duplicate-warning hidden" data-duplicate-warning-for="name">
<i class="ri-information-line"></i>
<span data-duplicate-count></span>
</div>
<lookup-field
id="person-name-field"
name="name"
data-multiline="true"
data-no-enter="true"
data-required="true"
data-autocomplete="false"
data-valid-fn="lookupRequiredText"
data-dup-endpoint="/api/agents/search"
data-dup-result-key="agents"
data-dup-current-id="{{ if not $model.is_new }}{{ $agent.Id }}{{ end }}"
value="{{- $agent.Name -}}">
</lookup-field>
</div>
<div class="inputwrapper">
<div class="inputlabelrow">

View File

@@ -132,11 +132,19 @@
</tool-tip>
</div>
</div>
<textarea name="title" id="title" class="inputinput no-enter" autocomplete="off" rows="1" data-duplicate-check data-duplicate-endpoint="/api/series/search" data-duplicate-result-key="series" data-duplicate-current-id="{{ if not $model.is_new }}{{ $series.Id }}{{ end }}">{{- $series.Title -}}</textarea>
<div class="duplicate-warning hidden" data-duplicate-warning-for="title">
<i class="ri-information-line"></i>
<span data-duplicate-count></span>
</div>
<lookup-field
id="series-title-field"
name="title"
data-multiline="true"
data-no-enter="true"
data-required="true"
data-autocomplete="false"
data-valid-fn="lookupRequiredText"
data-dup-endpoint="/api/series/search"
data-dup-result-key="series"
data-dup-current-id="{{ if not $model.is_new }}{{ $series.Id }}{{ end }}"
value="{{- $series.Title -}}">
</lookup-field>
</div>
<div class="flex flex-row gap-3">
<div class="inputwrapper grow">

View File

@@ -15,6 +15,8 @@ export class AlmanachEditPage extends HTMLElement {
this._saveEndpoint = "";
this._deleteEndpoint = "";
this._isSaving = false;
this._preferredSeriesRelationId = "";
this._preferredSeriesSeriesId = "";
this._handleSaveClick = this._handleSaveClick.bind(this);
this._handleResetClick = this._handleResetClick.bind(this);
this._handleDeleteClick = this._handleDeleteClick.bind(this);
@@ -27,6 +29,7 @@ export class AlmanachEditPage extends HTMLElement {
setTimeout(() => {
this._initForm();
this._initPlaces();
this._initPreferredSeries();
this._initSaveHandling();
this._initStatusSelect();
}, 0);
@@ -171,6 +174,16 @@ export class AlmanachEditPage extends HTMLElement {
}
}
_initPreferredSeries() {
const preferredSelect = this.querySelector("#preferred-series-field");
if (!preferredSelect) {
return;
}
this._preferredSeriesRelationId = preferredSelect.getAttribute("data-preferred-relation-id") || "";
this._preferredSeriesSeriesId = preferredSelect.getAttribute("data-preferred-series-id") || "";
}
_teardownSaveHandling() {
if (this._saveButton) {
this._saveButton.removeEventListener("click", this._handleSaveClick);
@@ -383,6 +396,54 @@ export class AlmanachEditPage extends HTMLElement {
targetField: "series",
});
const newSeriesRelations = this._collectNewRelations("entries_series");
const preferredSeriesId = this._readValue(formData, "preferred_series_id");
if (!preferredSeriesId) {
throw new Error("Reihentitel ist erforderlich.");
}
const applyPreferred = (relation) => {
relation.type = PREFERRED_SERIES_RELATION;
relation.uncertain = false;
};
let preferredApplied = false;
seriesRelations.forEach((relation) => {
if (relation.target_id === preferredSeriesId) {
applyPreferred(relation);
preferredApplied = true;
}
});
newSeriesRelations.forEach((relation) => {
if (relation.target_id === preferredSeriesId) {
applyPreferred(relation);
preferredApplied = true;
}
});
if (!preferredApplied) {
if (this._preferredSeriesRelationId && this._preferredSeriesSeriesId === preferredSeriesId) {
seriesRelations.push({
id: this._preferredSeriesRelationId,
target_id: preferredSeriesId,
type: PREFERRED_SERIES_RELATION,
uncertain: false,
});
} else {
newSeriesRelations.push({
target_id: preferredSeriesId,
type: PREFERRED_SERIES_RELATION,
uncertain: false,
});
}
}
if (
this._preferredSeriesRelationId &&
this._preferredSeriesSeriesId &&
this._preferredSeriesSeriesId !== preferredSeriesId &&
!deletedSeriesRelationIds.includes(this._preferredSeriesRelationId)
) {
deletedSeriesRelationIds.push(this._preferredSeriesRelationId);
}
const preferredCount = [...seriesRelations, ...newSeriesRelations].filter(
(relation) => relation.type === PREFERRED_SERIES_RELATION,
).length;
@@ -572,7 +633,8 @@ export class AlmanachEditPage extends HTMLElement {
return;
}
this._statusEl.textContent = "";
this._statusEl.classList.remove("text-red-700", "text-green-700");
this._statusEl.classList.remove("text-red-700", "text-green-700", "save-feedback-error", "save-feedback-success");
this._statusEl.classList.add("hidden");
}
_showStatus(message, type) {
@@ -581,10 +643,11 @@ export class AlmanachEditPage extends HTMLElement {
}
this._clearStatus();
this._statusEl.textContent = message;
this._statusEl.classList.remove("hidden");
if (type === "success") {
this._statusEl.classList.add("text-green-700");
this._statusEl.classList.add("text-green-700", "save-feedback-success");
} else if (type === "error") {
this._statusEl.classList.add("text-red-700");
this._statusEl.classList.add("text-red-700", "save-feedback-error");
}
}

View File

@@ -166,6 +166,42 @@
@apply inline-flex items-center px-1 text-gray-500;
}
.save-feedback {
@apply text-sm font-semibold px-3 py-2 rounded-xs border bg-stone-50 text-gray-700;
}
.save-feedback-error {
@apply bg-red-50 border-red-200 text-red-800;
}
.save-feedback-success {
@apply bg-green-50 border-green-200 text-green-800;
}
.lf-warn-icon {
@apply ml-1 mr-2;
}
.lf-link-button {
@apply ml-1 mr-2;
}
.lf-clear-button {
@apply ml-1 mr-2;
}
.lf-dup-warning {
@apply mt-0 mb-1 pl-3;
}
.preferred-series-select .ssr-clear-button {
@apply text-sm ml-1 mr-2;
}
.preferred-series-select .ssr-wrapper > .flex {
@apply gap-3;
}
.dbform div-menu {
@apply relative inline-block;
}

View File

@@ -0,0 +1,590 @@
const LF_WRAPPER_CLASS = "lookup-field";
const LF_INPUT_CLASS = "lf-input";
const LF_LIST_CLASS = "lf-list";
const LF_OPTION_CLASS = "lf-option";
const LF_HIDDEN_INPUT_CLASS = "lf-hidden-input";
const LF_CLEAR_BUTTON_CLASS = "lf-clear-button";
const LF_LINK_BUTTON_CLASS = "lf-link-button";
const LF_WARN_ICON_CLASS = "lf-warn-icon";
const LF_DUP_WARNING_CLASS = "lf-dup-warning";
const LF_DEFAULT_MIN_CHARS = 1;
const LF_DEFAULT_LIMIT = 10;
const LF_FETCH_DEBOUNCE_MS = 250;
export class LookupField extends HTMLElement {
constructor() {
super();
this._endpoint = "";
this._resultKey = "items";
this._minChars = LF_DEFAULT_MIN_CHARS;
this._limit = LF_DEFAULT_LIMIT;
this._autocomplete = true;
this._placeholder = "";
this._required = false;
this._multiline = false;
this._valueName = "";
this._textName = "";
this._valueFn = null;
this._linkFn = null;
this._validFn = null;
this._dupEndpoint = "";
this._dupResultKey = "";
this._dupCurrentId = "";
this._dupExact = true;
this._options = [];
this._selected = null;
this._highlightedIndex = -1;
this._fetchTimeout = null;
this._fetchController = null;
this._dupTimeout = null;
this._listVisible = false;
this._input = null;
this._hiddenInput = null;
this._list = null;
this._clearButton = null;
this._linkButton = null;
this._warnIcon = null;
this._dupWarning = null;
this._boundHandleInput = this._handleInput.bind(this);
this._boundHandleFocus = this._handleFocus.bind(this);
this._boundHandleKeyDown = this._handleKeyDown.bind(this);
this._boundHandleClear = this._handleClear.bind(this);
this._boundHandleClickOutside = this._handleClickOutside.bind(this);
}
static get observedAttributes() {
return [
"name",
"value",
"placeholder",
"data-endpoint",
"data-result-key",
"data-minchars",
"data-limit",
"data-autocomplete",
"data-required",
"data-multiline",
"data-value-name",
"data-text-name",
"data-value-fn",
"data-link-fn",
"data-valid-fn",
"data-dup-endpoint",
"data-dup-result-key",
"data-dup-current-id",
"data-dup-exact",
"data-initial-id",
"data-initial-name",
"data-initial-musenalm-id",
];
}
connectedCallback() {
this._render();
this._bindElements();
this._syncFromAttributes();
this._applyInitialValue();
this._updateValidity();
this._maybeCheckDuplicates(this._input?.value || "");
}
disconnectedCallback() {
document.removeEventListener("click", this._boundHandleClickOutside);
if (this._input) {
this._input.removeEventListener("input", this._boundHandleInput);
this._input.removeEventListener("focus", this._boundHandleFocus);
this._input.removeEventListener("keydown", this._boundHandleKeyDown);
}
if (this._clearButton) {
this._clearButton.removeEventListener("click", this._boundHandleClear);
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) {
return;
}
if (!this._input) {
return;
}
this._syncFromAttributes();
if (name === "value") {
this._applyInitialValue();
}
}
_render() {
const isMultiline = this.getAttribute("data-multiline") === "true";
const hasTextName = this.hasAttribute("data-text-name");
const textName = hasTextName ? this.getAttribute("data-text-name") || "" : "";
const valueName = this.getAttribute("data-value-name") || "";
const placeholder = this.getAttribute("placeholder") || "";
const inputId = this.getAttribute("id") ? `${this.getAttribute("id")}-input` : "";
const initialValue = this.getAttribute("value") || "";
const noEnter = this.getAttribute("data-no-enter") === "true";
const extraClass = noEnter ? " no-enter" : "";
const fallbackName = this.getAttribute("name") || "";
const finalTextName = hasTextName ? textName : fallbackName;
const textNameAttr = finalTextName ? ` name="${finalTextName}"` : "";
const inputMarkup = isMultiline
? `<textarea id="${inputId}" class="${LF_INPUT_CLASS} inputinput w-full${extraClass}" rows="1" placeholder="${placeholder}"${textNameAttr}>${initialValue}</textarea>`
: `<input id="${inputId}" type="text" class="${LF_INPUT_CLASS} inputinput w-full${extraClass}" placeholder="${placeholder}" value="${initialValue}"${textNameAttr} />`;
const hiddenInputMarkup = valueName ? `<input type="hidden" class="${LF_HIDDEN_INPUT_CLASS}" name="${valueName}" value="" />` : "";
this.innerHTML = `
<div class="${LF_WRAPPER_CLASS} relative">
<div class="flex items-center gap-2">
${inputMarkup.replace(/(class="[^"]*)"/, `$1" ${textNameAttr}`)}
<a class="${LF_LINK_BUTTON_CLASS} hidden text-sm text-gray-600 hover:text-gray-900 no-underline" aria-label="Auswahl öffnen" target="_blank" rel="noopener">
<i class="ri-external-link-line"></i>
</a>
<span class="${LF_WARN_ICON_CLASS} hidden text-red-700 text-lg" aria-hidden="true">
<i class="ri-error-warning-line"></i>
</span>
<button type="button" class="${LF_CLEAR_BUTTON_CLASS} text-sm text-gray-600 hover:text-gray-900" aria-label="Eingabe löschen">
<i class="ri-close-line"></i>
</button>
</div>
${hiddenInputMarkup}
<div class="${LF_LIST_CLASS} absolute left-0 right-0 mt-1 border border-stone-200 rounded-xs bg-white shadow-sm z-10 hidden max-h-64 overflow-auto"></div>
<div class="${LF_DUP_WARNING_CLASS} hidden text-sm text-blue-700 mt-1 flex items-center gap-2">
<i class="ri-information-line"></i>
<span data-role="dup-text"></span>
</div>
</div>
`;
}
_bindElements() {
this._input = this.querySelector(`.${LF_INPUT_CLASS}`);
this._hiddenInput = this.querySelector(`.${LF_HIDDEN_INPUT_CLASS}`);
this._list = this.querySelector(`.${LF_LIST_CLASS}`);
this._clearButton = this.querySelector(`.${LF_CLEAR_BUTTON_CLASS}`);
this._linkButton = this.querySelector(`.${LF_LINK_BUTTON_CLASS}`);
this._warnIcon = this.querySelector(`.${LF_WARN_ICON_CLASS}`);
this._dupWarning = this.querySelector(`.${LF_DUP_WARNING_CLASS}`);
if (this._input) {
this._input.addEventListener("input", this._boundHandleInput);
this._input.addEventListener("focus", this._boundHandleFocus);
this._input.addEventListener("keydown", this._boundHandleKeyDown);
}
if (this._clearButton) {
this._clearButton.addEventListener("click", this._boundHandleClear);
}
document.addEventListener("click", this._boundHandleClickOutside);
}
_syncFromAttributes() {
this._endpoint = this.getAttribute("data-endpoint") || "";
this._resultKey = this.getAttribute("data-result-key") || "items";
this._minChars = this._parsePositiveInt(this.getAttribute("data-minchars"), LF_DEFAULT_MIN_CHARS);
this._limit = this._parsePositiveInt(this.getAttribute("data-limit"), LF_DEFAULT_LIMIT);
this._autocomplete = this.getAttribute("data-autocomplete") !== "false";
this._placeholder = this.getAttribute("placeholder") || "";
this._required = this.getAttribute("data-required") === "true";
this._multiline = this.getAttribute("data-multiline") === "true";
this._valueName = this.getAttribute("data-value-name") || "";
this._textName = this.hasAttribute("data-text-name") ? this.getAttribute("data-text-name") || "" : "";
this._valueFn = this._getFn(this.getAttribute("data-value-fn"));
this._linkFn = this._getFn(this.getAttribute("data-link-fn"));
this._validFn = this._getFn(this.getAttribute("data-valid-fn"));
this._dupEndpoint = this.getAttribute("data-dup-endpoint") || "";
this._dupResultKey = this.getAttribute("data-dup-result-key") || "";
this._dupCurrentId = this.getAttribute("data-dup-current-id") || "";
this._dupExact = this.getAttribute("data-dup-exact") !== "false";
const initialName = this.getAttribute("data-initial-name") || "";
const initialId = this.getAttribute("data-initial-id") || "";
const initialMusenalmId = this.getAttribute("data-initial-musenalm-id") || "";
if (initialId && initialName && !this._selected) {
this._selected = { id: initialId, name: initialName, musenalm_id: initialMusenalmId || undefined };
this._syncHiddenInput();
if (this._input && !this._input.value) {
this._input.value = initialName;
}
}
if (this._input) {
this._input.placeholder = this._placeholder;
}
}
_getFn(name) {
if (!name) {
return null;
}
const fn = window[name];
return typeof fn === "function" ? fn : null;
}
_applyInitialValue() {
const valueAttr = this.getAttribute("value") || "";
if (this._input && valueAttr && !this._input.value) {
this._input.value = valueAttr;
}
}
_handleInput(event) {
const value = event.target.value.trim();
this._selected = null;
this._highlightedIndex = -1;
this._syncHiddenInput();
this._updateValidity();
this._maybeCheckDuplicates(value);
if (!this._autocomplete) {
return;
}
if (value.length < this._minChars) {
this._options = [];
this._renderOptions();
this._hideList();
return;
}
this._debouncedFetch(value);
}
_handleFocus() {
if (this._options.length > 0) {
this._showList();
}
}
_handleKeyDown(event) {
if (event.key === "Escape") {
this._hideList();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
this._moveHighlight(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
this._moveHighlight(-1);
return;
}
if (event.key === "Home") {
event.preventDefault();
this._setHighlight(0);
return;
}
if (event.key === "End") {
event.preventDefault();
this._setHighlight(this._options.length - 1);
return;
}
if (event.key === "Enter") {
if (this._options.length === 0) {
return;
}
event.preventDefault();
const index = this._highlightedIndex >= 0 ? this._highlightedIndex : 0;
this._selectOption(this._options[index]);
}
}
_handleClear(event) {
event.preventDefault();
if (this._input) {
this._input.value = "";
}
this._selected = null;
this._options = [];
this._syncHiddenInput();
this._updateValidity();
this._renderOptions();
this._hideList();
this._maybeCheckDuplicates("");
this.dispatchEvent(new CustomEvent("lfchange", { bubbles: true, detail: { item: null } }));
this.dispatchEvent(new Event("change", { bubbles: true }));
}
_handleClickOutside(event) {
if (!this.contains(event.target)) {
this._hideList();
}
}
_debouncedFetch(query) {
if (this._fetchTimeout) {
clearTimeout(this._fetchTimeout);
}
this._fetchTimeout = setTimeout(() => {
this._fetchOptions(query);
}, LF_FETCH_DEBOUNCE_MS);
}
async _fetchOptions(query) {
if (!this._endpoint) {
return;
}
if (this._fetchController) {
this._fetchController.abort();
}
this._fetchController = new AbortController();
const url = new URL(this._endpoint, window.location.origin);
url.searchParams.set("q", query);
if (this._limit > 0) {
url.searchParams.set("limit", String(this._limit));
}
try {
const resp = await fetch(url.toString(), { signal: this._fetchController.signal });
if (!resp.ok) {
return;
}
const data = await resp.json();
const items = Array.isArray(data?.[this._resultKey]) ? data[this._resultKey] : [];
this._options = items
.filter((item) => item && item.id && item.name)
.map((item) => {
if ("musenalm_id" in item && item.musenalm_id) {
return item;
}
const musenalmId = item.MusenalmID || item.musenalmId || item.musenalmID || "";
return musenalmId ? { ...item, musenalm_id: musenalmId } : item;
});
this._highlightedIndex = this._options.length > 0 ? 0 : -1;
this._maybeAutoSelectExactMatch(query);
this._renderOptions();
if (this._options.length > 0) {
if (this._options.length === 1 && this._isExactMatch(query, this._options[0]?.name || "")) {
this._hideList();
} else {
this._showList();
}
} else {
this._hideList();
}
} catch (err) {
if (err?.name === "AbortError") {
return;
}
}
}
_renderOptions() {
if (!this._list) {
return;
}
this._list.innerHTML = "";
this._options.forEach((item, idx) => {
const option = document.createElement("button");
option.type = "button";
option.setAttribute("data-index", String(idx));
option.className = `${LF_OPTION_CLASS} w-full text-left px-3 py-2 hover:bg-slate-100 transition-colors`;
const isHighlighted = idx === this._highlightedIndex;
option.classList.toggle("bg-slate-100", isHighlighted);
option.setAttribute("aria-selected", isHighlighted ? "true" : "false");
const nameEl = document.createElement("div");
nameEl.className = "text-sm font-semibold text-gray-800";
nameEl.textContent = item.name;
option.appendChild(nameEl);
if (item.detail) {
const detailEl = document.createElement("div");
detailEl.className = "text-xs text-gray-600";
detailEl.textContent = item.detail;
option.appendChild(detailEl);
}
option.addEventListener("click", () => {
this._selectOption(item);
});
this._list.appendChild(option);
});
}
_selectOption(item) {
this._selected = item;
if (this._input) {
this._input.value = item.name || "";
}
this._syncHiddenInput();
this._updateValidity();
this._hideList();
this.dispatchEvent(new CustomEvent("lfchange", { bubbles: true, detail: { item } }));
this.dispatchEvent(new Event("change", { bubbles: true }));
}
_isExactMatch(query, name) {
const lhs = (query || "").trim().toLowerCase();
const rhs = (name || "").trim().toLowerCase();
return lhs !== "" && lhs === rhs;
}
_maybeAutoSelectExactMatch(query) {
const match = this._options.find((item) => this._isExactMatch(query, item?.name || ""));
if (!match) {
return;
}
const prevId = this._selected?.id || "";
this._selected = match;
this._syncHiddenInput();
this._updateValidity();
if (match.id !== prevId) {
this.dispatchEvent(new CustomEvent("lfchange", { bubbles: true, detail: { item: match } }));
this.dispatchEvent(new Event("change", { bubbles: true }));
}
}
_setHighlight(index) {
if (this._options.length === 0) {
this._highlightedIndex = -1;
return;
}
const nextIndex = Math.max(0, Math.min(index, this._options.length - 1));
this._highlightedIndex = nextIndex;
this._renderOptions();
this._scrollHighlightedIntoView();
this._showList();
}
_moveHighlight(delta) {
if (this._options.length === 0) {
this._highlightedIndex = -1;
return;
}
const startIndex = this._highlightedIndex >= 0 ? this._highlightedIndex : 0;
const nextIndex = Math.max(0, Math.min(startIndex + delta, this._options.length - 1));
this._highlightedIndex = nextIndex;
this._renderOptions();
this._scrollHighlightedIntoView();
this._showList();
}
_scrollHighlightedIntoView() {
if (!this._list || this._highlightedIndex < 0) {
return;
}
const option = this._list.querySelector(`[data-index="${this._highlightedIndex}"]`);
if (option) {
option.scrollIntoView({ block: "nearest" });
}
}
_syncHiddenInput() {
if (!this._hiddenInput) {
return;
}
let nextValue = "";
if (this._valueFn && this._selected) {
nextValue = String(this._valueFn({ item: this._selected, displayValue: this._input?.value || "" }) || "");
} else if (this._selected?.id) {
nextValue = this._selected.id;
}
this._hiddenInput.value = nextValue;
}
_updateValidity() {
const displayValue = (this._input?.value || "").trim();
const hiddenValue = (this._hiddenInput?.value || "").trim();
let isValid = true;
if (this._validFn) {
isValid = Boolean(this._validFn({ value: hiddenValue || displayValue, displayValue, hiddenValue, item: this._selected }));
} else if (this._required) {
isValid = (hiddenValue || displayValue).length > 0;
}
const linkUrl = this._linkFn ? this._linkFn({ item: this._selected, value: hiddenValue || displayValue }) : "";
if (this._warnIcon && this._linkButton) {
if (!isValid) {
this._warnIcon.classList.remove("hidden");
this._linkButton.classList.add("hidden");
} else if (linkUrl) {
this._warnIcon.classList.add("hidden");
this._linkButton.classList.remove("hidden");
this._linkButton.setAttribute("href", linkUrl);
} else {
this._warnIcon.classList.add("hidden");
this._linkButton.classList.add("hidden");
}
}
if (this._clearButton) {
this._clearButton.classList.toggle("hidden", displayValue.length === 0);
}
}
_maybeCheckDuplicates(value) {
if (!this._dupEndpoint || !this._dupResultKey || !this._dupWarning) {
return;
}
if (this._dupTimeout) {
clearTimeout(this._dupTimeout);
}
this._dupTimeout = setTimeout(() => {
this._checkDuplicates(value);
}, LF_FETCH_DEBOUNCE_MS);
}
async _checkDuplicates(value) {
if (!this._dupEndpoint || !this._dupResultKey || !this._dupWarning) {
return;
}
const trimmed = (value || "").trim();
if (!trimmed) {
this._dupWarning.classList.add("hidden");
return;
}
try {
const url = new URL(this._dupEndpoint, window.location.origin);
url.searchParams.set("q", trimmed);
url.searchParams.set("limit", "100");
const resp = await fetch(url.toString());
if (!resp.ok) {
return;
}
const data = await resp.json();
const results = data[this._dupResultKey] || [];
let filtered = results;
if (this._dupCurrentId) {
filtered = results.filter((item) => item.id !== this._dupCurrentId);
}
const matches = this._dupExact
? filtered.filter((item) => item.name && item.name.toLowerCase() === trimmed.toLowerCase())
: filtered;
if (matches.length > 0) {
const textEl = this._dupWarning.querySelector("[data-role='dup-text']");
if (textEl) {
textEl.textContent = `Der Name ist bereits vorhanden (${matches.length} Treffer)`;
}
this._dupWarning.classList.remove("hidden");
} else {
this._dupWarning.classList.add("hidden");
}
} catch {
this._dupWarning.classList.add("hidden");
}
}
_parsePositiveInt(value, fallback) {
const parsed = parseInt(value || "", 10);
if (Number.isNaN(parsed) || parsed <= 0) {
return fallback;
}
return parsed;
}
_showList() {
if (!this._list) {
return;
}
this._listVisible = true;
this._list.classList.remove("hidden");
}
_hideList() {
if (!this._list) {
return;
}
this._listVisible = false;
this._list.classList.add("hidden");
}
}

View File

@@ -29,6 +29,7 @@ import { EditPage } from "./edit-page.js";
import { FabMenu } from "./fab-menu.js";
import { DuplicateWarningChecker } from "./duplicate-warning.js";
import { ContentImages } from "./content-images.js";
import { LookupField } from "./lookup-field.js";
const FILTER_LIST_ELEMENT = "filter-list";
const FAB_MENU_ELEMENT = "fab-menu";
@@ -51,6 +52,12 @@ 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";
const LOOKUP_FIELD_ELEMENT = "lookup-field";
window.lookupSeriesValue = ({ item }) => item?.id || "";
window.lookupSeriesLink = ({ item }) => (item?.musenalm_id ? `/reihe/${item.musenalm_id}` : "");
window.lookupRequiredText = ({ displayValue }) => Boolean((displayValue || "").trim());
window.lookupRequiredId = ({ hiddenValue }) => Boolean((hiddenValue || "").trim());
customElements.define(INT_LINK_ELEMENT, IntLink);
customElements.define(ABBREV_TOOLTIPS_ELEMENT, AbbreviationTooltips);
@@ -73,6 +80,7 @@ customElements.define(EDIT_PAGE_ELEMENT, EditPage);
customElements.define(FAB_MENU_ELEMENT, FabMenu);
customElements.define(DUPLICATE_WARNING_ELEMENT, DuplicateWarningChecker);
customElements.define(CONTENT_IMAGES_ELEMENT, ContentImages);
customElements.define(LOOKUP_FIELD_ELEMENT, LookupField);
function PathPlusQuery() {
const path = window.location.pathname;
@@ -469,4 +477,4 @@ window.HookupRBChange = HookupRBChange;
window.FormLoad = FormLoad;
window.TextareaAutoResize = TextareaAutoResize;
export { FilterList, ScrollButton, AbbreviationTooltips, MultiSelectSimple, MultiSelectRole, ToolTip, PopupImage, TabList, FilterPill, ImageReel, IntLink, ItemsEditor, SingleSelectRemote, AlmanachEditPage, RelationsEditor, EditPage, FabMenu };
export { FilterList, ScrollButton, AbbreviationTooltips, MultiSelectSimple, MultiSelectRole, ToolTip, PopupImage, TabList, FilterPill, ImageReel, IntLink, ItemsEditor, SingleSelectRemote, AlmanachEditPage, RelationsEditor, EditPage, FabMenu, LookupField };

View File

@@ -26,6 +26,11 @@ export class SingleSelectRemote extends HTMLElement {
this._fetchTimeout = null;
this._fetchController = null;
this._listVisible = false;
this._linkBase = "";
this._linkTarget = "_blank";
this._linkButton = null;
this._showWarningIcon = false;
this._linkField = "id";
this._boundHandleInput = this._handleInput.bind(this);
this._boundHandleFocus = this._handleFocus.bind(this);
this._boundHandleKeyDown = this._handleKeyDown.bind(this);
@@ -34,7 +39,19 @@ export class SingleSelectRemote extends HTMLElement {
}
static get observedAttributes() {
return ["data-endpoint", "data-result-key", "data-minchars", "data-limit", "placeholder", "name"];
return [
"data-endpoint",
"data-result-key",
"data-minchars",
"data-limit",
"placeholder",
"name",
"data-link-base",
"data-link-target",
"data-link-field",
"data-initial-link-id",
"data-show-warning-icon",
];
}
connectedCallback() {
@@ -49,6 +66,13 @@ export class SingleSelectRemote extends HTMLElement {
this._minChars = this._parsePositiveInt(this.getAttribute("data-minchars"), SSR_DEFAULT_MIN_CHARS);
this._limit = this._parsePositiveInt(this.getAttribute("data-limit"), SSR_DEFAULT_LIMIT);
this._placeholder = this.getAttribute("placeholder") || "Search...";
const initialId = this.getAttribute("data-initial-id") || "";
const initialName = this.getAttribute("data-initial-name") || "";
const initialLinkId = this.getAttribute("data-initial-link-id") || "";
this._linkBase = this.getAttribute("data-link-base") || "";
this._linkTarget = this.getAttribute("data-link-target") || "_blank";
this._linkField = this.getAttribute("data-link-field") || "id";
this._showWarningIcon = this.getAttribute("data-show-warning-icon") === "true";
if (this._input) {
this._input.placeholder = this._placeholder;
@@ -57,10 +81,21 @@ export class SingleSelectRemote extends HTMLElement {
this._input.addEventListener("keydown", this._boundHandleKeyDown);
}
this._linkButton = this.querySelector("[data-role='ssr-open-link']");
if (this._clearButton) {
this._clearButton.addEventListener("click", this._boundHandleClear);
}
if (initialId && initialName) {
this._selected = { id: initialId, name: initialName, linkId: initialLinkId };
if (this._input) {
this._input.value = initialName;
}
this._syncHiddenInput();
}
this._updateLinkButton();
document.addEventListener("click", this._boundHandleClickOutside);
}
@@ -89,6 +124,10 @@ export class SingleSelectRemote extends HTMLElement {
if (this._input) this._input.placeholder = this._placeholder;
}
if (name === "name" && this._hiddenInput) this._hiddenInput.name = newValue || "";
if (name === "data-link-base") this._linkBase = newValue || "";
if (name === "data-link-target") this._linkTarget = newValue || "_blank";
if (name === "data-link-field") this._linkField = newValue || "id";
if (name === "data-show-warning-icon") this._showWarningIcon = newValue === "true";
}
_handleInput(event) {
@@ -154,6 +193,7 @@ export class SingleSelectRemote extends HTMLElement {
this._options = [];
if (this._input) this._input.value = "";
this._syncHiddenInput();
this._updateLinkButton();
this._renderOptions();
this._hideList();
this.dispatchEvent(new CustomEvent("ssrchange", { bubbles: true, detail: { item: null } }));
@@ -208,9 +248,14 @@ export class SingleSelectRemote extends HTMLElement {
this._options = filteredItems;
this._highlightedIndex = this._options.length > 0 ? 0 : -1;
this._maybeAutoSelectExactMatch(query);
this._renderOptions();
if (this._options.length > 0) {
this._showList();
if (this._options.length === 1 && this._isExactMatch(query, this._options[0]?.name || "")) {
this._hideList();
} else {
this._showList();
}
} else {
this._hideList();
}
@@ -221,6 +266,30 @@ export class SingleSelectRemote extends HTMLElement {
}
}
_isExactMatch(query, name) {
const lhs = (query || "").trim().toLowerCase();
const rhs = (name || "").trim().toLowerCase();
return lhs !== "" && lhs === rhs;
}
_maybeAutoSelectExactMatch(query) {
if (!query) {
return;
}
const match = this._options.find((item) => this._isExactMatch(query, item?.name || ""));
if (!match) {
return;
}
const prevId = this._selected?.id || "";
this._selected = match;
this._syncHiddenInput();
this._updateLinkButton();
if (match.id !== prevId) {
this.dispatchEvent(new CustomEvent("ssrchange", { bubbles: true, detail: { item: match } }));
this.dispatchEvent(new Event("change", { bubbles: true }));
}
}
_renderOptions() {
if (!this._list) {
return;
@@ -309,6 +378,7 @@ export class SingleSelectRemote extends HTMLElement {
this._input.value = item.name || "";
}
this._syncHiddenInput();
this._updateLinkButton();
this._hideList();
this.dispatchEvent(new CustomEvent("ssrchange", { bubbles: true, detail: { item } }));
this.dispatchEvent(new Event("change", { bubbles: true }));
@@ -359,6 +429,14 @@ export class SingleSelectRemote extends HTMLElement {
spellcheck="false"
placeholder="${this._placeholder}"
/>
<a
class="ssr-open-link hidden text-sm text-gray-600 hover:text-gray-900 no-underline"
data-role="ssr-open-link"
aria-label="Auswahl öffnen"
target="${this._linkTarget}"
rel="noreferrer">
<i data-role="ssr-open-link-icon" class="ri-external-link-line"></i>
</a>
<button type="button" class="${SSR_CLEAR_BUTTON_CLASS} text-sm text-gray-600 hover:text-gray-900">
<i class="ri-close-line"></i>
</button>
@@ -368,4 +446,34 @@ export class SingleSelectRemote extends HTMLElement {
</div>
`;
}
_updateLinkButton() {
if (!this._linkButton) {
return;
}
const linkValue = this._selected?.[this._linkField] || this._selected?.linkId || this._selected?.id;
const icon = this._linkButton.querySelector("[data-role='ssr-open-link-icon']");
if (!linkValue || !this._linkBase) {
if (this._showWarningIcon) {
this._linkButton.classList.remove("hidden");
this._linkButton.removeAttribute("href");
this._linkButton.classList.add("ssr-open-link-warning");
this._linkButton.setAttribute("aria-label", "Auswahl fehlt");
if (icon) {
icon.className = "ri-error-warning-line";
}
} else {
this._linkButton.classList.add("hidden");
this._linkButton.removeAttribute("href");
}
return;
}
this._linkButton.classList.remove("hidden");
this._linkButton.classList.remove("ssr-open-link-warning");
this._linkButton.setAttribute("href", `${this._linkBase}${linkValue}`);
this._linkButton.setAttribute("aria-label", "Auswahl öffnen");
if (icon) {
icon.className = "ri-external-link-line";
}
}
}