+more filters on /baende endpoint

This commit is contained in:
Simon Martens
2026-01-27 10:37:45 +01:00
parent cac2b0916c
commit 826c08add2
7 changed files with 536 additions and 66 deletions

View File

@@ -223,6 +223,10 @@ func (p *BaendePage) buildResultData(app core.App, ma pagemodels.IApp, e *core.R
// Get filters from query params // Get filters from query params
search := strings.TrimSpace(e.Request.URL.Query().Get("search")) search := strings.TrimSpace(e.Request.URL.Query().Get("search"))
letter := strings.ToUpper(strings.TrimSpace(e.Request.URL.Query().Get("letter"))) letter := strings.ToUpper(strings.TrimSpace(e.Request.URL.Query().Get("letter")))
status := strings.TrimSpace(e.Request.URL.Query().Get("status"))
person := strings.TrimSpace(e.Request.URL.Query().Get("person"))
yearStr := strings.TrimSpace(e.Request.URL.Query().Get("year"))
place := strings.TrimSpace(e.Request.URL.Query().Get("place"))
// Validate letter // Validate letter
if letter != "" { if letter != "" {
@@ -298,9 +302,53 @@ func (p *BaendePage) buildResultData(app core.App, ma pagemodels.IApp, e *core.R
return data, fmt.Errorf("failed to get entries agents from cache") return data, fmt.Errorf("failed to get entries agents from cache")
} }
// Apply search or letter filter // Determine active filter (only one at a time)
activeFilterType := ""
activeFilterValue := ""
switch {
case status != "":
activeFilterType = "status"
activeFilterValue = status
person = ""
yearStr = ""
place = ""
case person != "":
activeFilterType = "person"
activeFilterValue = person
yearStr = ""
place = ""
case yearStr != "":
activeFilterType = "year"
activeFilterValue = yearStr
place = ""
case place != "":
activeFilterType = "place"
activeFilterValue = place
}
if activeFilterType != "" {
search = ""
letter = ""
}
// Apply search/letter/filters
var filteredEntries []*dbmodels.Entry var filteredEntries []*dbmodels.Entry
if search != "" { if activeFilterType != "" {
switch activeFilterType {
case "status":
filteredEntries = filterEntriesByStatus(allEntries, status)
case "person":
filteredEntries = filterEntriesByAgent(allEntries, entryAgentsMap, person)
case "year":
yearVal, err := strconv.Atoi(yearStr)
if err != nil {
filteredEntries = []*dbmodels.Entry{}
} else {
filteredEntries = filterEntriesByYear(allEntries, yearVal)
}
case "place":
filteredEntries = filterEntriesByPlace(allEntries, place)
}
} else if search != "" {
trimmedSearch := strings.TrimSpace(search) trimmedSearch := strings.TrimSpace(search)
if utf8.RuneCountInString(trimmedSearch) >= 3 { if utf8.RuneCountInString(trimmedSearch) >= 3 {
entries, err := searchBaendeEntries(app, trimmedSearch) entries, err := searchBaendeEntries(app, trimmedSearch)
@@ -396,6 +444,8 @@ func (p *BaendePage) buildResultData(app core.App, ma pagemodels.IApp, e *core.R
data["sort_field"] = sort data["sort_field"] = sort
data["sort_order"] = order data["sort_order"] = order
data["csrf_token"] = req.Session().Token data["csrf_token"] = req.Session().Token
data["active_filter_type"] = activeFilterType
data["active_filter_value"] = activeFilterValue
// Keep letters array for navigation // Keep letters array for navigation
letters := []string{ letters := []string{
@@ -404,6 +454,16 @@ func (p *BaendePage) buildResultData(app core.App, ma pagemodels.IApp, e *core.R
} }
data["letters"] = letters data["letters"] = letters
// Build filter lists
data["filter_statuses"] = buildStatusFilters()
data["filter_status_labels"] = buildStatusLabelMap()
data["filter_agents"] = buildAgentFilters(agentsMap)
data["filter_agent_labels"] = buildAgentLabelMap(agentsMap)
data["filter_places"] = buildPlaceFilters(placesMap)
data["filter_place_labels"] = buildPlaceLabelMap(placesMap)
data["filter_years"] = buildYearFilters(allEntries)
data["filter_year_labels"] = buildYearLabelMap(allEntries)
return data, nil return data, nil
} }
@@ -632,3 +692,153 @@ func searchBaendeEntriesQuick(app core.App, query string) ([]*dbmodels.Entry, er
dbmodels.Sort_Entries_Title_Year(entries) dbmodels.Sort_Entries_Title_Year(entries)
return entries, nil return entries, nil
} }
func filterEntriesByStatus(entries []*dbmodels.Entry, status string) []*dbmodels.Entry {
if status == "" {
return entries
}
results := make([]*dbmodels.Entry, 0, len(entries))
for _, entry := range entries {
if entry.EditState() == status {
results = append(results, entry)
}
}
return results
}
func filterEntriesByAgent(entries []*dbmodels.Entry, entryAgentsMap map[string][]*dbmodels.REntriesAgents, agentID string) []*dbmodels.Entry {
if agentID == "" {
return entries
}
results := make([]*dbmodels.Entry, 0, len(entries))
for _, entry := range entries {
rels := entryAgentsMap[entry.Id]
for _, rel := range rels {
if rel.Agent() == agentID {
results = append(results, entry)
break
}
}
}
return results
}
func filterEntriesByYear(entries []*dbmodels.Entry, year int) []*dbmodels.Entry {
results := make([]*dbmodels.Entry, 0, len(entries))
for _, entry := range entries {
if entry.Year() == year {
results = append(results, entry)
}
}
return results
}
func filterEntriesByPlace(entries []*dbmodels.Entry, placeID string) []*dbmodels.Entry {
if placeID == "" {
return entries
}
results := make([]*dbmodels.Entry, 0, len(entries))
for _, entry := range entries {
for _, pid := range entry.Places() {
if pid == placeID {
results = append(results, entry)
break
}
}
}
return results
}
func buildStatusFilters() []map[string]string {
labels := buildStatusLabelMap()
allowed := []string{"Unknown", "ToDo", "Review", "Seen", "Edited"}
filters := make([]map[string]string, 0, len(allowed))
for _, val := range allowed {
label := val
if mapped, ok := labels[val]; ok {
label = mapped
}
filters = append(filters, map[string]string{
"value": val,
"label": label,
})
}
return filters
}
func buildStatusLabelMap() map[string]string {
return map[string]string{
"Unknown": "Gesucht",
"ToDo": "Zu erledigen",
"Review": "Überprüfen",
"Seen": "Autopsiert",
"Edited": "Vollständig Erfasst",
}
}
func buildAgentFilters(agentsMap map[string]*dbmodels.Agent) []*dbmodels.Agent {
agents := make([]*dbmodels.Agent, 0, len(agentsMap))
for _, agent := range agentsMap {
agents = append(agents, agent)
}
dbmodels.Sort_Agents_Name(agents)
return agents
}
func buildAgentLabelMap(agentsMap map[string]*dbmodels.Agent) map[string]string {
labels := make(map[string]string, len(agentsMap))
for id, agent := range agentsMap {
if agent != nil {
labels[id] = agent.Name()
}
}
return labels
}
func buildPlaceFilters(placesMap map[string]*dbmodels.Place) []*dbmodels.Place {
places := make([]*dbmodels.Place, 0, len(placesMap))
for _, place := range placesMap {
places = append(places, place)
}
dbmodels.Sort_Places_Name(places)
return places
}
func buildPlaceLabelMap(placesMap map[string]*dbmodels.Place) map[string]string {
labels := make(map[string]string, len(placesMap))
for id, place := range placesMap {
if place != nil {
labels[id] = place.Name()
}
}
return labels
}
func buildYearFilters(entries []*dbmodels.Entry) []int {
yearSet := map[int]struct{}{}
for _, entry := range entries {
yearSet[entry.Year()] = struct{}{}
}
years := make([]int, 0, len(yearSet))
for year := range yearSet {
years = append(years, year)
}
slices.Sort(years)
return years
}
func buildYearLabelMap(entries []*dbmodels.Entry) map[string]string {
labels := map[string]string{}
for _, entry := range entries {
year := entry.Year()
if _, ok := labels[strconv.Itoa(year)]; ok {
continue
}
if year == 0 {
labels[strconv.Itoa(year)] = "ohne Jahr"
} else {
labels[strconv.Itoa(year)] = strconv.Itoa(year)
}
}
return labels
}

File diff suppressed because one or more lines are too long

View File

@@ -9,6 +9,42 @@
selectedLetter: '{{ $model.letter }}', selectedLetter: '{{ $model.letter }}',
sortField: '{{ if $model.sort_field }}{{ $model.sort_field }}{{ else }}title{{ end }}', sortField: '{{ if $model.sort_field }}{{ $model.sort_field }}{{ else }}title{{ end }}',
sortOrder: '{{ if $model.sort_order }}{{ $model.sort_order }}{{ else }}asc{{ end }}', sortOrder: '{{ if $model.sort_order }}{{ $model.sort_order }}{{ else }}asc{{ end }}',
activeFilterType: '{{ $model.active_filter_type }}',
activeFilterValue: '{{ $model.active_filter_value }}',
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 -}}
},
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 -}}
},
appendActiveFilter(params) {
if (this.activeFilterType && this.activeFilterValue) {
params.set(this.activeFilterType, this.activeFilterValue);
}
},
clearFilters() {
this.activeFilterType = '';
this.activeFilterValue = '';
},
closeOtherDropdowns(current) {
document.querySelectorAll('details').forEach((d) => {
if (d !== current) d.open = false;
});
},
changeSort(field) { changeSort(field) {
if (this.sortField === field) { if (this.sortField === field) {
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
@@ -23,12 +59,15 @@
params.set('sort', this.sortField); params.set('sort', this.sortField);
params.set('order', this.sortOrder); params.set('order', this.sortOrder);
params.set('offset', 0); params.set('offset', 0);
this.appendActiveFilter(params);
if (!this.activeFilterType) {
if (this.search) { if (this.search) {
params.set('search', this.search); params.set('search', this.search);
} }
if (this.selectedLetter) { if (this.selectedLetter) {
params.set('letter', this.selectedLetter); params.set('letter', this.selectedLetter);
} }
}
const queryString = params.toString(); const queryString = params.toString();
htmx.ajax('GET', `/baende/results/?${queryString}`, { htmx.ajax('GET', `/baende/results/?${queryString}`, {
@@ -42,12 +81,15 @@
params.set('offset', this.offset); params.set('offset', this.offset);
params.set('sort', this.sortField); params.set('sort', this.sortField);
params.set('order', this.sortOrder); params.set('order', this.sortOrder);
this.appendActiveFilter(params);
if (!this.activeFilterType) {
if (this.search) { if (this.search) {
params.set('search', this.search); params.set('search', this.search);
} }
if (this.selectedLetter) { if (this.selectedLetter) {
params.set('letter', this.selectedLetter); params.set('letter', this.selectedLetter);
} }
}
const query = params.toString(); const query = params.toString();
const newUrl = query ? `/baende/?${query}` : '/baende/'; const newUrl = query ? `/baende/?${query}` : '/baende/';
window.history.replaceState(null, '', newUrl); window.history.replaceState(null, '', newUrl);
@@ -57,12 +99,15 @@
params.set('offset', this.offset); params.set('offset', this.offset);
params.set('sort', this.sortField); params.set('sort', this.sortField);
params.set('order', this.sortOrder); params.set('order', this.sortOrder);
this.appendActiveFilter(params);
if (!this.activeFilterType) {
if (this.search) { if (this.search) {
params.set('search', this.search); params.set('search', this.search);
} }
if (this.selectedLetter) { if (this.selectedLetter) {
params.set('letter', this.selectedLetter); params.set('letter', this.selectedLetter);
} }
}
return `/baende/more/?${params.toString()}`; return `/baende/more/?${params.toString()}`;
} }
}" }"
@@ -70,9 +115,27 @@
if ($event.detail.target && $event.detail.target.id === 'baenderesults') { if ($event.detail.target && $event.detail.target.id === 'baenderesults') {
const responseUrl = $event.detail.xhr?.responseURL || window.location.href; const responseUrl = $event.detail.xhr?.responseURL || window.location.href;
const params = new URL(responseUrl).searchParams; const params = new URL(responseUrl).searchParams;
selectedLetter = params.get('letter') || '';
sortField = params.get('sort') || sortField; sortField = params.get('sort') || sortField;
sortOrder = params.get('order') || sortOrder; sortOrder = params.get('order') || sortOrder;
const filterKeys = ['status', 'person', 'year', 'place'];
activeFilterType = '';
activeFilterValue = '';
filterKeys.some((key) => {
const val = params.get(key);
if (val) {
activeFilterType = key;
activeFilterValue = val;
return true;
}
return false;
});
if (activeFilterType) {
search = '';
selectedLetter = '';
} else {
search = params.get('search') || '';
selectedLetter = params.get('letter') || '';
}
updateUrl(); updateUrl();
} }
" "
@@ -110,8 +173,8 @@ class="container-normal font-sans mt-10">
value="{{ $model.search }}" value="{{ $model.search }}"
placeholder="Signatur oder Suchbegriff" placeholder="Signatur oder Suchbegriff"
x-model="search" x-model="search"
@input.debounce.500="((search.trim().length >= 3) || /^[0-9]+$/.test(search.trim()) || search === '') && $el.form.requestSubmit()" @input.debounce.500="selectedLetter = ''; clearFilters(); ((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()" @search.debounce.500="selectedLetter = ''; clearFilters(); ((search.trim().length >= 3) || /^[0-9]+$/.test(search.trim()) || search === '') && $el.form.requestSubmit()"
autocomplete="off" /> autocomplete="off" />
<button x-show="false">Suchen</button> <button x-show="false">Suchen</button>
</form> </form>
@@ -120,9 +183,10 @@ class="container-normal font-sans mt-10">
<!-- Alphabet navigation toggle --> <!-- Alphabet navigation toggle -->
<div class="relative"> <div class="relative">
<details class="font-sans text-base list-none" data-role="alphabet-toggle" @toggle="alphabetOpen = $el.open"> <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 text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2"> <summary class="cursor-pointer text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2"
Alphabet :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> <i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': alphabetOpen }"></i>
</summary> </summary>
<div class="absolute left-0 mt-2 z-10 bg-white rounded-md shadow-lg border border-gray-200"> <div class="absolute left-0 mt-2 z-10 bg-white rounded-md shadow-lg border border-gray-200">
@@ -134,7 +198,7 @@ class="container-normal font-sans mt-10">
hx-swap="outerHTML" hx-swap="outerHTML"
hx-indicator="body" hx-indicator="body"
hx-push-url="/baende/?letter={{ $ch }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}" hx-push-url="/baende/?letter={{ $ch }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
@click="offset = 0; hasMore = true; alphabetOpen = false; selectedLetter = '{{ $ch }}'; search = ''" @click="offset = 0; hasMore = true; alphabetOpen = false; selectedLetter = '{{ $ch }}'; search = ''; clearFilters()"
:class="selectedLetter === '{{ $ch }}' ? 'bg-stone-200 font-bold' : ''" :class="selectedLetter === '{{ $ch }}' ? 'bg-stone-200 font-bold' : ''"
class="text-center py-1 px-2 rounded hover:bg-gray-100 no-underline transition-colors"> class="text-center py-1 px-2 rounded hover:bg-gray-100 no-underline transition-colors">
{{ $ch }} {{ $ch }}
@@ -146,7 +210,7 @@ class="container-normal font-sans mt-10">
hx-swap="outerHTML" hx-swap="outerHTML"
hx-indicator="body" hx-indicator="body"
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}" hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
@click="offset = 0; hasMore = true; alphabetOpen = false; selectedLetter = ''; search = ''" @click="offset = 0; hasMore = true; alphabetOpen = false; selectedLetter = ''; search = ''; clearFilters()"
class="text-center py-1 px-2 rounded hover:bg-gray-100 no-underline transition-colors col-span-13 border-t mt-1"> class="text-center py-1 px-2 rounded hover:bg-gray-100 no-underline transition-colors col-span-13 border-t mt-1">
Alle Alle
</a> </a>
@@ -155,11 +219,190 @@ class="container-normal font-sans mt-10">
</details> </details>
</div> </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 text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2"
:class="activeFilterType === 'status' ? 'font-semibold text-slate-900 ring-1 ring-slate-300' : ''">
<span x-text="activeFilterType === 'status' ? `Status: ${statusLabels[activeFilterValue] || activeFilterValue}` : 'Status'"></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-64 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" data-role="filter-list">
<a data-role="filter-item" data-label="Alle" href="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
hx-get="/baende/results/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
hx-target="#baenderesults"
hx-swap="outerHTML"
hx-indicator="body"
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
@click="offset = 0; hasMore = true; open = false; clearFilters(); search = ''; selectedLetter = ''"
x-show="activeFilterType === '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="/baende/?status={{ $s.value }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
hx-get="/baende/results/?status={{ $s.value }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
hx-target="#baenderesults"
hx-swap="outerHTML"
hx-indicator="body"
hx-push-url="/baende/?status={{ $s.value }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
@click="offset = 0; hasMore = true; open = false; activeFilterType = 'status'; activeFilterValue = '{{ $s.value }}'; search = ''; selectedLetter = ''"
:class="activeFilterType === 'status' && activeFilterValue === '{{ $s.value }}' ? 'bg-stone-100 font-semibold' : ''"
class="px-2 py-1 rounded hover:bg-gray-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 text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2"
:class="activeFilterType === 'person' ? 'font-semibold text-slate-900 ring-1 ring-slate-300' : ''">
<span x-text="activeFilterType === 'person' ? `Person: ${personLabels[activeFilterValue] || activeFilterValue}` : '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-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="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
hx-get="/baende/results/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
hx-target="#baenderesults"
hx-swap="outerHTML"
hx-indicator="body"
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
@click="offset = 0; hasMore = true; open = false; clearFilters(); search = ''; selectedLetter = ''"
x-show="activeFilterType === '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>
<input data-role="filter-search" type="search" placeholder="Personen filtern..." class="w-full px-2 py-1 border border-stone-200 rounded text-sm" />
<div class="mt-2 max-h-80 overflow-auto flex flex-col gap-0.5 text-sm text-gray-700" data-role="filter-list">
{{- range $_, $a := $model.filter_agents -}}
<a data-role="filter-item" data-label="{{ $a.Name }}" href="/baende/?person={{ $a.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
hx-get="/baende/results/?person={{ $a.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
hx-target="#baenderesults"
hx-swap="outerHTML"
hx-indicator="body"
hx-push-url="/baende/?person={{ $a.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
@click="offset = 0; hasMore = true; open = false; activeFilterType = 'person'; activeFilterValue = '{{ $a.Id }}'; search = ''; selectedLetter = ''"
:class="activeFilterType === 'person' && activeFilterValue === '{{ $a.Id }}' ? 'bg-stone-100 font-semibold' : ''"
class="px-2 py-1 rounded hover:bg-gray-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>
<!-- 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 text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2"
:class="activeFilterType === 'year' ? 'font-semibold text-slate-900 ring-1 ring-slate-300' : ''">
<span x-text="activeFilterType === 'year' ? `Jahr: ${yearLabels[activeFilterValue] || activeFilterValue}` : '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-56 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="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
hx-get="/baende/results/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
hx-target="#baenderesults"
hx-swap="outerHTML"
hx-indicator="body"
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
@click="offset = 0; hasMore = true; open = false; clearFilters(); search = ''; selectedLetter = ''"
x-show="activeFilterType === '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>
<input data-role="filter-search" type="search" placeholder="Jahre filtern..." class="w-full px-2 py-1 border border-stone-200 rounded text-sm" />
<div class="mt-2 max-h-80 overflow-auto flex flex-col gap-0.5 text-sm text-gray-700" 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="/baende/?year={{ $y }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
hx-get="/baende/results/?year={{ $y }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
hx-target="#baenderesults"
hx-swap="outerHTML"
hx-indicator="body"
hx-push-url="/baende/?year={{ $y }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
@click="offset = 0; hasMore = true; open = false; activeFilterType = 'year'; activeFilterValue = '{{ $y }}'; search = ''; selectedLetter = ''"
:class="activeFilterType === 'year' && activeFilterValue === '{{ $y }}' ? 'bg-stone-100 font-semibold' : ''"
class="px-2 py-1 rounded hover:bg-gray-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 text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2"
:class="activeFilterType === 'place' ? 'font-semibold text-slate-900 ring-1 ring-slate-300' : ''">
<span x-text="activeFilterType === 'place' ? `Ort: ${placeLabels[activeFilterValue] || activeFilterValue}` : '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-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="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
hx-get="/baende/results/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
hx-target="#baenderesults"
hx-swap="outerHTML"
hx-indicator="body"
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
@click="offset = 0; hasMore = true; open = false; clearFilters(); search = ''; selectedLetter = ''"
x-show="activeFilterType === '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>
<input data-role="filter-search" type="search" placeholder="Orte filtern..." class="w-full px-2 py-1 border border-stone-200 rounded text-sm" />
<div class="mt-2 max-h-80 overflow-auto flex flex-col gap-0.5 text-sm text-gray-700" data-role="filter-list">
{{- range $_, $p := $model.filter_places -}}
<a data-role="filter-item" data-label="{{ $p.Name }}" href="/baende/?place={{ $p.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
hx-get="/baende/results/?place={{ $p.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
hx-target="#baenderesults"
hx-swap="outerHTML"
hx-indicator="body"
hx-push-url="/baende/?place={{ $p.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
@click="offset = 0; hasMore = true; open = false; activeFilterType = 'place'; activeFilterValue = '{{ $p.Id }}'; search = ''; selectedLetter = ''"
:class="activeFilterType === 'place' && activeFilterValue === '{{ $p.Id }}' ? 'bg-stone-100 font-semibold' : ''"
class="px-2 py-1 rounded hover:bg-gray-100 no-underline transition-colors">
{{ $p.Name }}
</a>
{{- end -}}
</div>
</div>
</div>
</details>
</div>
<!-- Spalten toggle --> <!-- Spalten toggle -->
<div class="relative" x-data="{ open: false }"> <div class="relative" x-data="{ open: false }" data-role="baende-filter">
<details class="font-sans text-base list-none" data-role="baende-column-toggle" @toggle="open = $el.open"> <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 text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2"> <summary class="cursor-pointer text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2">
Spalten <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> <i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
</summary> </summary>
<div class="absolute left-0 mt-2 w-56 z-10 bg-white rounded-md shadow-lg border border-gray-200"> <div class="absolute left-0 mt-2 w-56 z-10 bg-white rounded-md shadow-lg border border-gray-200">
@@ -182,14 +425,8 @@ class="container-normal font-sans mt-10">
<div id="baende-count" class="text-lg font-semibold font-sans text-gray-600 whitespace-nowrap"> <div id="baende-count" class="text-lg font-semibold font-sans text-gray-600 whitespace-nowrap">
{{ if $model.current_count }}{{ $model.current_count }}&thinsp;/&thinsp;{{ end }}{{ if $model.total_count }}{{ $model.total_count }}{{ else }}{{ len $model.result.Entries }}{{ end }} Bände {{ if $model.current_count }}{{ $model.current_count }}&thinsp;/&thinsp;{{ end }}{{ if $model.total_count }}{{ $model.total_count }}{{ else }}{{ len $model.result.Entries }}{{ end }} Bände
</div> </div>
<button
type="button"
class="content-action-button"
onclick="window.location.assign('/almanach/new')">
<i class="ri-add-line"></i>
<span>Neuer Band</span>
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -198,11 +435,6 @@ class="container-normal font-sans mt-10">
<div id="baenderesults" class="mt-2" data-next-offset="{{ $model.next_offset }}"> <div id="baenderesults" class="mt-2" data-next-offset="{{ $model.next_offset }}">
{{ template "_baende_table" $model }} {{ template "_baende_table" $model }}
<!-- Bottom count -->
<div id="baende-count-bottom" class="mt-4 flex justify-center text-base font-semibold font-sans text-gray-600 whitespace-nowrap">
{{ if $model.current_count }}{{ $model.current_count }}&thinsp;/&thinsp;{{ end }}{{ if $model.total_count }}{{ $model.total_count }}{{ else }}{{ len $model.result.Entries }}{{ end }} Bände
</div>
<!-- Load More Button --> <!-- Load More Button -->
<div class="mt-6 flex justify-center" x-show="hasMore"> <div class="mt-6 flex justify-center" x-show="hasMore">
<button <button
@@ -229,3 +461,24 @@ class="container-normal font-sans mt-10">
</div> </div>
</div> </div>
</div> </div>
<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));
});
});
});
})();
</script>

