This commit is contained in:
Simon Martens
2026-01-07 20:03:49 +01:00
parent f9fb077518
commit 54a6714e76
9 changed files with 1692 additions and 924 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -111,263 +111,319 @@ type AlmanachResult struct {
<!-- Kurztitel -->
<div class="inputwrapper">
<div class="flex flex-row justify-between">
<label for="preferred_title" class="inputlabel"><i class="ri-text"></i> Kurztitel</label>
<label for="preferred_title" class="inputlabel">Kurztitel</label>
</div>
<textarea name="preferred_title" id="preferred_title" class="inputinput no-enter" placeholder="" required autocomplete="off" rows="1">
{{- $model.result.Entry.PreferredTitle -}}
</textarea>
</div>
<!-- Titles Section -->
<div id="titles"></div>
<div-manager dm-target="titles">
<button class="dm-menu-button text-right w-full cursor-pointer"><i class="ri-add-line"></i>
Titeldaten hinzufügen</button>
<div class="inputwrapper {{ if eq $model.result.Entry.TitleStmt "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="title" class="inputlabel menu-label"> <i class="ri-text"></i> Titel</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
<div class="mt-6">
<div class="flex items-center gap-2 text-lg font-bold text-gray-700">
<i class="ri-book-2-line"></i>
<span>Titeldaten &amp; Anmerkungen</span>
</div>
<hr class="border-stone-200 mt-2" />
<div class="flex flex-col gap-4 mt-4">
<!-- Titles Section -->
<div id="titles"></div>
<div-manager dm-target="titles">
<button class="dm-menu-button text-right w-full cursor-pointer"><i class="ri-add-line"></i>
Titeldaten hinzufügen</button>
<textarea name="title" id="title" class="inputinput" placeholder="" autocomplete="off">
<div class="inputwrapper {{ if eq $model.result.Entry.TitleStmt "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="title" class="inputlabel menu-label">Titel</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<textarea name="title" id="title" class="inputinput" placeholder="" autocomplete="off">
{{- $model.result.Entry.TitleStmt -}}
</textarea>
</div>
</div>
<div class="mt-2 inputwrapper {{ if eq $model.result.Entry.ParallelTitle "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="paralleltitle" class="inputlabel menu-label"><i class="ri-text"></i> Titel
(übersetzt)</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<div class="mt-2 inputwrapper {{ if eq $model.result.Entry.ParallelTitle "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="paralleltitle" class="inputlabel menu-label">Titel
(übersetzt)</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<textarea name="paralleltitle" id="paralleltitle" class="inputinput" placeholder="" autocomplete="off">
<textarea name="paralleltitle" id="paralleltitle" class="inputinput" placeholder="" autocomplete="off">
{{- $model.result.Entry.ParallelTitle -}}
</textarea>
</div>
</div>
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.SubtitleStmt "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="subtitle" class="inputlabel menu-label"><i class="ri-text"></i> Untertitel</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.SubtitleStmt "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="subtitle" class="inputlabel menu-label">Untertitel</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<textarea name="subtitle" id="subtitle" class="inputinput" placeholder="" autocomplete="off">
<textarea name="subtitle" id="subtitle" class="inputinput" placeholder="" autocomplete="off">
{{- $model.result.Entry.SubtitleStmt -}}
</textarea>
</div>
</div>
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.VariantTitle "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="varianttitle" class="inputlabel menu-label"><i class="ri-text"></i>
Titelvarianten</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.VariantTitle "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="varianttitle" class="inputlabel menu-label">Titelvarianten</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<textarea name="varianttitle" id="varianttitle" class="inputinput" placeholder="" autocomplete="off">
<textarea name="varianttitle" id="varianttitle" class="inputinput" placeholder="" autocomplete="off">
{{- $model.result.Entry.VariantTitle -}}
</textarea>
</div>
</div>
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.IncipitStmt "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="incipit" class="inputlabel menu-label"><i class="ri-text"></i> Incipit</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.IncipitStmt "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="incipit" class="inputlabel menu-label">Incipit</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<textarea name="incipit" id="incipit" class="inputinput" placeholder="" autocomplete="off">
<textarea name="incipit" id="incipit" class="inputinput" placeholder="" autocomplete="off">
{{- $model.result.Entry.IncipitStmt -}}
</textarea>
</div>
</div-manager>
</div>
</div-manager>
<!-- Publication Information: Year and Edition - Always visible -->
<div class="flex gap-4">
<div class="flex-1 inputwrapper">
<label for="year" class="inputlabel"><i class="ri-calendar-line"></i> Jahr</label>
<input type="number" name="year" id="year" class="inputinput" placeholder="" autocomplete="off" value="{{ $model.result.Entry.Year }}" />
</div>
<!-- Publication Information: Year and Edition - Always visible -->
<div class="flex gap-4">
<div class="flex-1 inputwrapper">
<label for="year" class="inputlabel">Jahr</label>
<input type="number" name="year" id="year" class="inputinput" placeholder="" autocomplete="off" value="{{ $model.result.Entry.Year }}" />
</div>
<div class="flex-1 inputwrapper">
<label for="edition" class="inputlabel"><i class="ri-file-copy-line"></i> Ausgabe</label>
<textarea name="edition" id="edition" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.Edition -}}</textarea>
</div>
</div>
<div class="flex-1 inputwrapper">
<label for="edition" class="inputlabel">Ausgabe</label>
<textarea name="edition" id="edition" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.Edition -}}</textarea>
</div>
</div>
<!-- Publication Information: Optional fields -->
<div id="publication"></div>
<div-manager dm-target="publication">
<button class="dm-menu-button text-right w-full cursor-pointer"><i class="ri-add-line"></i>
Publikationsdaten hinzufügen</button>
<!-- Publication Information: Optional fields -->
<div id="publication"></div>
<div-manager dm-target="publication">
<button class="dm-menu-button text-right w-full cursor-pointer"><i class="ri-add-line"></i>
Publikationsdaten hinzufügen</button>
<div class="inputwrapper {{ if eq $model.result.Entry.ResponsibilityStmt "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="responsibility_statement" class="inputlabel menu-label"><i class="ri-user-line"></i> Autorangabe</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
<div class="inputwrapper {{ if eq $model.result.Entry.ResponsibilityStmt "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="responsibility_statement" class="inputlabel menu-label">Autorangabe</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<textarea name="responsibility_statement" id="responsibility_statement" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.ResponsibilityStmt -}}</textarea>
</div>
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.PublicationStmt "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="publication_statement" class="inputlabel menu-label">Publikationsangabe</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<textarea name="publication_statement" id="publication_statement" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.PublicationStmt -}}</textarea>
</div>
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.PlaceStmt "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="place_statement" class="inputlabel menu-label">Ortsangabe</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<textarea name="place_statement" id="place_statement" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.PlaceStmt -}}</textarea>
</div>
</div-manager>
<!-- Annotationen -->
<div class="inputwrapper">
<label for="annotation" class="inputlabel">Annotationen</label>
<textarea name="annotation" id="annotation" class="inputinput" placeholder="" autocomplete="off">{{- $model.result.Entry.Annotation -}}</textarea>
</div>
</div>
<textarea name="responsibility_statement" id="responsibility_statement" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.ResponsibilityStmt -}}</textarea>
</div>
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.PublicationStmt "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="publication_statement" class="inputlabel menu-label"><i class="ri-book-line"></i> Publikationsangabe</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
<div class="mt-6">
<div class="flex items-center gap-2 text-lg font-bold text-gray-700">
<i class="ri-links-line"></i>
<span>Normdaten &amp; Verknüpfungen</span>
</div>
<hr class="border-stone-200 mt-2" />
<div class="flex flex-col gap-4 mt-4">
<div class="inputwrapper">
<label for="places" class="inputlabel">Erscheinungs- und Verlagsorte</label>
<multi-select-simple
id="places"
name="places[]"
placeholder="Orte suchen..."
show-create-button="false"
data-endpoint="/api/places/search"
data-result-key="places"
data-minchars="1"
data-limit="15">
</multi-select-simple>
</div>
</div>
<textarea name="publication_statement" id="publication_statement" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.PublicationStmt -}}</textarea>
</div>
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.PlaceStmt "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="place_statement" class="inputlabel menu-label"><i class="ri-map-pin-line"></i> Ortsangabe</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<textarea name="place_statement" id="place_statement" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.PlaceStmt -}}</textarea>
</div>
</div-manager>
<!-- Annotationen -->
<div id="annotation_section"></div>
<div-manager dm-target="annotation_section">
<button class="dm-menu-button text-right w-full cursor-pointer"><i class="ri-add-line"></i>
Annotationen hinzufügen</button>
<div class="inputwrapper {{ if eq $model.result.Entry.Annotation "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="annotation" class="inputlabel menu-label"><i class="ri-sticky-note-line"></i> Annotationen</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<textarea name="annotation" id="annotation" class="inputinput" placeholder="" autocomplete="off">{{- $model.result.Entry.Annotation -}}</textarea>
</div>
</div-manager>
<script type="module">
const placesSelect = document.getElementById("places");
if (placesSelect) {
const initialPlaces = [
{{- range $i, $place := $model.result.Places }}
{ id: "{{ $place.Id }}", name: {{ printf "%q" $place.Name }}, additional_data: {{ printf "%q" $place.Pseudonyms }} },
{{- end -}}
];
const initialPlaceIds = [
{{- range $i, $place := $model.result.Places -}}
{{- if $i }},{{ end }}"{{ $place.Id }}"
{{- end -}}
];
if (initialPlaces.length > 0) {
placesSelect.setOptions(initialPlaces);
}
placesSelect.value = initialPlaceIds;
}
</script>
</div>
<!-- End Left Column -->
<!-- Right Column -->
<div class="w-[28rem] shrink-0 flex flex-col gap-4">
<!-- Status -->
<div class="inputwrapper">
<label for="type" class="inputlabel"><i class="ri-alarm-warning-line"></i> Status</label>
<select name="type" id="type" autocomplete="off" class="inputselect font-bold">
<option value="Unknown" {{ if eq $model.result.Entry.EditState "Unknown" }}selected{{ end }}>Unbekannt</option>
<option value="ToDo" {{ if eq $model.result.Entry.EditState "ToDo" }}selected{{ end }}>Zu erledigen</option>
<option value="Review" {{ if eq $model.result.Entry.EditState "Review" }}selected{{ end }}>Überprüfen</option>
<option value="Seen" {{ if eq $model.result.Entry.EditState "Seen" }}selected{{ end }}>Autopsiert</option>
<option value="Edited" {{ if eq $model.result.Entry.EditState "Edited" }}selected{{ end }}>Vollständig Erfasst</option>
</select>
</div>
<!-- Bearbeitungsvermerk -->
<div id="edit_comment_section"></div>
<div-manager dm-target="edit_comment_section">
<button class="dm-menu-button text-right w-full cursor-pointer"><i class="ri-add-line"></i>
Bearbeitungsvermerk hinzufügen</button>
<div class="inputwrapper {{ if eq $model.result.Entry.Comment "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="edit_comment" class="inputlabel menu-label"><i class="ri-chat-1-line"></i> Bearbeitungsvermerk</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
<div class="w-[28rem] shrink-0 flex flex-col gap-6">
<div>
<div class="flex items-center gap-2 text-lg font-bold text-gray-700">
<i class="ri-clipboard-line"></i>
<span>Bearbeitungsdaten</span>
</div>
<hr class="border-stone-200 mt-2" />
<div class="flex flex-col gap-4 mt-4">
<!-- Status -->
<div class="inputwrapper">
<label for="type" class="inputlabel">Status</label>
<select name="type" id="type" autocomplete="off" class="inputselect font-bold">
<option value="Unknown" {{ if eq $model.result.Entry.EditState "Unknown" }}selected{{ end }}>Unbekannt</option>
<option value="ToDo" {{ if eq $model.result.Entry.EditState "ToDo" }}selected{{ end }}>Zu erledigen</option>
<option value="Review" {{ if eq $model.result.Entry.EditState "Review" }}selected{{ end }}>Überprüfen</option>
<option value="Seen" {{ if eq $model.result.Entry.EditState "Seen" }}selected{{ end }}>Autopsiert</option>
<option value="Edited" {{ if eq $model.result.Entry.EditState "Edited" }}selected{{ end }}>Vollständig Erfasst</option>
</select>
</div>
<textarea name="edit_comment" id="edit_comment" class="inputinput" placeholder="" autocomplete="off">{{- $model.result.Entry.Comment -}}</textarea>
</div>
</div-manager>
<!-- Bearbeitungsvermerk -->
<div id="edit_comment_section"></div>
<div-manager dm-target="edit_comment_section">
<button class="dm-menu-button text-right w-full cursor-pointer"><i class="ri-add-line"></i>
Bearbeitungsvermerk hinzufügen</button>
<!-- Languages -->
<div class="inputwrapper">
<label for="languages" class="inputlabel"><i class="ri-earth-line"></i> Sprachen</label>
<multi-select-simple id="languages" show-create-button="false" placeholder="Sprachen suchen..."></multi-select-simple>
<script type="module">
const smlang = document.getElementById("languages");
smlang.value = {{ $model.result.Entry.Language }};
</script>
<div class="inputwrapper {{ if eq $model.result.Entry.Comment "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="edit_comment" class="inputlabel menu-label">Bearbeitungsvermerk</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<textarea name="edit_comment" id="edit_comment" class="inputinput" placeholder="" autocomplete="off">{{- $model.result.Entry.Comment -}}</textarea>
</div>
</div-manager>
</div>
</div>
<!-- Nachweise - Always visible -->
<div class="inputwrapper">
<label for="refs" class="inputlabel"><i class="ri-links-line"></i> Nachweise</label>
<textarea name="refs" id="refs" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.References -}}</textarea>
<div>
<div class="flex items-center gap-2 text-lg font-bold text-gray-700">
<i class="ri-information-line"></i>
<span>Metadaten</span>
</div>
<hr class="border-stone-200 mt-2" />
<div class="flex flex-col gap-4 mt-4">
<!-- Languages -->
<div class="inputwrapper">
<label for="languages" class="inputlabel">Sprachen</label>
<multi-select-simple id="languages" show-create-button="false" placeholder="Sprachen suchen..."></multi-select-simple>
<script type="module">
const smlang = document.getElementById("languages");
smlang.value = {{ $model.result.Entry.Language }};
</script>
</div>
<!-- Nachweise - Always visible -->
<div class="inputwrapper">
<label for="refs" class="inputlabel">Nachweise</label>
<textarea name="refs" id="refs" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.References -}}</textarea>
</div>
<!-- Physical Description -->
<div id="physical"></div>
<div-manager dm-target="physical">
<button class="dm-menu-button text-right w-full cursor-pointer"><i class="ri-add-line"></i>
Physische Beschreibung hinzufügen</button>
<div class="inputwrapper {{ if eq $model.result.Entry.Extent "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="extent" class="inputlabel menu-label">Struktur/Umfang</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<textarea name="extent" id="extent" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.Extent -}}</textarea>
</div>
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.Dimensions "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="dimensions" class="inputlabel menu-label">Maße</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<textarea name="dimensions" id="dimensions" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.Dimensions -}}</textarea>
</div>
</div-manager>
</div>
</div>
<!-- Physical Description -->
<div id="physical"></div>
<div-manager dm-target="physical">
<button class="dm-menu-button text-right w-full cursor-pointer"><i class="ri-add-line"></i>
Physische Beschreibung hinzufügen</button>
<div class="inputwrapper {{ if eq $model.result.Entry.Extent "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="extent" class="inputlabel menu-label"><i class="ri-file-text-line"></i> Struktur/Umfang</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<textarea name="extent" id="extent" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.Extent -}}</textarea>
</div>
<div class="mt-3 inputwrapper {{ if eq $model.result.Entry.Dimensions "" }}hidden{{ end }}">
<div class="flex flex-row justify-between">
<label for="dimensions" class="inputlabel menu-label"><i class="ri-ruler-line"></i> Maße</label>
<div class="pr-2">
<button class="dm-close-button font-bold input-label">
<i class="ri-close-line"></i>
</button>
</div>
</div>
<textarea name="dimensions" id="dimensions" class="inputinput" placeholder="" autocomplete="off" rows="1">{{- $model.result.Entry.Dimensions -}}</textarea>
</div>
</div-manager>
<!-- Exemplare -->
<div class="mt-8">
<div class="mt-6">
<div class="flex items-center gap-2 text-lg font-bold text-gray-700">
<i class="ri-archive-line"></i>
<span>Exemplare</span>
@@ -383,7 +439,7 @@ type AlmanachResult struct {
<div class="text-base font-bold" data-summary-container>
<span data-summary-field="owner" data-summary-hide-empty="true">{{ $item.Owner }}</span>
</div>
<div class="px-2 py-0.5 rounded-full bg-stone-200 text-sm font-bold" data-summary-container>
<div class="px-2 py-0.5 bg-stone-200 text-sm font-bold rounded-sm" data-summary-container>
<span data-summary-field="identifier" data-summary-hide-empty="true">{{ $item.Identifier }}</span>
</div>
</div>
@@ -482,7 +538,7 @@ type AlmanachResult struct {
<div class="text-base font-bold" data-summary-container>
<span data-summary-field="owner" data-summary-hide-empty="true">—</span>
</div>
<div class="px-2 py-0.5 rounded-full bg-stone-200 text-sm font-bold" data-summary-container>
<div class="px-2 py-0.5 bg-stone-200 text-sm font-bold" data-summary-container>
<span data-summary-field="identifier" data-summary-hide-empty="true">—</span>
</div>
</div>

View File

@@ -189,7 +189,7 @@
}
.mss-selected-item-pill {
@apply bg-gray-200 text-gray-800 py-0.5 px-2 rounded text-xs leading-5; /* Adjusted font size and padding */
@apply bg-gray-200 text-gray-800 py-0.5 px-2 rounded leading-5 shadow-xs hover:shadow select-none; /* Adjusted font size and padding */
/* Tailwind classes from component: flex items-center */
}
@@ -204,7 +204,7 @@
}
.mss-selected-item-delete-btn {
@apply bg-transparent border-none text-gray-600 opacity-70 cursor-pointer ml-1 text-base leading-none align-middle hover:opacity-100 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed;
@apply bg-transparent border-none text-gray-600 opacity-70 cursor-pointer ml-2 text-lg leading-none align-middle hover:opacity-100 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed;
}
.mss-input-controls-container {
@@ -220,7 +220,7 @@
}
.mss-text-input {
@apply py-1.5 px-2 text-sm;
@apply py-1.5 px-2;
/* Tailwind classes from component: w-full outline-none bg-transparent */
}
.mss-text-input::placeholder {
@@ -235,12 +235,12 @@
}
.mss-options-list {
@apply bg-white border border-gray-300 rounded shadow-md; /* Using shadow-md as a softer default */
@apply bg-white border border-gray-300 rounded shadow-md list-none m-0; /* Using shadow-md as a softer default */
/* Tailwind classes from component: absolute z-20 w-full max-h-60 overflow-y-auto mt-1 hidden */
}
.mss-option-item {
@apply text-gray-700 py-1.5 px-2.5 text-sm cursor-pointer transition-colors duration-75 hover:bg-gray-100;
@apply text-gray-700 py-1.5 px-2.5 cursor-pointer transition-colors duration-75 hover:bg-gray-100 list-none m-0;
}
.mss-option-item-name {
@@ -248,7 +248,7 @@
}
.mss-option-item-detail {
@apply text-gray-500 text-xs ml-1.5;
@apply text-gray-500 ml-1.5;
}
.mss-option-item-highlighted {

View File

@@ -0,0 +1,280 @@
const ROW_CLASS = "items-row";
const LIST_CLASS = "items-list";
const TEMPLATE_CLASS = "items-template";
const ADD_BUTTON_CLASS = "items-add-button";
const REMOVE_BUTTON_CLASS = "items-remove-button";
const EDIT_BUTTON_CLASS = "items-edit-button";
const CLOSE_BUTTON_CLASS = "items-close-button";
const SUMMARY_CLASS = "items-summary";
const EDIT_PANEL_CLASS = "items-edit-panel";
const REMOVED_INPUT_NAME = "items_removed[]";
export class ItemsEditor extends HTMLElement {
constructor() {
super();
this._list = null;
this._template = null;
this._addButton = null;
this._idPrefix = `items-editor-${crypto.randomUUID().slice(0, 8)}`;
this._handleAdd = this._onAddClick.bind(this);
}
connectedCallback() {
this._list = this.querySelector(`.${LIST_CLASS}`);
this._template = this.querySelector(`template.${TEMPLATE_CLASS}`);
this._addButton = this.querySelector(`.${ADD_BUTTON_CLASS}`);
if (!this._list || !this._template || !this._addButton) {
console.error("ItemsEditor: Missing list, template, or add button.");
return;
}
this._addButton.addEventListener("click", this._handleAdd);
this._wireRemoveButtons();
this._wireEditButtons();
this._refreshRowIds();
this._syncAllSummaries();
}
disconnectedCallback() {
if (this._addButton) {
this._addButton.removeEventListener("click", this._handleAdd);
}
}
_onAddClick(event) {
event.preventDefault();
this.addItem();
}
addItem() {
const fragment = this._template.content.cloneNode(true);
const newRow = fragment.querySelector(`.${ROW_CLASS}`);
if (!newRow) {
console.error("ItemsEditor: Template is missing a row element.");
return;
}
this._list.appendChild(fragment);
this._wireRemoveButtons(newRow);
this._wireEditButtons(newRow);
this._assignRowFieldIds(newRow, this._rowIndex(newRow));
this._wireSummarySync(newRow);
this._syncSummary(newRow);
this._setRowMode(newRow, "edit");
}
removeItem(button) {
const row = button.closest(`.${ROW_CLASS}`);
if (!row) {
return;
}
const idInput = row.querySelector('input[name="items_id[]"]');
const itemId = idInput ? idInput.value.trim() : "";
if (itemId) {
this._ensureRemovalInput(itemId);
}
row.remove();
this._refreshRowIds();
}
_wireRemoveButtons(root = this) {
root.querySelectorAll(`.${REMOVE_BUTTON_CLASS}`).forEach((btn) => {
if (btn.dataset.itemsBound === "true") {
return;
}
btn.dataset.itemsBound = "true";
btn.addEventListener("click", (event) => {
event.preventDefault();
this.removeItem(btn);
});
});
}
_wireEditButtons(root = this) {
root.querySelectorAll(`.${EDIT_BUTTON_CLASS}`).forEach((btn) => {
if (btn.dataset.itemsBound === "true") {
return;
}
btn.dataset.itemsBound = "true";
btn.addEventListener("click", (event) => {
event.preventDefault();
const row = btn.closest(`.${ROW_CLASS}`);
if (row) {
this._setRowMode(row, "edit");
}
});
});
root.querySelectorAll(`.${CLOSE_BUTTON_CLASS}`).forEach((btn) => {
if (btn.dataset.itemsBound === "true") {
return;
}
btn.dataset.itemsBound = "true";
btn.addEventListener("click", (event) => {
event.preventDefault();
const row = btn.closest(`.${ROW_CLASS}`);
if (row) {
this._setRowMode(row, "summary");
}
});
});
}
_setRowMode(row, mode) {
const summary = row.querySelector(`.${SUMMARY_CLASS}`);
const editor = row.querySelector(`.${EDIT_PANEL_CLASS}`);
if (!summary || !editor) {
return;
}
if (mode === "edit") {
summary.classList.add("hidden");
editor.classList.remove("hidden");
} else {
summary.classList.remove("hidden");
editor.classList.add("hidden");
this._syncSummary(row);
}
}
_refreshRowIds() {
const rows = Array.from(this.querySelectorAll(`.${ROW_CLASS}`));
rows.forEach((row, index) => {
this._assignRowFieldIds(row, index);
});
}
_rowIndex(row) {
const rows = Array.from(this.querySelectorAll(`.${ROW_CLASS}`));
return rows.indexOf(row);
}
_assignRowFieldIds(row, index) {
if (index < 0) {
return;
}
row.querySelectorAll("[data-field-label]").forEach((label) => {
const fieldName = label.getAttribute("data-field-label");
if (!fieldName) {
return;
}
const field = row.querySelector(`[data-field="${fieldName}"]`);
if (!field) {
return;
}
const fieldId = `${this._idPrefix}-${index}-${fieldName}`;
field.id = fieldId;
label.setAttribute("for", fieldId);
});
}
_syncAllSummaries() {
this.querySelectorAll(`.${ROW_CLASS}`).forEach((row) => {
this._wireSummarySync(row);
this._syncSummary(row);
});
}
_wireSummarySync(row) {
if (row.dataset.summaryBound === "true") {
return;
}
row.dataset.summaryBound = "true";
row.querySelectorAll("[data-field]").forEach((field) => {
field.addEventListener("input", () => this._syncSummary(row));
field.addEventListener("change", () => this._syncSummary(row));
});
}
_syncSummary(row) {
row.querySelectorAll("[data-summary-field]").forEach((summaryField) => {
const fieldName = summaryField.getAttribute("data-summary-field");
if (!fieldName) {
return;
}
const formField = row.querySelector(`[data-field="${fieldName}"]`);
if (!formField) {
return;
}
const value = this._readFieldValue(formField);
const container =
summaryField.getAttribute("data-summary-hide-empty") === "true"
? summaryField.closest("[data-summary-container]")
: null;
if (value) {
this._setSummaryContent(summaryField, value);
summaryField.classList.remove("text-gray-400");
if (container) {
container.classList.remove("hidden");
}
} else {
this._setSummaryContent(summaryField, "—");
summaryField.classList.add("text-gray-400");
if (container) {
container.classList.add("hidden");
}
}
});
}
_setSummaryContent(summaryField, value) {
const link = summaryField.querySelector("[data-summary-link]");
if (link) {
if (value && value !== "—") {
link.setAttribute("href", value);
link.textContent = value;
} else {
link.setAttribute("href", "#");
link.textContent = "—";
}
} else {
summaryField.textContent = value || "—";
}
}
_readFieldValue(field) {
if (field instanceof HTMLSelectElement) {
if (field.multiple) {
return Array.from(field.selectedOptions)
.map((opt) => opt.textContent.trim())
.filter(Boolean)
.join(", ");
}
const selected = field.selectedOptions[0];
return selected ? selected.textContent.trim() : "";
}
if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) {
return field.value.trim();
}
return "";
}
_ensureRemovalInput(itemId) {
const existing = Array.from(this.querySelectorAll(`input[name="${REMOVED_INPUT_NAME}"]`)).some(
(input) => input.value === itemId,
);
if (existing) {
return;
}
const hidden = document.createElement("input");
hidden.type = "hidden";
hidden.name = REMOVED_INPUT_NAME;
hidden.value = itemId;
this.appendChild(hidden);
}
}

