mirror of
https://github.com/Theodor-Springmann-Stiftung/musenalm.git
synced 2026-02-04 10:35:30 +00:00
800 lines
35 KiB
Plaintext
800 lines
35 KiB
Plaintext
{{ $model := . }}
|
||
|
||
<div x-data="{
|
||
search: '{{ $model.search }}',
|
||
offset: {{ if $model.next_offset }}{{ $model.next_offset }}{{ else }}0{{ end }},
|
||
hasMore: {{ if $model.has_more }}true{{ else }}false{{ end }},
|
||
loading: false,
|
||
alphabetOpen: false,
|
||
selectedLetter: '{{ $model.letter }}',
|
||
status: '{{ $model.status }}',
|
||
person: '{{ $model.person }}',
|
||
user: '{{ $model.user }}',
|
||
year: '{{ $model.year }}',
|
||
place: '{{ $model.place }}',
|
||
sortField: '{{ if $model.sort_field }}{{ $model.sort_field }}{{ else }}title{{ end }}',
|
||
sortOrder: '{{ if $model.sort_order }}{{ $model.sort_order }}{{ else }}asc{{ end }}',
|
||
statusLabels: {
|
||
{{- range $k, $v := $model.filter_status_labels -}}
|
||
{{ printf "%q" $k }}: {{ printf "%q" $v }},
|
||
{{- end -}}
|
||
},
|
||
personLabels: {
|
||
{{- range $k, $v := $model.filter_agent_labels -}}
|
||
{{ printf "%q" $k }}: {{ printf "%q" $v }},
|
||
{{- end -}}
|
||
},
|
||
userLabels: {
|
||
{{- range $k, $v := $model.filter_user_labels -}}
|
||
{{ printf "%q" $k }}: {{ printf "%q" $v }},
|
||
{{- end -}}
|
||
},
|
||
placeLabels: {
|
||
{{- range $k, $v := $model.filter_place_labels -}}
|
||
{{ printf "%q" $k }}: {{ printf "%q" $v }},
|
||
{{- end -}}
|
||
},
|
||
yearLabels: {
|
||
{{- range $k, $v := $model.filter_year_labels -}}
|
||
{{ printf "%q" $k }}: {{ printf "%q" $v }},
|
||
{{- end -}}
|
||
},
|
||
appendFilters(params) {
|
||
if (this.status) params.set('status', this.status);
|
||
if (this.person) params.set('person', this.person);
|
||
if (this.user) params.set('user', this.user);
|
||
if (this.year) params.set('year', this.year);
|
||
if (this.place) params.set('place', this.place);
|
||
},
|
||
buildResultsUrl(overrides = {}) {
|
||
const params = new URLSearchParams();
|
||
const next = {
|
||
search: this.search,
|
||
letter: this.selectedLetter,
|
||
status: this.status,
|
||
person: this.person,
|
||
user: this.user,
|
||
year: this.year,
|
||
place: this.place,
|
||
sort: this.sortField,
|
||
order: this.sortOrder,
|
||
offset: 0,
|
||
...overrides,
|
||
};
|
||
Object.entries(next).forEach(([key, value]) => {
|
||
if (value !== '' && value !== null && value !== undefined) {
|
||
params.set(key, value);
|
||
}
|
||
});
|
||
return `/baende/results/?${params.toString()}`;
|
||
},
|
||
buildPageUrl(overrides = {}) {
|
||
const params = new URLSearchParams();
|
||
const next = {
|
||
search: this.search,
|
||
letter: this.selectedLetter,
|
||
status: this.status,
|
||
person: this.person,
|
||
user: this.user,
|
||
year: this.year,
|
||
place: this.place,
|
||
sort: this.sortField,
|
||
order: this.sortOrder,
|
||
offset: this.offset,
|
||
...overrides,
|
||
};
|
||
Object.entries(next).forEach(([key, value]) => {
|
||
if (value !== '' && value !== null && value !== undefined) {
|
||
params.set(key, value);
|
||
}
|
||
});
|
||
const query = params.toString();
|
||
return query ? `/baende/?${query}` : '/baende/';
|
||
},
|
||
applyFilter(overrides = {}, indicator = 'body', sourceEl = null) {
|
||
Object.entries(overrides).forEach(([key, value]) => {
|
||
this[key] = value;
|
||
});
|
||
this.offset = 0;
|
||
this.hasMore = true;
|
||
const url = this.buildResultsUrl();
|
||
htmx.ajax('GET', url, {
|
||
target: '#baenderesults',
|
||
swap: 'outerHTML',
|
||
indicator,
|
||
source: sourceEl || (indicator && document.querySelector(indicator)) || undefined,
|
||
});
|
||
},
|
||
clearFilters() {
|
||
this.status = '';
|
||
this.person = '';
|
||
this.user = '';
|
||
this.year = '';
|
||
this.place = '';
|
||
},
|
||
closeOtherDropdowns(current) {
|
||
document.querySelectorAll('details').forEach((d) => {
|
||
if (d !== current) d.open = false;
|
||
});
|
||
},
|
||
changeSort(field, event = null) {
|
||
if (this.sortField === field) {
|
||
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
this.sortField = field;
|
||
this.sortOrder = 'asc';
|
||
}
|
||
this.offset = 0;
|
||
this.hasMore = true;
|
||
|
||
const params = new URLSearchParams();
|
||
params.set('sort', this.sortField);
|
||
params.set('order', this.sortOrder);
|
||
params.set('offset', 0);
|
||
this.appendFilters(params);
|
||
if (this.search) {
|
||
params.set('search', this.search);
|
||
}
|
||
if (this.selectedLetter) {
|
||
params.set('letter', this.selectedLetter);
|
||
}
|
||
|
||
const queryString = params.toString();
|
||
htmx.ajax('GET', `/baende/results/?${queryString}`, {
|
||
target: '#baenderesults',
|
||
swap: 'outerHTML',
|
||
source: event?.currentTarget || undefined,
|
||
});
|
||
},
|
||
updateUrl() {
|
||
const params = new URLSearchParams();
|
||
params.set('offset', this.offset);
|
||
params.set('sort', this.sortField);
|
||
params.set('order', this.sortOrder);
|
||
this.appendFilters(params);
|
||
if (this.search) {
|
||
params.set('search', this.search);
|
||
}
|
||
if (this.selectedLetter) {
|
||
params.set('letter', this.selectedLetter);
|
||
}
|
||
const query = params.toString();
|
||
const newUrl = query ? `/baende/?${query}` : '/baende/';
|
||
window.history.replaceState(null, '', newUrl);
|
||
},
|
||
loadMoreUrl() {
|
||
const params = new URLSearchParams();
|
||
params.set('offset', this.offset);
|
||
params.set('sort', this.sortField);
|
||
params.set('order', this.sortOrder);
|
||
this.appendFilters(params);
|
||
if (this.search) {
|
||
params.set('search', this.search);
|
||
}
|
||
if (this.selectedLetter) {
|
||
params.set('letter', this.selectedLetter);
|
||
}
|
||
return `/baende/more/?${params.toString()}`;
|
||
}
|
||
}"
|
||
@htmx:after-swap.window="
|
||
if ($event.detail.target && $event.detail.target.id === 'baenderesults') {
|
||
const responseUrl = $event.detail.xhr?.responseURL || window.location.href;
|
||
const params = new URL(responseUrl).searchParams;
|
||
sortField = params.get('sort') || sortField;
|
||
sortOrder = params.get('order') || sortOrder;
|
||
status = params.get('status') || '';
|
||
person = params.get('person') || '';
|
||
user = params.get('user') || '';
|
||
year = params.get('year') || '';
|
||
place = params.get('place') || '';
|
||
search = params.get('search') || '';
|
||
selectedLetter = params.get('letter') || '';
|
||
updateUrl();
|
||
}
|
||
"
|
||
class="container-normal font-sans mt-10">
|
||
<div id="pageheading" class="headingcontainer">
|
||
<h1 class="heading">Bände A–Z</h1>
|
||
|
||
<div class="mt-2">
|
||
<div class="border-b px-3 border-zinc-300">
|
||
<!-- Row 1: Filters -->
|
||
<div class="flex flex-wrap items-end justify-center min-h-14 gap-x-2 gap-y-3 pb-3" hx-boost="false">
|
||
<!-- Alphabet navigation toggle -->
|
||
<div class="relative">
|
||
<details class="font-sans text-base list-none" data-role="alphabet-toggle" @toggle="alphabetOpen = $el.open; if ($el.open) { closeOtherDropdowns($el); }">
|
||
<summary class="cursor-pointer select-none text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2"
|
||
:class="selectedLetter ? 'font-semibold text-slate-900 ring-1 ring-slate-300' : ''">
|
||
<span x-text="selectedLetter ? `Alphabet: ${selectedLetter}` : 'Alphabet'"></span>
|
||
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': alphabetOpen }"></i>
|
||
<span id="baende-alphabet-spinner" class="htmx-indicator text-slate-900">
|
||
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
|
||
</span>
|
||
</summary>
|
||
<div class="absolute left-0 mt-2 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
||
<div class="p-2 w-[26rem]">
|
||
<a :href="buildPageUrl({ letter: '', offset: 0 })"
|
||
hx-indicator="#baende-alphabet-spinner"
|
||
@click.prevent="alphabetOpen = false; $el.closest('details').open = false; applyFilter({ selectedLetter: '' }, '#baende-alphabet-spinner', $el)"
|
||
x-show="selectedLetter"
|
||
class="mb-2 inline-flex w-full items-center justify-center gap-2 rounded bg-orange-100 px-2 py-1 text-sm font-semibold text-orange-800 hover:bg-orange-200 no-underline transition-colors">
|
||
<i class="ri-filter-off-line text-base"></i>
|
||
<span>Alle</span>
|
||
</a>
|
||
<div class="grid grid-cols-13 gap-1 text-sm text-gray-700" x-effect="
|
||
const active = selectedLetter || '';
|
||
$el.querySelectorAll('[data-letter]').forEach((el) => {
|
||
const isActive = el.dataset.letter === active;
|
||
el.classList.toggle('bg-stone-200', isActive);
|
||
el.classList.toggle('font-bold', isActive);
|
||
});
|
||
">
|
||
{{- range $_, $ch := $model.letters -}}
|
||
<a :href="buildPageUrl({ letter: '{{ $ch }}', offset: 0 })"
|
||
hx-indicator="#baende-alphabet-spinner"
|
||
@click.prevent="alphabetOpen = false; $el.closest('details').open = false; applyFilter({ selectedLetter: '{{ $ch }}' }, '#baende-alphabet-spinner', $el)"
|
||
data-letter="{{ $ch }}"
|
||
class="text-center py-1 px-2 rounded hover:bg-gray-100 no-underline transition-colors">
|
||
{{ $ch }}
|
||
</a>
|
||
{{- end -}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<!-- Status filter -->
|
||
<div class="relative" x-data="{ open: false }" data-role="baende-filter">
|
||
<details class="font-sans text-base list-none" @toggle="open = $el.open; if ($el.open) { closeOtherDropdowns($el); }">
|
||
<summary class="cursor-pointer select-none text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2"
|
||
:class="status ? 'font-semibold text-slate-900 ring-1 ring-slate-300' : ''">
|
||
<span x-text="status ? `Status: ${statusLabels[status] || status}` : 'Status'"></span>
|
||
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
||
<span id="baende-status-spinner" class="htmx-indicator text-slate-900">
|
||
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
|
||
</span>
|
||
</summary>
|
||
<div class="absolute left-0 mt-2 w-72 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
||
<div class="p-3">
|
||
<div class="max-h-64 overflow-auto flex flex-col gap-1 text-sm text-gray-700 border border-stone-100 rounded-sm" data-role="filter-list">
|
||
<a data-role="filter-item" data-label="Alle" :href="buildPageUrl({ status: '', offset: 0 })"
|
||
hx-indicator="#baende-status-spinner"
|
||
@click.prevent="open = false; $el.closest('details').open = false; applyFilter({ status: '' }, '#baende-status-spinner', $el)"
|
||
x-show="status"
|
||
class="mb-2 inline-flex w-full items-center justify-center gap-2 rounded bg-orange-100 px-2 py-1 text-sm font-semibold text-orange-800 hover:bg-orange-200 no-underline transition-colors">
|
||
<i class="ri-filter-off-line text-base"></i>
|
||
<span>Alle</span>
|
||
</a>
|
||
{{- range $_, $s := $model.filter_statuses -}}
|
||
<a data-role="filter-item" data-label="{{ $s.label }}" :href="buildPageUrl({ status: '{{ $s.value }}', offset: 0 })"
|
||
hx-indicator="#baende-status-spinner"
|
||
@click.prevent="open = false; $el.closest('details').open = false; applyFilter({ status: '{{ $s.value }}' }, '#baende-status-spinner', $el)"
|
||
:class="status === '{{ $s.value }}' ? 'bg-stone-100 font-semibold' : ''"
|
||
class="filter-list-row px-2 py-1 rounded-sm hover:bg-stone-100 no-underline transition-colors">
|
||
{{ $s.label }}
|
||
</a>
|
||
{{- end -}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<!-- Person filter -->
|
||
<div class="relative" x-data="{ open: false }" data-role="baende-filter">
|
||
<details class="font-sans text-base list-none" @toggle="open = $el.open; if ($el.open) { closeOtherDropdowns($el); }">
|
||
<summary class="cursor-pointer select-none text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2"
|
||
:class="person ? 'font-semibold text-slate-900 ring-1 ring-slate-300' : ''">
|
||
<span x-text="person ? `Person: ${personLabels[person] || person}` : 'Person'"></span>
|
||
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
||
</summary>
|
||
<div class="absolute left-0 mt-2 w-80 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
||
<div class="p-3">
|
||
<a data-role="filter-item" data-label="Alle" :href="buildPageUrl({ person: '', offset: 0 })"
|
||
hx-indicator="#baende-person-spinner"
|
||
@click.prevent="
|
||
open = false;
|
||
const root = $el.closest('details');
|
||
const input = root?.querySelector('[data-role=filter-search]');
|
||
if (input) { input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); }
|
||
root.open = false;
|
||
applyFilter({ person: '' }, '#baende-person-spinner', $el);
|
||
"
|
||
x-show="person"
|
||
class="mb-2 inline-flex w-full items-center justify-center gap-2 rounded bg-orange-100 px-2 py-1 text-sm font-semibold text-orange-800 hover:bg-orange-200 no-underline transition-colors">
|
||
<i class="ri-filter-off-line text-base"></i>
|
||
<span>Alle</span>
|
||
</a>
|
||
<div class="relative">
|
||
<input data-role="filter-search" type="search" autocomplete="off" placeholder="Personen filtern..." class="w-full px-2 py-1 pr-7 border border-stone-200 rounded text-sm" />
|
||
<span id="baende-person-spinner" class="htmx-indicator absolute right-2 top-1/2 -translate-y-1/2 text-slate-900">
|
||
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
|
||
</span>
|
||
</div>
|
||
<div class="mt-2 max-h-80 overflow-auto flex flex-col gap-0.5 text-sm text-gray-700 border border-stone-100 rounded-sm" data-role="filter-list">
|
||
{{- range $_, $a := $model.filter_agents -}}
|
||
<a data-role="filter-item" data-label="{{ $a.Name }}" :href="buildPageUrl({ person: '{{ $a.Id }}', offset: 0 })"
|
||
hx-indicator="#baende-person-spinner"
|
||
@click.prevent="
|
||
open = false;
|
||
const root = $el.closest('details');
|
||
const input = root?.querySelector('[data-role=filter-search]');
|
||
if (input) { input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); }
|
||
root.open = false;
|
||
applyFilter({ person: '{{ $a.Id }}' }, '#baende-person-spinner', $el);
|
||
"
|
||
:class="person === '{{ $a.Id }}' ? 'bg-stone-100 font-semibold' : ''"
|
||
class="filter-list-row px-2 py-1 rounded-sm hover:bg-stone-100 no-underline transition-colors">
|
||
<span class="filter-list-searchable mr-1">{{ $a.Name }}</span>
|
||
{{- if $a.CorporateBody -}}
|
||
<span class="inline-flex items-center rounded-xs bg-stone-100 px-1.5 py-0.5 text-[0.7rem] font-semibold text-slate-600">ORG</span>
|
||
{{- else if $a.BiographicalData -}}
|
||
<span class="text-[0.7rem] text-stone-500 whitespace-nowrap">{{ $a.BiographicalData }}</span>
|
||
{{- end -}}
|
||
</a>
|
||
{{- end -}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<!-- Benutzer filter -->
|
||
<div class="relative" x-data="{ open: false }" data-role="baende-filter">
|
||
<details class="font-sans text-base list-none" @toggle="open = $el.open; if ($el.open) { closeOtherDropdowns($el); }">
|
||
<summary class="cursor-pointer select-none text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2"
|
||
:class="user ? 'font-semibold text-slate-900 ring-1 ring-slate-300' : ''">
|
||
<span x-text="user ? `Benutzer: ${userLabels[user] || user}` : 'Benutzer'"></span>
|
||
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
||
</summary>
|
||
<div class="absolute left-0 mt-2 w-80 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
||
<div class="p-3">
|
||
<a data-role="filter-item" data-label="Alle" :href="buildPageUrl({ user: '', offset: 0 })"
|
||
hx-indicator="#baende-user-spinner"
|
||
@click.prevent="
|
||
open = false;
|
||
const root = $el.closest('details');
|
||
const input = root?.querySelector('[data-role=filter-search]');
|
||
if (input) { input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); }
|
||
root.open = false;
|
||
applyFilter({ user: '' }, '#baende-user-spinner', $el);
|
||
"
|
||
x-show="user"
|
||
class="mb-2 inline-flex w-full items-center justify-center gap-2 rounded bg-orange-100 px-2 py-1 text-sm font-semibold text-orange-800 hover:bg-orange-200 no-underline transition-colors">
|
||
<i class="ri-filter-off-line text-base"></i>
|
||
<span>Alle</span>
|
||
</a>
|
||
<div class="relative">
|
||
<input data-role="filter-search" type="search" autocomplete="off" placeholder="Benutzer filtern..." class="w-full px-2 py-1 pr-7 border border-stone-200 rounded text-sm" />
|
||
<span id="baende-user-spinner" class="htmx-indicator absolute right-2 top-1/2 -translate-y-1/2 text-slate-900">
|
||
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
|
||
</span>
|
||
</div>
|
||
<div class="mt-2 max-h-80 overflow-auto flex flex-col gap-0.5 text-sm text-gray-700 border border-stone-100 rounded-sm" data-role="filter-list">
|
||
{{- range $_, $u := $model.filter_users -}}
|
||
<a data-role="filter-item" data-label="{{ $u.Name }}" :href="buildPageUrl({ user: '{{ $u.Id }}', offset: 0 })"
|
||
hx-indicator="#baende-user-spinner"
|
||
@click.prevent="
|
||
open = false;
|
||
const root = $el.closest('details');
|
||
const input = root?.querySelector('[data-role=filter-search]');
|
||
if (input) { input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); }
|
||
root.open = false;
|
||
applyFilter({ user: '{{ $u.Id }}' }, '#baende-user-spinner', $el);
|
||
"
|
||
:class="user === '{{ $u.Id }}' ? 'bg-stone-100 font-semibold' : ''"
|
||
class="filter-list-row px-2 py-1 rounded-sm hover:bg-stone-100 no-underline transition-colors">
|
||
<span class="filter-list-searchable mr-1">{{ $u.Name }}</span>
|
||
</a>
|
||
{{- end -}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<!-- Jahr filter -->
|
||
<div class="relative" x-data="{ open: false }" data-role="baende-filter">
|
||
<details class="font-sans text-base list-none" @toggle="open = $el.open; if ($el.open) { closeOtherDropdowns($el); }">
|
||
<summary class="cursor-pointer select-none text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2"
|
||
:class="year ? 'font-semibold text-slate-900 ring-1 ring-slate-300' : ''">
|
||
<span x-text="year ? `Jahr: ${yearLabels[year] || year}` : 'Jahr'"></span>
|
||
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
||
</summary>
|
||
<div class="absolute left-0 mt-2 w-72 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
||
<div class="p-3">
|
||
<a data-role="filter-item" data-label="Alle" :href="buildPageUrl({ year: '', offset: 0 })"
|
||
hx-indicator="#baende-year-spinner"
|
||
@click.prevent="
|
||
open = false;
|
||
const root = $el.closest('details');
|
||
const input = root?.querySelector('[data-role=filter-search]');
|
||
if (input) { input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); }
|
||
root.open = false;
|
||
applyFilter({ year: '' }, '#baende-year-spinner', $el);
|
||
"
|
||
x-show="year"
|
||
class="mb-2 inline-flex w-full items-center justify-center gap-2 rounded bg-orange-100 px-2 py-1 text-sm font-semibold text-orange-800 hover:bg-orange-200 no-underline transition-colors">
|
||
<i class="ri-filter-off-line text-base"></i>
|
||
<span>Alle</span>
|
||
</a>
|
||
<div class="relative">
|
||
<input data-role="filter-search" type="search" autocomplete="off" placeholder="Jahre filtern..." class="w-full px-2 py-1 pr-7 border border-stone-200 rounded text-sm" />
|
||
<span id="baende-year-spinner" class="htmx-indicator absolute right-2 top-1/2 -translate-y-1/2 text-slate-900">
|
||
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
|
||
</span>
|
||
</div>
|
||
<div class="mt-2 max-h-80 overflow-auto flex flex-col gap-0.5 text-sm text-gray-700 border border-stone-100 rounded-sm" data-role="filter-list">
|
||
{{- range $_, $y := $model.filter_years -}}
|
||
{{- $label := $y -}}
|
||
{{- if eq $y 0 -}}{{- $label = "ohne Jahr" -}}{{- end -}}
|
||
<a data-role="filter-item" data-label="{{ $label }}" :href="buildPageUrl({ year: '{{ $y }}', offset: 0 })"
|
||
hx-indicator="#baende-year-spinner"
|
||
@click.prevent="
|
||
open = false;
|
||
const root = $el.closest('details');
|
||
const input = root?.querySelector('[data-role=filter-search]');
|
||
if (input) { input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); }
|
||
root.open = false;
|
||
applyFilter({ year: '{{ $y }}' }, '#baende-year-spinner', $el);
|
||
"
|
||
:class="year === '{{ $y }}' ? 'bg-stone-100 font-semibold' : ''"
|
||
class="filter-list-row px-2 py-1 rounded-sm hover:bg-stone-100 no-underline transition-colors">
|
||
{{ $label }}
|
||
</a>
|
||
{{- end -}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<!-- Ort filter -->
|
||
<div class="relative" x-data="{ open: false }" data-role="baende-filter">
|
||
<details class="font-sans text-base list-none" @toggle="open = $el.open; if ($el.open) { closeOtherDropdowns($el); }">
|
||
<summary class="cursor-pointer select-none text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2"
|
||
:class="place ? 'font-semibold text-slate-900 ring-1 ring-slate-300' : ''">
|
||
<span x-text="place ? `Ort: ${placeLabels[place] || place}` : 'Ort'"></span>
|
||
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
||
</summary>
|
||
<div class="absolute left-0 mt-2 w-80 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
||
<div class="p-3">
|
||
<a data-role="filter-item" data-label="Alle" :href="buildPageUrl({ place: '', offset: 0 })"
|
||
hx-indicator="#baende-place-spinner"
|
||
@click.prevent="
|
||
open = false;
|
||
const root = $el.closest('details');
|
||
const input = root?.querySelector('[data-role=filter-search]');
|
||
if (input) { input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); }
|
||
root.open = false;
|
||
applyFilter({ place: '' }, '#baende-place-spinner', $el);
|
||
"
|
||
x-show="place"
|
||
class="mb-2 inline-flex w-full items-center justify-center gap-2 rounded bg-orange-100 px-2 py-1 text-sm font-semibold text-orange-800 hover:bg-orange-200 no-underline transition-colors">
|
||
<i class="ri-filter-off-line text-base"></i>
|
||
<span>Alle</span>
|
||
</a>
|
||
<div class="relative">
|
||
<input data-role="filter-search" type="search" autocomplete="off" placeholder="Orte filtern..." class="w-full px-2 py-1 pr-7 border border-stone-200 rounded text-sm" />
|
||
<span id="baende-place-spinner" class="htmx-indicator absolute right-2 top-1/2 -translate-y-1/2 text-slate-900">
|
||
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
|
||
</span>
|
||
</div>
|
||
<div class="mt-2 max-h-80 overflow-auto flex flex-col gap-0.5 text-sm text-gray-700 border border-stone-100 rounded-sm" data-role="filter-list">
|
||
{{- range $_, $p := $model.filter_places -}}
|
||
<a data-role="filter-item" data-label="{{ $p.Name }}" :href="buildPageUrl({ place: '{{ $p.Id }}', offset: 0 })"
|
||
hx-indicator="#baende-place-spinner"
|
||
@click.prevent="
|
||
open = false;
|
||
const root = $el.closest('details');
|
||
const input = root?.querySelector('[data-role=filter-search]');
|
||
if (input) { input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); }
|
||
root.open = false;
|
||
applyFilter({ place: '{{ $p.Id }}' }, '#baende-place-spinner', $el);
|
||
"
|
||
:class="place === '{{ $p.Id }}' ? 'bg-stone-100 font-semibold' : ''"
|
||
class="filter-list-row px-2 py-1 rounded-sm hover:bg-stone-100 no-underline transition-colors">
|
||
{{ $p.Name }}
|
||
</a>
|
||
{{- end -}}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Row 2: Search + Spalten + count -->
|
||
<div class="flex flex-wrap items-center justify-between gap-3 pb-0 mt-3">
|
||
<!-- Search box -->
|
||
<div class="min-w-[22.5rem] max-w-96 flex flex-row bg-stone-50 relative font-sans text-lg items-center">
|
||
<div>
|
||
<i class="ri-search-line"></i><i class="-ml-0.5 inline-block ri-arrow-right-s-line"></i>
|
||
</div>
|
||
<div class="border-b-4 border-zinc-300 grow">
|
||
<form
|
||
method="GET"
|
||
action="/baende/"
|
||
hx-get="/baende/results/"
|
||
hx-indicator="#baende-search-spinner"
|
||
hx-push-url="false"
|
||
hx-swap="outerHTML"
|
||
hx-target="#baenderesults"
|
||
role="search"
|
||
@submit="offset = 0; hasMore = true"
|
||
aria-label="Bändesuche">
|
||
<input type="hidden" name="sort" :value="sortField" />
|
||
<input type="hidden" name="order" :value="sortOrder" />
|
||
<input type="hidden" name="status" :value="status" />
|
||
<input type="hidden" name="person" :value="person" />
|
||
<input type="hidden" name="user" :value="user" />
|
||
<input type="hidden" name="year" :value="year" />
|
||
<input type="hidden" name="place" :value="place" />
|
||
<input type="hidden" name="letter" :value="selectedLetter" />
|
||
<div class="relative">
|
||
<input
|
||
class="px-2 py-0.5 pr-7 font-sans placeholder:italic w-full text-lg"
|
||
type="search"
|
||
name="search"
|
||
value="{{ $model.search }}"
|
||
placeholder="Signatur oder Suchbegriff"
|
||
x-model="search"
|
||
@input.debounce.500="((search.trim().length >= 3) || /^[0-9]+$/.test(search.trim()) || search === '') && $el.form.requestSubmit()"
|
||
@search.debounce.500="((search.trim().length >= 3) || /^[0-9]+$/.test(search.trim()) || search === '') && $el.form.requestSubmit()"
|
||
autocomplete="off" />
|
||
<span id="baende-search-spinner" class="htmx-indicator absolute right-1 top-1/2 -translate-y-1/2 text-slate-900">
|
||
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
|
||
</span>
|
||
</div>
|
||
<button x-show="false">Suchen</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap items-center justify-end gap-3">
|
||
<div class="relative" x-data="{ open: false }">
|
||
<details class="font-sans text-base list-none" data-role="baende-column-toggle" @toggle="open = $el.open; if ($el.open) { closeOtherDropdowns($el); }">
|
||
<summary class="cursor-pointer select-none text-gray-600 hover:text-slate-900 px-2 py-1 rounded-xs flex items-center gap-2 text-lg font-semibold font-sans">
|
||
<i class="ri-eye-line"></i>
|
||
<span>Spalten</span>
|
||
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
||
</summary>
|
||
<div class="absolute right-0 mt-2 w-56 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
||
<div class="p-4 flex flex-col gap-2 text-sm text-gray-700">
|
||
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="title" checked /> Titel</label>
|
||
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="appearance" checked /> Erscheinung</label>
|
||
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="year" /> Jahr</label>
|
||
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="extent" checked /> Umfang / Maße</label>
|
||
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="signatures" checked /> Signaturen</label>
|
||
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="modified" /> Bearbeitet / Benutzer</label>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<div id="baende-count" class="text-lg font-semibold font-sans text-gray-600 whitespace-nowrap">
|
||
{{ if $model.current_count }}{{ $model.current_count }} / {{ end }}{{ if $model.total_count }}{{ $model.total_count }}{{ else }}{{ len $model.result.Entries }}{{ end }} Bände
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="baenderesults" class="mt-2" data-next-offset="{{ $model.next_offset }}">
|
||
{{ template "_baende_table" $model }}
|
||
|
||
<!-- Load More Button -->
|
||
<div class="mt-6 flex justify-center" x-show="hasMore">
|
||
<button
|
||
type="button"
|
||
class="content-action-button"
|
||
:hx-get="loadMoreUrl()"
|
||
hx-target="#baende-tbody"
|
||
hx-swap="beforeend"
|
||
hx-indicator="this"
|
||
@htmx:before-request="loading = true"
|
||
@htmx:after-request="
|
||
loading = false;
|
||
hasMore = $event.detail.xhr.getResponseHeader('X-Has-More') === 'true';
|
||
const nextOffsetHeader = Number($event.detail.xhr.getResponseHeader('X-Next-Offset'));
|
||
if (!Number.isNaN(nextOffsetHeader)) {
|
||
offset = nextOffsetHeader;
|
||
}
|
||
updateUrl();
|
||
"
|
||
:disabled="loading">
|
||
<i class="ri-arrow-down-line" :class="{ 'spinning': loading }"></i>
|
||
<span x-text="loading ? 'Lädt...' : 'Weitere 150 laden'"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<dialog data-role="baende-delete-dialog" class="dbform fixed inset-0 m-auto rounded-md border border-slate-200 p-0 shadow-xl backdrop:bg-black/40">
|
||
<div class="p-5 w-[26rem]">
|
||
<div class="text-base font-bold text-gray-900">Band löschen?</div>
|
||
<div data-role="baende-delete-title" class="text-sm font-bold text-gray-900 mt-1"></div>
|
||
<div data-role="baende-delete-impacts" class="mt-2 text-sm text-gray-700">
|
||
Lade Informationen …
|
||
</div>
|
||
<div data-role="baende-delete-error" class="mt-2 text-sm text-red-700 hidden"></div>
|
||
<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="baende-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="baende-delete-confirm">
|
||
Löschen
|
||
</button>
|
||
</div>
|
||
<input type="hidden" data-role="baende-delete-csrf" value="{{ $model.csrf_token }}" />
|
||
</div>
|
||
</dialog>
|
||
|
||
<script>
|
||
(() => {
|
||
const filterRoots = document.querySelectorAll('[data-role="baende-filter"]');
|
||
filterRoots.forEach((root) => {
|
||
const input = root.querySelector('[data-role="filter-search"]');
|
||
const list = root.querySelector('[data-role="filter-list"]');
|
||
if (!list || !input) return;
|
||
input.addEventListener('input', () => {
|
||
const query = input.value.trim().toLowerCase();
|
||
list.querySelectorAll('[data-role="filter-item"]').forEach((item) => {
|
||
const label = (item.getAttribute('data-label') || '').toLowerCase();
|
||
if (item.getAttribute('data-label') === 'Alle') {
|
||
return;
|
||
}
|
||
item.classList.toggle('hidden', query !== '' && !label.includes(query));
|
||
});
|
||
});
|
||
});
|
||
})();
|
||
|
||
(() => {
|
||
const dialog = document.querySelector("[data-role='baende-delete-dialog']");
|
||
if (!dialog) return;
|
||
const titleEl = dialog.querySelector("[data-role='baende-delete-title']");
|
||
const impactsEl = dialog.querySelector("[data-role='baende-delete-impacts']");
|
||
const errorEl = dialog.querySelector("[data-role='baende-delete-error']");
|
||
const cancelBtn = dialog.querySelector("[data-role='baende-delete-cancel']");
|
||
const confirmBtn = dialog.querySelector("[data-role='baende-delete-confirm']");
|
||
const csrfInput = dialog.querySelector("[data-role='baende-delete-csrf']");
|
||
|
||
let currentEntryId = "";
|
||
let currentDeleteEndpoint = "";
|
||
|
||
const closeDialog = (event) => {
|
||
if (event) {
|
||
event.preventDefault();
|
||
}
|
||
if (dialog.open) {
|
||
dialog.close();
|
||
}
|
||
};
|
||
|
||
const openDialog = () => {
|
||
if (typeof dialog.showModal === "function") {
|
||
dialog.showModal();
|
||
}
|
||
};
|
||
|
||
const setError = (message) => {
|
||
if (!errorEl) return;
|
||
if (message) {
|
||
errorEl.textContent = message;
|
||
errorEl.classList.remove("hidden");
|
||
} else {
|
||
errorEl.textContent = "";
|
||
errorEl.classList.add("hidden");
|
||
}
|
||
};
|
||
|
||
const handleDeleteClick = async (event) => {
|
||
let button = null;
|
||
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
||
for (const node of path) {
|
||
if (node && node.getAttribute && node.getAttribute("data-role") === "baende-delete") {
|
||
button = node;
|
||
break;
|
||
}
|
||
}
|
||
if (!button && event.target && event.target.closest) {
|
||
button = event.target.closest("[data-role='baende-delete']");
|
||
}
|
||
if (!button) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
currentEntryId = button.getAttribute("data-entry-id") || "";
|
||
currentDeleteEndpoint = button.getAttribute("data-delete-endpoint") || "";
|
||
const entryTitle = button.getAttribute("data-entry-title") || "";
|
||
|
||
if (titleEl) {
|
||
titleEl.textContent = entryTitle ? entryTitle : "Unbekannter Eintrag";
|
||
}
|
||
if (impactsEl) {
|
||
impactsEl.textContent = "Lade Informationen …";
|
||
}
|
||
setError("");
|
||
openDialog();
|
||
|
||
if (!currentEntryId || !impactsEl) return;
|
||
try {
|
||
const response = await fetch(`/baende/delete-info/${encodeURIComponent(currentEntryId)}`);
|
||
if (!response.ok) {
|
||
throw new Error("Infos konnten nicht geladen werden.");
|
||
}
|
||
const html = await response.text();
|
||
impactsEl.innerHTML = html;
|
||
} catch (error) {
|
||
impactsEl.textContent = "Infos konnten nicht geladen werden.";
|
||
}
|
||
};
|
||
|
||
document.addEventListener("click", handleDeleteClick, true);
|
||
|
||
if (cancelBtn) {
|
||
cancelBtn.addEventListener("click", closeDialog);
|
||
}
|
||
dialog.addEventListener("cancel", closeDialog);
|
||
|
||
if (confirmBtn) {
|
||
confirmBtn.addEventListener("click", async (event) => {
|
||
event.preventDefault();
|
||
if (!currentDeleteEndpoint || !csrfInput) return;
|
||
setError("");
|
||
try {
|
||
const response = await fetch(currentDeleteEndpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
csrf_token: csrfInput.value || "",
|
||
}),
|
||
});
|
||
let data = null;
|
||
try {
|
||
data = await response.clone().json();
|
||
} catch {
|
||
data = null;
|
||
}
|
||
if (!response.ok) {
|
||
throw new Error(data?.error || "Löschen fehlgeschlagen.");
|
||
}
|
||
closeDialog();
|
||
if (currentEntryId) {
|
||
const rows = document.querySelectorAll(`tr[data-entry-id='${CSS.escape(currentEntryId)}']`);
|
||
rows.forEach((row) => row.remove());
|
||
const countEl = document.getElementById("baende-count");
|
||
if (countEl) {
|
||
const raw = countEl.textContent || "";
|
||
const hasSlash = raw.includes("/");
|
||
const nums = raw.match(/\d+/g) || [];
|
||
if (nums.length > 0) {
|
||
let current = parseInt(nums[0], 10);
|
||
let total = nums.length > 1 ? parseInt(nums[1], 10) : null;
|
||
if (!Number.isNaN(current) && current > 0) current -= 1;
|
||
if (total !== null && !Number.isNaN(total) && total > 0) total -= 1;
|
||
if (hasSlash && total !== null) {
|
||
countEl.textContent = `${current} / ${total} Bände`;
|
||
} else {
|
||
countEl.textContent = `${current} Bände`;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
setError(error instanceof Error ? error.message : "Löschen fehlgeschlagen.");
|
||
}
|
||
});
|
||
}
|
||
})();
|
||
</script>
|