View File

@@ -101,6 +101,13 @@
</tr> </tr>
</thead> </thead>
<tbody id="baende-tbody"> <tbody id="baende-tbody">
{{- if not (len $model.result.Entries) -}}
<tr>
<td colspan="6" class="py-6 text-center text-sm text-gray-500">
Keine Bände gefunden.
</td>
</tr>
{{- end -}}
{{- range $_, $entry := $model.result.Entries -}} {{- range $_, $entry := $model.result.Entries -}}
<tr class="border-b align-top cursor-pointer transition-colors odd:bg-white even:bg-stone-50/60 hover:bg-stone-100" <tr class="border-b align-top cursor-pointer transition-colors odd:bg-white even:bg-stone-50/60 hover:bg-stone-100"
data-role="baende-row" data-role="baende-row"
@@ -143,16 +150,16 @@
</div> </div>
</td> </td>
<td class="py-2 pr-4"> <td class="py-2 pr-4">
<div class="font-semibold text-slate-900 text-base leading-snug inline-flex items-center gap-1"> <div class="font-semibold text-slate-900 text-base leading-snug">
{{- if $entry.PreferredTitle -}} {{- if $entry.PreferredTitle -}}
{{ $entry.PreferredTitle }} <span class="inline">{{ $entry.PreferredTitle }}</span>
{{- else if ne $entry.Year 0 -}} {{- else if ne $entry.Year 0 -}}
{{ $entry.Year }} <span class="inline">{{ $entry.Year }}</span>
{{- else -}} {{- else -}}
[o.J.] <span class="inline">[o.J.]</span>
{{- end -}} {{- end -}}
<tool-tip position="top" class="inline-flex items-center"> <tool-tip position="top" class="inline-block align-middle ml-1">
<i class="status-icon {{- if eq $entry.EditState "Edited" }} ri-checkbox-circle-line{{- else if eq $entry.EditState "Seen" }} ri-information-line{{- else if eq $entry.EditState "Review" }} ri-search-line{{- else if eq $entry.EditState "ToDo" }} ri-list-check{{- else }} ri-forbid-2-line{{- end }}" data-status="{{ $entry.EditState }}"></i> <i class="status-icon align-middle {{- if eq $entry.EditState "Edited" }} ri-checkbox-circle-line{{- else if eq $entry.EditState "Seen" }} ri-information-line{{- else if eq $entry.EditState "Review" }} ri-search-line{{- else if eq $entry.EditState "ToDo" }} ri-list-check{{- else }} ri-forbid-2-line{{- end }}" data-status="{{ $entry.EditState }}"></i>
<div class="data-tip"> <div class="data-tip">
{{- if eq $entry.EditState "Unknown" -}} {{- if eq $entry.EditState "Unknown" -}}
Gesucht Gesucht