View File

@@ -17,6 +17,11 @@ const MSS_OPTION_ITEM_DETAIL_CLASS = "mss-option-item-detail";
const MSS_HIGHLIGHTED_OPTION_CLASS = "mss-option-item-highlighted";
const MSS_HIDDEN_SELECT_CLASS = "mss-hidden-select";
const MSS_NO_ITEMS_TEXT_CLASS = "mss-no-items-text";
const MSS_LOADING_CLASS = "mss-loading";
const MSS_REMOTE_DEFAULT_MIN_CHARS = 1;
const MSS_REMOTE_DEFAULT_LIMIT = 10;
const MSS_REMOTE_FETCH_DEBOUNCE_MS = 250;
// State classes for MultiSelectSimple
const MSS_STATE_NO_SELECTION = "mss-state-no-selection";
@@ -220,6 +225,13 @@ export class MultiSelectSimple extends HTMLElement {
this._highlightedIndex = -1;
this._isOptionsListVisible = false;
this._remoteEndpoint = null;
this._remoteResultKey = "items";
this._remoteMinChars = MSS_REMOTE_DEFAULT_MIN_CHARS;
this._remoteLimit = MSS_REMOTE_DEFAULT_LIMIT;
this._remoteFetchController = null;
this._remoteFetchTimeout = null;
this._placeholder = this.getAttribute("placeholder") || "Search items...";
this._showCreateButton = this.getAttribute("show-create-button") !== "false";
@@ -284,7 +296,12 @@ export class MultiSelectSimple extends HTMLElement {
setOptions(newOptions) {
if (Array.isArray(newOptions) && newOptions.every((o) => o && typeof o.id === "string" && typeof o.name === "string")) {
this._options = [...newOptions];
this._options = newOptions.map((option) => {
const normalized = { ...option };
normalized.name = this._normalizeText(normalized.name);
normalized.additional_data = this._normalizeText(normalized.additional_data);
return normalized;
});
const validValues = this._value.filter((id) => this._getItemById(id));
if (validValues.length !== this._value.length) this.value = validValues;
else if (this.selectedItemsContainer) this._renderSelectedItems();
@@ -336,6 +353,10 @@ export class MultiSelectSimple extends HTMLElement {
this.placeholder = this.getAttribute("placeholder") || "Search items...";
this.showCreateButton = this.getAttribute("show-create-button") !== "false";
this._remoteEndpoint = this.getAttribute("data-endpoint") || null;
this._remoteResultKey = this.getAttribute("data-result-key") || "items";
this._remoteMinChars = this._parsePositiveInt(this.getAttribute("data-minchars"), MSS_REMOTE_DEFAULT_MIN_CHARS);
this._remoteLimit = this._parsePositiveInt(this.getAttribute("data-limit"), MSS_REMOTE_DEFAULT_LIMIT);
if (this.name && this.hiddenSelect) this.hiddenSelect.name = this.name;
@@ -380,10 +401,15 @@ export class MultiSelectSimple extends HTMLElement {
if (this.createNewButton) this.createNewButton.removeEventListener("click", this._handleCreateNewButtonClick);
if (this.selectedItemsContainer) this.selectedItemsContainer.removeEventListener("click", this._handleSelectedItemsContainerClick);
clearTimeout(this._blurTimeout);
if (this._remoteFetchTimeout) {
clearTimeout(this._remoteFetchTimeout);
this._remoteFetchTimeout = null;
}
this._cancelRemoteFetch();
}
static get observedAttributes() {
return ["disabled", "name", "value", "placeholder", "show-create-button"];
return ["disabled", "name", "value", "placeholder", "show-create-button", "data-endpoint", "data-result-key", "data-minchars", "data-limit"];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
@@ -400,6 +426,10 @@ export class MultiSelectSimple extends HTMLElement {
}
} else if (name === "placeholder") this.placeholder = newValue;
else if (name === "show-create-button") this.showCreateButton = newValue;
else if (name === "data-endpoint") this._remoteEndpoint = newValue || null;
else if (name === "data-result-key") this._remoteResultKey = newValue || "items";
else if (name === "data-minchars") this._remoteMinChars = this._parsePositiveInt(newValue, MSS_REMOTE_DEFAULT_MIN_CHARS);
else if (name === "data-limit") this._remoteLimit = this._parsePositiveInt(newValue, MSS_REMOTE_DEFAULT_LIMIT);
}
formAssociatedCallback(form) {}
@@ -458,10 +488,10 @@ export class MultiSelectSimple extends HTMLElement {
</style>
<div class="${MSS_COMPONENT_WRAPPER_CLASS} relative">
<div class="${MSS_SELECTED_ITEMS_CONTAINER_CLASS} flex flex-wrap gap-1 mb-1 min-h-[38px]" aria-live="polite" tabindex="-1"></div>
<div class="${MSS_INPUT_CONTROLS_CONTAINER_CLASS} flex items-center space-x-2">
<div class="${MSS_INPUT_CONTROLS_CONTAINER_CLASS} flex items-center space-x-4">
<div class="${MSS_INPUT_WRAPPER_CLASS} relative rounded-md flex items-center flex-grow">
<input type="text"
class="${MSS_TEXT_INPUT_CLASS} w-full outline-none bg-transparent text-sm"
class="${MSS_TEXT_INPUT_CLASS} w-full outline-none bg-transparent"
placeholder="${this.placeholder}"
aria-autocomplete="list"
aria-expanded="${this._isOptionsListVisible}"
@@ -483,11 +513,13 @@ export class MultiSelectSimple extends HTMLElement {
const textEl = pillEl.querySelector('[data-ref="textEl"]');
const detailEl = pillEl.querySelector('[data-ref="detailEl"]'); // This now uses MSS_SELECTED_ITEM_PILL_DETAIL_CLASS
const deleteBtn = pillEl.querySelector('[data-ref="deleteBtn"]');
textEl.textContent = itemData.name;
if (itemData.additional_data) {
detailEl.textContent = `(${itemData.additional_data})`;
textEl.textContent = this._normalizeText(itemData.name);
const detailText = this._normalizeText(itemData.additional_data);
if (detailText) {
detailEl.textContent = `(${detailText})`;
detailEl.classList.remove("hidden"); // Toggle visibility via JS
} else {
detailEl.textContent = "";
detailEl.classList.add("hidden"); // Toggle visibility via JS
}
deleteBtn.setAttribute("aria-label", `Remove ${itemData.name}`);
@@ -517,8 +549,9 @@ export class MultiSelectSimple extends HTMLElement {
const li = fragment.firstElementChild;
const nameEl = li.querySelector('[data-ref="nameEl"]');
const detailEl = li.querySelector('[data-ref="detailEl"]');
nameEl.textContent = itemData.name;
detailEl.textContent = itemData.additional_data ? `(${itemData.additional_data})` : "";
nameEl.textContent = this._normalizeText(itemData.name);
const detailText = this._normalizeText(itemData.additional_data);
detailEl.textContent = detailText ? `(${detailText})` : "";
li.dataset.id = itemData.id;
li.setAttribute("aria-selected", String(index === this._highlightedIndex));
const optionElementId = `option-${this.id || "mss"}-${itemData.id}`;
@@ -569,6 +602,10 @@ export class MultiSelectSimple extends HTMLElement {
}
_handleInput(event) {
const searchTerm = event.target.value;
if (this._remoteEndpoint) {
this._handleRemoteInput(searchTerm);
return;
}
if (searchTerm.length === 0) {
this._filteredOptions = [];
this._isOptionsListVisible = false;
@@ -576,8 +613,10 @@ export class MultiSelectSimple extends HTMLElement {
const searchTermLower = searchTerm.toLowerCase();
this._filteredOptions = this._options.filter((item) => {
if (this._value.includes(item.id)) return false;
const nameMatch = item.name.toLowerCase().includes(searchTermLower);
const additionalDataMatch = item.additional_data && item.additional_data.toLowerCase().includes(searchTermLower);
const normalizedName = this._normalizeText(item.name);
const nameMatch = normalizedName.toLowerCase().includes(searchTermLower);
const detailValue = this._normalizeText(item.additional_data);
const additionalDataMatch = detailValue && detailValue.toLowerCase().includes(searchTermLower);
return nameMatch || additionalDataMatch;
});
this._isOptionsListVisible = this._filteredOptions.length > 0;
@@ -668,4 +707,161 @@ export class MultiSelectSimple extends HTMLElement {
if (this.inputElement && this.inputElement.value) this._handleInput({ target: this.inputElement });
if (this.inputElement && !this.hasAttribute("disabled")) this.inputElement.focus();
}
_parsePositiveInt(value, fallback) {
if (!value) return fallback;
const parsed = parseInt(value, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
return fallback;
}
return parsed;
}
_handleRemoteInput(searchTerm) {
if (this._remoteFetchTimeout) {
clearTimeout(this._remoteFetchTimeout);
}
if (searchTerm.length < this._remoteMinChars) {
this._filteredOptions = [];
this._isOptionsListVisible = false;
this._renderOptionsList();
return;
}
this._remoteFetchTimeout = setTimeout(() => {
this._fetchRemoteOptions(searchTerm);
}, MSS_REMOTE_FETCH_DEBOUNCE_MS);
}
_cancelRemoteFetch() {
if (this._remoteFetchController) {
this._remoteFetchController.abort();
this._remoteFetchController = null;
}
}
async _fetchRemoteOptions(searchTerm) {
if (!this._remoteEndpoint) return;
this._cancelRemoteFetch();
this.classList.add(MSS_LOADING_CLASS);
const controller = new AbortController();
this._remoteFetchController = controller;
try {
const url = new URL(this._remoteEndpoint, window.location.origin);
url.searchParams.set("q", searchTerm);
if (this._remoteLimit) {
url.searchParams.set("limit", String(this._remoteLimit));
}
const response = await fetch(url.toString(), {
headers: { Accept: "application/json" },
signal: controller.signal,
credentials: "same-origin",
});
if (!response.ok) {
throw new Error(`Remote fetch failed with status ${response.status}`);
}
const payload = await response.json();
if (controller.signal.aborted) {
return;
}
const options = this._extractRemoteOptions(payload);
this._applyRemoteResults(options);
} catch (error) {
if (controller.signal.aborted) {
return;
}
console.error("MultiSelectSimple remote fetch error:", error);
this._filteredOptions = [];
this._isOptionsListVisible = false;
this._renderOptionsList();
} finally {
if (this._remoteFetchController === controller) {
this._remoteFetchController = null;
}
this.classList.remove(MSS_LOADING_CLASS);
}
}
_extractRemoteOptions(payload) {
if (!payload) return [];
let entries = [];
if (Array.isArray(payload)) {
entries = payload;
} else if (this._remoteResultKey && Array.isArray(payload[this._remoteResultKey])) {
entries = payload[this._remoteResultKey];
} else if (Array.isArray(payload.items)) {
entries = payload.items;
}
return entries
.map((entry) => {
if (!entry) return null;
const id = entry.id ?? entry.ID ?? entry.value ?? "";
const name = entry.name ?? entry.title ?? entry.label ?? "";
const detail = entry.detail ?? entry.additional_data ?? entry.annotation ?? "";
const normalizedName = this._normalizeText(name);
const detailText = this._normalizeText(detail);
if (!id || !normalizedName) return null;
return {
id: String(id),
name: normalizedName,
additional_data: detailText,
};
})
.filter(Boolean);
}
_applyRemoteResults(options) {
const selected = new Set(this._value);
const merged = new Map();
this._options.forEach((opt) => {
if (opt?.id) {
merged.set(opt.id, opt);
}
});
options.forEach((opt) => {
if (opt?.id) {
merged.set(opt.id, opt);
}
});
this._options = Array.from(merged.values());
this._filteredOptions = options.filter((opt) => opt && !selected.has(opt.id));
this._isOptionsListVisible = this._filteredOptions.length > 0;
this._highlightedIndex = this._isOptionsListVisible ? 0 : -1;
this._renderOptionsList();
}
_normalizeText(rawValue) {
if (rawValue === null || rawValue === undefined) {
return "";
}
let text = String(rawValue).trim();
if (!text) {
return "";
}
const first = text[0];
const last = text[text.length - 1];
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
text = text.slice(1, -1).trim();
if (!text) {
return "";
}
}
return text;
}
}