View File

@@ -41,16 +41,16 @@
</div> </div>
</td> </td>
<td class="py-2 pr-4"> <td class="py-2 pr-4">
<div class="font-semibold text-slate-900 text-base leading-snug inline-flex items-center gap-1"> <div class="font-semibold text-slate-900 text-base leading-snug">
{{- if $entry.PreferredTitle -}} {{- if $entry.PreferredTitle -}}
{{ $entry.PreferredTitle }} <span class="inline">{{ $entry.PreferredTitle }}</span>
{{- else if ne $entry.Year 0 -}} {{- else if ne $entry.Year 0 -}}
{{ $entry.Year }} <span class="inline">{{ $entry.Year }}</span>
{{- else -}} {{- else -}}
[o.J.] <span class="inline">[o.J.]</span>
{{- end -}} {{- end -}}
<tool-tip position="top" class="inline-flex items-center"> <tool-tip position="top" class="inline-block align-middle ml-1">
<i class="status-icon {{- if eq $entry.EditState "Edited" }} ri-checkbox-circle-line{{- else if eq $entry.EditState "Seen" }} ri-information-line{{- else if eq $entry.EditState "Review" }} ri-search-line{{- else if eq $entry.EditState "ToDo" }} ri-list-check{{- else }} ri-forbid-2-line{{- end }}" data-status="{{ $entry.EditState }}"></i> <i class="status-icon align-middle {{- if eq $entry.EditState "Edited" }} ri-checkbox-circle-line{{- else if eq $entry.EditState "Seen" }} ri-information-line{{- else if eq $entry.EditState "Review" }} ri-search-line{{- else if eq $entry.EditState "ToDo" }} ri-list-check{{- else }} ri-forbid-2-line{{- end }}" data-status="{{ $entry.EditState }}"></i>
<div class="data-tip"> <div class="data-tip">
{{- if eq $entry.EditState "Unknown" -}} {{- if eq $entry.EditState "Unknown" -}}
Gesucht Gesucht

View File

@@ -7,26 +7,26 @@
loading: false, loading: false,
search: '{{ $model.search }}', search: '{{ $model.search }}',
letter: '{{ $model.letter }}', letter: '{{ $model.letter }}',
activeFilterType: '{{ $model.active_filter_type }}',
activeFilterValue: '{{ $model.active_filter_value }}',
sortField: '{{ if $model.sort_field }}{{ $model.sort_field }}{{ else }}title{{ end }}', sortField: '{{ if $model.sort_field }}{{ $model.sort_field }}{{ else }}title{{ end }}',
sortOrder: '{{ if $model.sort_order }}{{ $model.sort_order }}{{ else }}asc{{ end }}' sortOrder: '{{ if $model.sort_order }}{{ $model.sort_order }}{{ else }}asc{{ end }}'
}"> }">
<div id="baende-count" class="text-lg font-semibold font-sans text-gray-600 whitespace-nowrap"> <span id="baende-count"
hx-swap-oob="outerHTML"
class="text-lg font-semibold font-sans text-gray-600 whitespace-nowrap">
{{ if $model.current_count }}{{ $model.current_count }}&thinsp;/&thinsp;{{ end }}{{ if $model.total_count }}{{ $model.total_count }}{{ else }}{{ len $model.result.Entries }}{{ end }} Bände {{ if $model.current_count }}{{ $model.current_count }}&thinsp;/&thinsp;{{ end }}{{ if $model.total_count }}{{ $model.total_count }}{{ else }}{{ len $model.result.Entries }}{{ end }} Bände
</div> </span>
{{ template "_baende_table" $model }} {{ template "_baende_table" $model }}
<div id="baende-count-bottom" class="mt-4 flex justify-center text-base font-semibold font-sans text-gray-600 whitespace-nowrap">
{{ if $model.current_count }}{{ $model.current_count }}&thinsp;/&thinsp;{{ end }}{{ if $model.total_count }}{{ $model.total_count }}{{ else }}{{ len $model.result.Entries }}{{ end }} Bände
</div>
<!-- Load More Button --> <!-- Load More Button -->
<div class="mt-6 flex justify-center" x-show="hasMore"> <div class="mt-6 flex justify-center" x-show="hasMore">
<button <button
type="button" type="button"
class="content-action-button" class="content-action-button"
:hx-get="`/baende/more/?offset=${offset}&search=${search}&letter=${letter}&sort=${sortField}&order=${sortOrder}`" :hx-get="`/baende/more/?offset=${offset}&${activeFilterType && activeFilterValue ? `${activeFilterType}=${encodeURIComponent(activeFilterValue)}` : `search=${search}&letter=${letter}`}&sort=${sortField}&order=${sortOrder}`"
hx-replace-url="true" hx-replace-url="true"
hx-target="#baende-tbody" hx-target="#baende-tbody"
hx-swap="beforeend" hx-swap="beforeend"

View File

@@ -41,16 +41,16 @@
</div> </div>
</td> </td>
<td class="py-2 pr-4"> <td class="py-2 pr-4">
<div class="font-semibold text-slate-900 text-base leading-snug inline-flex items-center gap-1"> <div class="font-semibold text-slate-900 text-base leading-snug">
{{- if $entry.PreferredTitle -}} {{- if $entry.PreferredTitle -}}
{{ $entry.PreferredTitle }} <span class="inline">{{ $entry.PreferredTitle }}</span>
{{- else if ne $entry.Year 0 -}} {{- else if ne $entry.Year 0 -}}
{{ $entry.Year }} <span class="inline">{{ $entry.Year }}</span>
{{- else -}} {{- else -}}
[o.J.] <span class="inline">[o.J.]</span>
{{- end -}} {{- end -}}
<tool-tip position="top" class="inline-flex items-center"> <tool-tip position="top" class="inline-block align-middle ml-1">
<i class="status-icon {{- if eq $entry.EditState "Edited" }} ri-checkbox-circle-line{{- else if eq $entry.EditState "Seen" }} ri-information-line{{- else if eq $entry.EditState "Review" }} ri-search-line{{- else if eq $entry.EditState "ToDo" }} ri-list-check{{- else }} ri-forbid-2-line{{- end }}" data-status="{{ $entry.EditState }}"></i> <i class="status-icon align-middle {{- if eq $entry.EditState "Edited" }} ri-checkbox-circle-line{{- else if eq $entry.EditState "Seen" }} ri-information-line{{- else if eq $entry.EditState "Review" }} ri-search-line{{- else if eq $entry.EditState "ToDo" }} ri-list-check{{- else }} ri-forbid-2-line{{- end }}" data-status="{{ $entry.EditState }}"></i>
<div class="data-tip"> <div class="data-tip">
{{- if eq $entry.EditState "Unknown" -}} {{- if eq $entry.EditState "Unknown" -}}
Gesucht Gesucht