Start places overhaul

This commit is contained in:
Simon Martens
2025-09-28 03:53:03 +02:00
parent 2a3d2c2323
commit adc45f2212
13 changed files with 683 additions and 42 deletions

View File

@@ -161,6 +161,7 @@ func (k *KGPZ) Routes(srv *fiber.App) error {
srv.Get(SEARCH_URL, controllers.GetSearch(k.Library, k.Search))
srv.Get(FILTER_URL, controllers.GetQuickFilter(k.Library))
srv.Get("/ort/fragment/:place", controllers.GetPlaceFragment(k.Library))
srv.Get(PLACE_OVERVIEW_URL, controllers.GetPlace(k.Library))
srv.Get(CATEGORY_OVERVIEW_URL, controllers.GetCategory(k.Library))
srv.Get(AGENTS_OVERVIEW_URL, controllers.GetAgents(k.Library))

View File

@@ -47,3 +47,36 @@ func GetPlace(kgpz *xmlmodels.Library) fiber.Handler {
}
}
}
func GetPlaceFragment(kgpz *xmlmodels.Library) fiber.Handler {
return func(c *fiber.Ctx) error {
placeID := c.Params("place", DEFAULT_PLACE)
placeID = strings.ToLower(placeID)
// Get places data using view model
places := viewmodels.PlacesView(placeID, kgpz)
// If no places found at all, return 404
if len(places.Places) == 0 {
logging.Error(nil, "No places found")
return c.SendStatus(fiber.StatusNotFound)
}
// If a specific place was requested but not found, return 404
if placeID != "" && len(placeID) > 1 && places.SelectedPlace == nil {
logging.Error(nil, "Place not found: "+placeID)
return c.SendStatus(fiber.StatusNotFound)
}
// Only render fragment if we have a selected place
if places.SelectedPlace != nil {
// Fragment view - no layout wrapper
return c.Render("/ort/fragment/", fiber.Map{
"model": places,
}, "clear")
}
// If no specific place selected, return 404
return c.SendStatus(fiber.StatusNotFound)
}
}

View File

@@ -13,6 +13,7 @@ type PlacesListView struct {
Search string
AvailableLetters []string
Places map[string]xmlmodels.Place
PlacePieceCounts map[string]int
Sorted []string
SelectedPlace *PlaceDetailView
TotalPiecesWithPlaces int
@@ -26,17 +27,23 @@ type PlaceDetailView struct {
// PlacesView returns places data for the overview page
func PlacesView(placeID string, lib *xmlmodels.Library) *PlacesListView {
res := PlacesListView{Search: placeID, Places: make(map[string]xmlmodels.Place)}
res := PlacesListView{
Search: placeID,
Places: make(map[string]xmlmodels.Place),
PlacePieceCounts: make(map[string]int),
}
av := make(map[string]bool)
// Get all places that are referenced in pieces and count total pieces with places
referencedPlaces := make(map[string]bool)
placePieceCounts := make(map[string]int)
totalPiecesWithPlaces := 0
for _, piece := range lib.Pieces.Array {
hasPlace := false
for _, placeRef := range piece.PlaceRefs {
referencedPlaces[placeRef.Ref] = true
placePieceCounts[placeRef.Ref]++
hasPlace = true
}
if hasPlace {
@@ -54,6 +61,9 @@ func PlacesView(placeID string, lib *xmlmodels.Library) *PlacesListView {
}
}
// Set the piece counts
res.PlacePieceCounts = placePieceCounts
// If a specific place is requested, get its details
if placeID != "" && len(placeID) > 1 {
if place, exists := res.Places[placeID]; exists {

View File

@@ -566,8 +566,73 @@ class V extends HTMLElement {
this.countElement && (t === "" ? this.countElement.textContent = `Alle Orte (${this.originalCount})` : e === 0 ? this.countElement.textContent = `Keine Orte gefunden für "${t}"` : this.countElement.textContent = `${e} von ${this.originalCount} Orten`);
}
}
customElements.define("places-filter", V);
class R extends HTMLElement {
constructor() {
super(), this.isExpanded = !1, this.isLoading = !1, this.hasLoaded = !1;
}
connectedCallback() {
this.setupAccordion(), this.setupEventListeners();
}
disconnectedCallback() {
this.cleanupEventListeners();
}
setupAccordion() {
if (!this.querySelector(".accordion-chevron")) {
const e = document.createElement("i");
e.className = "ri-chevron-down-line accordion-chevron transition-transform duration-200 text-slate-400";
const t = this.querySelector('[class*="bg-slate-100"]');
t && t.parentNode.insertBefore(e, t);
}
if (!this.querySelector("[data-content]")) {
const e = this.getAttribute("data-place-id"), t = document.createElement("div");
t.setAttribute("data-content", ""), t.className = "accordion-content overflow-hidden transition-all duration-300 max-h-0", t.setAttribute("hx-get", `/ort/fragment/${e}`), t.setAttribute("hx-trigger", "load-content"), t.setAttribute("hx-swap", "innerHTML"), t.setAttribute("hx-target", "this"), t.setAttribute("hx-select", ".place-fragment-content"), t.setAttribute("hx-boost", "false"), this.appendChild(t);
}
}
setupEventListeners() {
this.addEventListener("click", this.handleClick.bind(this));
}
cleanupEventListeners() {
this.removeEventListener("click", this.handleClick.bind(this));
}
handleClick(e) {
const t = this.querySelector("[data-content]");
t && t.contains(e.target) || this.toggle();
}
toggle() {
this.isExpanded ? this.collapse() : this.expand();
}
expand() {
if (this.isLoading) return;
this.isExpanded = !0, this.updateChevron();
const e = this.querySelector("[data-content]");
e && (this.hasLoaded ? e.style.maxHeight = e.scrollHeight + "px" : this.loadContent());
}
collapse() {
this.isExpanded = !1, this.updateChevron();
const e = this.querySelector("[data-content]");
e && (e.style.maxHeight = "0px");
}
loadContent() {
this.isLoading = !0;
const e = this.querySelector("[data-content]");
e.innerHTML = '<div class="p-4 text-center text-slate-500">Lädt...</div>', e.style.maxHeight = e.scrollHeight + "px";
const t = () => {
this.hasLoaded = !0, this.isLoading = !1, setTimeout(() => {
e.style.maxHeight = e.scrollHeight + "px";
}, 10), e.removeEventListener("htmx:afterRequest", t);
}, i = () => {
this.isLoading = !1, e.innerHTML = '<div class="p-4 text-center text-red-500">Fehler beim Laden</div>', e.removeEventListener("htmx:responseError", i);
};
e.addEventListener("htmx:afterRequest", t), e.addEventListener("htmx:responseError", i), htmx.trigger(e, "load-content");
}
updateChevron() {
const e = this.querySelector(".accordion-chevron");
e && (this.isExpanded ? e.style.transform = "rotate(180deg)" : e.style.transform = "rotate(0deg)");
}
}
customElements.define("places-filter", V);
customElements.define("place-accordion", R);
class z extends HTMLElement {
constructor() {
super(), this.searchInput = null, this.itemCards = [], this.countElement = null, this.debounceTimer = null, this.originalCount = 0;
}
@@ -640,8 +705,8 @@ class R extends HTMLElement {
}
}
}
customElements.define("generic-filter", R);
class z extends HTMLElement {
customElements.define("generic-filter", z);
class D extends HTMLElement {
constructor() {
super(), this.resizeObserver = null;
}
@@ -994,7 +1059,7 @@ class z extends HTMLElement {
return "KGPZ";
}
}
customElements.define("single-page-viewer", z);
customElements.define("single-page-viewer", D);
document.body.addEventListener("htmx:beforeRequest", function(a) {
const e = document.querySelector("single-page-viewer");
e && e.style.display !== "none" && (console.log("Cleaning up single page viewer before HTMX navigation"), e.close());
@@ -1003,7 +1068,7 @@ window.addEventListener("beforeunload", function() {
const a = document.querySelector("single-page-viewer");
a && a.close();
});
class D extends HTMLElement {
class j extends HTMLElement {
constructor() {
super(), this.isVisible = !1, this.scrollHandler = null, this.htmxAfterSwapHandler = null;
}
@@ -1044,8 +1109,8 @@ class D extends HTMLElement {
});
}
}
customElements.define("scroll-to-top-button", D);
class j extends HTMLElement {
customElements.define("scroll-to-top-button", j);
class F extends HTMLElement {
constructor() {
super(), this.pageObserver = null, this.pageContainers = /* @__PURE__ */ new Map(), this.singlePageViewerActive = !1, this.singlePageViewerCurrentPage = null, this.boundHandleSinglePageViewer = this.handleSinglePageViewer.bind(this);
}
@@ -1164,8 +1229,8 @@ class j extends HTMLElement {
this.pageObserver && (this.pageObserver.disconnect(), this.pageObserver = null), document.removeEventListener("singlepageviewer:opened", this.boundHandleSinglePageViewer), document.removeEventListener("singlepageviewer:closed", this.boundHandleSinglePageViewer), document.removeEventListener("singlepageviewer:pagechanged", this.boundHandleSinglePageViewer), this.pageContainers.clear();
}
}
customElements.define("inhaltsverzeichnis-scrollspy", j);
class F extends HTMLElement {
customElements.define("inhaltsverzeichnis-scrollspy", F);
class K extends HTMLElement {
constructor() {
super(), this.innerHTML = `
<div id="error-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center backdrop-blur-sm">
@@ -1213,11 +1278,11 @@ class F extends HTMLElement {
window.showErrorModal = (e) => this.show(e), window.closeErrorModal = () => this.close();
}
}
customElements.define("error-modal", F);
customElements.define("error-modal", K);
window.currentPageContainers = window.currentPageContainers || [];
window.currentActiveIndex = window.currentActiveIndex || 0;
window.pageObserver = window.pageObserver || null;
function K(a, e, t, i = null) {
function W(a, e, t, i = null) {
let n = document.querySelector("single-page-viewer");
n || (n = document.createElement("single-page-viewer"), document.body.appendChild(n));
const s = a.closest('[data-beilage="true"]') !== null, o = window.templateData && window.templateData.targetPage ? window.templateData.targetPage : 0, r = a.closest(".newspaper-page-container, .piece-page-container");
@@ -1235,7 +1300,7 @@ function K(a, e, t, i = null) {
function E() {
document.getElementById("pageModal").classList.add("hidden");
}
function W() {
function Z() {
if (window.pageObserver && (window.pageObserver.disconnect(), window.pageObserver = null), window.currentPageContainers = Array.from(document.querySelectorAll(".newspaper-page-container")), window.currentActiveIndex = 0, b(), document.querySelector(".newspaper-page-container")) {
let e = /* @__PURE__ */ new Set();
window.pageObserver = new IntersectionObserver(
@@ -1256,7 +1321,7 @@ function W() {
});
}
}
function Z() {
function J() {
if (window.currentActiveIndex > 0) {
let a = -1;
const e = [];
@@ -1277,7 +1342,7 @@ function Z() {
}, 100));
}
}
function J() {
function G() {
if (window.currentActiveIndex < window.currentPageContainers.length - 1) {
let a = -1;
const e = [];
@@ -1298,7 +1363,7 @@ function J() {
}, 100));
}
}
function G() {
function Y() {
if (C()) {
const e = document.querySelector("#newspaper-content .newspaper-page-container");
e && e.scrollIntoView({
@@ -1338,7 +1403,7 @@ function b() {
i ? (t.title = "Zur Hauptausgabe", t.className = "w-14 h-10 lg:w-16 lg:h-12 px-2 py-1 bg-gray-100 hover:bg-gray-200 text-gray-700 hover:text-gray-800 border border-gray-300 transition-colors duration-200 flex items-center justify-center cursor-pointer", n && (n.className = "ri-file-text-line text-lg lg:text-xl")) : (t.title = "Zu Beilage", t.className = "w-14 h-10 lg:w-16 lg:h-12 px-2 py-1 bg-amber-100 hover:bg-amber-200 text-amber-700 hover:text-amber-800 border border-amber-300 transition-colors duration-200 flex items-center justify-center cursor-pointer", n && (n.className = "ri-attachment-line text-lg lg:text-xl"));
}
}
function Y() {
function U() {
const a = document.getElementById("shareLinkBtn");
let e = "";
if (window.currentActiveIndex !== void 0 && window.currentPageContainers && window.currentPageContainers[window.currentActiveIndex]) {
@@ -1373,7 +1438,7 @@ function x(a, e) {
}
}
}
function U() {
function X() {
const a = document.getElementById("citationBtn"), e = document.title || "KGPZ";
let t = window.location.origin + window.location.pathname;
t.includes("#") && (t = t.split("#")[0]);
@@ -1426,7 +1491,7 @@ function g(a, e) {
}, 200);
}, 2e3);
}
function X(a, e, t = !1) {
function Q(a, e, t = !1) {
let i = "";
if (t)
i = window.location.origin + window.location.pathname + `#beilage-1-page-${a}`;
@@ -1458,7 +1523,7 @@ function X(a, e, t = !1) {
}
}
}
function Q(a, e) {
function _(a, e) {
const t = document.title || "KGPZ", i = window.location.pathname.split("/");
let n;
if (i.length >= 3) {
@@ -1487,7 +1552,7 @@ function Q(a, e) {
}
}
function L() {
W(), window.addEventListener("scroll", function() {
Z(), window.addEventListener("scroll", function() {
clearTimeout(window.scrollTimeout), window.scrollTimeout = setTimeout(() => {
b();
}, 50);
@@ -1495,7 +1560,7 @@ function L() {
a.key === "Escape" && E();
});
}
function P() {
function k() {
const a = window.location.pathname;
document.querySelectorAll(".citation-link[data-citation-url]").forEach((t) => {
const i = t.getAttribute("data-citation-url");
@@ -1512,7 +1577,7 @@ function P() {
n ? (t.classList.add("text-red-700", "pointer-events-none"), t.setAttribute("aria-current", "page")) : (t.classList.remove("text-red-700", "pointer-events-none"), t.removeAttribute("aria-current"));
});
}
function k() {
function P() {
const a = window.location.pathname, e = document.body;
e.classList.remove(
"page-akteure",
@@ -1524,21 +1589,21 @@ function k() {
"page-edition"
), a.includes("/akteure/") || a.includes("/autoren") ? e.classList.add("page-akteure") : a.match(/\/\d{4}\/\d+/) ? e.classList.add("page-ausgabe") : a.includes("/search") || a.includes("/suche") ? e.classList.add("page-search") : a.includes("/ort/") ? e.classList.add("page-ort") : a.includes("/kategorie/") ? e.classList.add("page-kategorie") : a.includes("/beitrag/") ? e.classList.add("page-piece") : a.includes("/edition") && e.classList.add("page-edition");
}
window.enlargePage = K;
window.enlargePage = W;
window.closeModal = E;
window.scrollToPreviousPage = Z;
window.scrollToNextPage = J;
window.scrollToBeilage = G;
window.shareCurrentPage = Y;
window.generateCitation = U;
window.copyPagePermalink = X;
window.generatePageCitation = Q;
k();
window.scrollToPreviousPage = J;
window.scrollToNextPage = G;
window.scrollToBeilage = Y;
window.shareCurrentPage = U;
window.generateCitation = X;
window.copyPagePermalink = Q;
window.generatePageCitation = _;
P();
k();
document.querySelector(".newspaper-page-container") && L();
let _ = function(a) {
k(), P(), S(), setTimeout(() => {
let ee = function(a) {
P(), k(), S(), setTimeout(() => {
document.querySelector(".newspaper-page-container") && L();
}, 50);
};
document.body.addEventListener("htmx:afterSettle", _);
document.body.addEventListener("htmx:afterSettle", ee);

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,74 @@
# Orte Components Documentation
## Overview
The Orte (Places) section has been updated to use an expandable list view instead of a card grid. This provides better usability on mobile devices and allows for lazy-loading of place details.
## Architecture
### Templates
- `_place_expandable.gohtml` - Template for individual expandable place entries (currently unused, kept for reference)
- `_place_details_fragment.gohtml` - HTMX fragment for place details (header + pieces list)
### JavaScript Components
- `ExpandablePlacesList` - Main web component for the expandable places list
- No shadow DOM - full Tailwind CSS support
- Property-based data passing
- HTMX integration for lazy-loading details
### Backend
- `GetPlaceDetails()` - New controller handler for HTMX requests
- Route: `/ort/{place}/details`
- Returns place details fragment
## Features
### Expandable Interaction
- Click to expand/collapse place entries
- Only one place can be expanded at a time
- Smooth CSS transitions for expand/collapse
- Keyboard navigation support (Enter/Space)
### Data Loading
- Initial places list loaded from server-side template
- Place details loaded on-demand via HTMX
- Loading states and error handling
- Caching of loaded content
### Accessibility
- Proper ARIA attributes (`aria-expanded`, `aria-hidden`, `aria-controls`)
- Keyboard navigation support
- Screen reader friendly labels
- Focus management
### Preserved Functionality
- `/ort/{id}` permalink URLs still work for direct access
- Search filtering via existing `GenericFilter` component
- External links (Wikipedia, Geonames, OpenStreetMap) in expanded view
- Citation links to specific newspaper issues
## Usage
The component is automatically initialized on page load with data from the Go template:
```html
<expandable-places-list id="places-list"></expandable-places-list>
<script>
const placesList = document.getElementById('places-list');
placesList.places = placesData; // Array of place objects
</script>
```
## Data Structure
Each place object contains:
- `ID`: Place identifier
- `Names`: Array of place names
- `Geo`: Geonames URL (optional)
- `PieceCount`: Number of associated pieces
## Technical Notes
- Uses CSS `max-height` transitions for smooth expand/collapse
- HTMX events handled for loading states
- Event delegation for dynamically created content
- Compatible with existing search filtering system

View File

@@ -0,0 +1,179 @@
{{- /*
Place Details Fragment
Used for HTMX requests to load place details into expandable content
Contains place header information and associated pieces
*/ -}}
{{- /* Place Header Information */ -}}
<div class="mb-6">
{{ $geonames := GetGeonames .place.Place.Geo }}
{{- /* Name and external links */ -}}
<div class="flex items-start justify-between gap-4 mb-4">
<div class="flex-1">
<h2 class="text-xl font-bold text-slate-800 mb-2">
{{ if .place.Place.Names }}
{{ index .place.Place.Names 0 }}
{{ else }}
{{ .place.Place.ID }}
{{ end }}
</h2>
{{- /* Geographic Information from Geonames */ -}}
{{ if ne $geonames nil }}
<div class="text-sm text-slate-700 mb-2">
{{- /* Modern Country Info (only if not Germany) */ -}}
{{ $mainPlaceName := "" }}
{{ if .place.Place.Names }}
{{ $mainPlaceName = index .place.Place.Names 0 }}
{{ end }}
{{ $fullInfo := GetFullPlaceInfo .place.Place.Geo $mainPlaceName }}
{{ if ne $fullInfo "" }}
<div class="mb-1">{{ $fullInfo }}</div>
{{ end }}
{{- /* Coordinates */ -}}
<div class="text-slate-600 text-sm space-y-1">
{{ if and (ne $geonames.Lat "") (ne $geonames.Lng "") }}
<div>
<i class="ri-map-pin-line mr-1"></i><a href="https://www.openstreetmap.org/?mlat={{ $geonames.Lat }}&mlon={{ $geonames.Lng }}&zoom=12" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-700 underline">{{ $geonames.Lat }}, {{ $geonames.Lng }}</a>
</div>
{{ end }}
</div>
</div>
{{ else }}
{{- /* Fallback when no Geonames data */ -}}
{{ if .place.Place.Geo }}
<p class="text-slate-600 mb-2 text-sm">
<i class="ri-map-pin-line mr-1"></i>
<a href="{{ .place.Place.Geo }}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:text-blue-700 underline">
Geonames
</a>
</p>
{{ end }}
{{ end }}
</div>
{{- /* External link symbols on the right */ -}}
<div class="flex gap-2 flex-shrink-0 items-center">
{{ if ne $geonames nil }}
{{- /* Wikipedia link if available */ -}}
{{ if ne $geonames.WikipediaURL "" }}
<a href="https://{{ $geonames.WikipediaURL }}" target="_blank" class="hover:opacity-80 transition-opacity" title="Wikipedia">
<img src="/assets/wikipedia.png" alt="Wikipedia" class="w-5 h-5">
</a>
{{ end }}
{{ end }}
{{- /* Geonames link */ -}}
{{ if .place.Place.Geo }}
<a href="{{ .place.Place.Geo }}" target="_blank" class="hover:opacity-80 transition-opacity no-underline" title="Geonames">
<i class="ri-global-line text-lg text-blue-600"></i>
</a>
{{ end }}
{{- /* OpenStreetMap link */ -}}
{{ if and (ne $geonames nil) (ne $geonames.Lat "") (ne $geonames.Lng "") }}
<a href="https://www.openstreetmap.org/?mlat={{ $geonames.Lat }}&mlon={{ $geonames.Lng }}&zoom=12" target="_blank" class="hover:opacity-80 transition-opacity" title="OpenStreetMap">
<i class="ri-map-2-line text-lg text-green-600"></i>
</a>
{{ end }}
</div>
</div>
</div>
{{- /* Associated Pieces */ -}}
<div>
<h3 class="text-lg font-semibold text-slate-800 mb-3">
<i class="ri-newspaper-line mr-2"></i>Verlinkte Beiträge ({{ len .place.Pieces }})
</h3>
{{ if .place.Pieces }}
{{- /* Group pieces by year */ -}}
{{- $piecesByYear := dict -}}
{{- range $_, $p := .place.Pieces -}}
{{- range $issueRef := $p.IssueRefs -}}
{{- $year := printf "%d" $issueRef.When.Year -}}
{{- $existing := index $piecesByYear $year -}}
{{- if $existing -}}
{{- $piecesByYear = merge $piecesByYear (dict $year (append $existing $p)) -}}
{{- else -}}
{{- $piecesByYear = merge $piecesByYear (dict $year (slice $p)) -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- /* Get sorted years */ -}}
{{- $sortedYears := slice -}}
{{- range $year, $pieces := $piecesByYear -}}
{{- $sortedYears = append $sortedYears $year -}}
{{- end -}}
{{- $sortedYears = sortStrings $sortedYears -}}
<div class="space-y-4 max-w-[85ch]">
{{- range $year := $sortedYears -}}
{{- $yearPieces := index $piecesByYear $year -}}
{{- /* Year Header */ -}}
<div>
<h4 class="text-base font-bold font-serif text-slate-800 mb-2">{{ $year }}</h4>
<div class="space-y-1 text-sm">
{{- /* Group pieces by title within each year */ -}}
{{- $groupedPieces := dict -}}
{{- range $_, $p := $yearPieces -}}
{{- $groupKey := "" -}}
{{- if $p.Title -}}
{{- $groupKey = index $p.Title 0 -}}
{{- else if $p.Incipit -}}
{{- $groupKey = index $p.Incipit 0 -}}
{{- else -}}
{{- $groupKey = printf "untitled-%s" $p.ID -}}
{{- end -}}
{{- $existing := index $groupedPieces $groupKey -}}
{{- if $existing -}}
{{- $groupedPieces = merge $groupedPieces (dict $groupKey (append $existing $p)) -}}
{{- else -}}
{{- $groupedPieces = merge $groupedPieces (dict $groupKey (slice $p)) -}}
{{- end -}}
{{- end -}}
{{- range $groupKey, $groupedItems := $groupedPieces -}}
<div class="pb-1 leading-relaxed">
{{- /* Use first piece for display text with colon format for places */ -}}
{{ template "_unified_piece_entry" (dict "Piece" (index $groupedItems 0) "CurrentActorID" "" "DisplayMode" "place" "ShowPlaceTags" false "UseColonFormat" true "ShowContinuation" false) }}
{{- /* Show all citations from all pieces in this group inline with commas */ -}}
{{ " " }}{{- range $groupIndex, $groupItem := $groupedItems -}}
{{- range $issueIndex, $issue := $groupItem.IssueRefs -}}
{{- /* Only show citations for the current year */ -}}
{{- if eq (printf "%d" $issue.When.Year) $year -}}
{{- if or (gt $groupIndex 0) (gt $issueIndex 0) }}, {{ end -}}
<span class="text-blue-600 hover:text-blue-700 underline decoration-dotted hover:decoration-solid [&>a]:text-blue-600 [&>a:hover]:text-blue-700">{{- template "_citation" $issue -}}</span>
{{- end -}}
{{- end -}}
{{- end -}}
{{- /* Add "Ganzer Beitrag" link if piece spans multiple issues */ -}}
{{- $firstGroupItem := index $groupedItems 0 -}}
{{- if gt (len $firstGroupItem.IssueRefs) 1 -}}
{{ " " }}<span class="inline-flex items-center gap-1 px-2 py-1 bg-blue-50
hover:bg-blue-100 text-blue-700 hover:text-blue-800 border border-blue-200
hover:border-blue-300 rounded text-xs font-medium transition-colors duration-200">
<i class="ri-file-copy-2-line text-xs"></i>
<a href="{{ GetPieceURL $firstGroupItem.ID }}">
Ganzer Beitrag
</a>
</span>
{{- end }}
</div>
{{- end -}}
</div>
</div>
{{- end -}}
</div>
{{ else }}
<p class="text-slate-500 italic text-sm">Keine verlinkten Beiträge für diesen Ort gefunden.</p>
{{ end }}
</div>

View File

@@ -0,0 +1,79 @@
{{- /*
Expandable Place Entry Template
This is used as a JavaScript template string in the ExpandablePlacesList component
Variables: place, geonames, mainPlaceName, modernName, fullInfo, pieceCount
*/ -}}
<div class="border border-slate-200 rounded-lg hover:border-slate-300 transition-colors duration-200"
data-place-name="${mainPlaceName}"
data-modern-name="${modernName}"
data-place-id="${place.ID}">
{{- /* Collapsed State - Always Visible */ -}}
<div class="p-4 cursor-pointer select-none"
data-toggle="collapse"
data-target="place-${place.ID}-content"
role="button"
tabindex="0"
aria-expanded="false"
aria-controls="place-${place.ID}-content">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center gap-3">
{{- /* Expand/Collapse Icon */ -}}
<div class="transition-transform duration-200" data-expand-icon>
<svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</div>
{{- /* Place Name as clickable link */ -}}
<div>
<a href="/ort/${place.ID}"
class="text-lg font-medium text-slate-800 hover:text-slate-900 hover:underline"
onclick="event.stopPropagation()">
${mainPlaceName}
</a>
{{- /* Geographic info if available */ -}}
\${fullInfo ? \`
<p class="text-sm text-slate-600 mt-1">
<i class="ri-map-pin-line mr-1"></i>\${fullInfo}
</p>
\` : ''}
</div>
</div>
</div>
{{- /* Piece count badge */ -}}
<div class="flex items-center gap-2 text-sm text-slate-500">
<span>\${pieceCount} Beiträge</span>
</div>
</div>
</div>
{{- /* Expandable Content Area */ -}}
<div id="place-${place.ID}-content"
class="overflow-hidden transition-all duration-300 ease-in-out max-h-0"
data-content-area
aria-hidden="true">
<div class="border-t border-slate-200 p-4 bg-slate-50">
{{- /* Loading state */ -}}
<div class="flex items-center justify-center py-8" data-loading-state>
<div class="flex items-center gap-2 text-slate-500">
<div class="animate-spin">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</div>
<span>Lade Beiträge...</span>
</div>
</div>
{{- /* Content will be loaded here via HTMX */ -}}
<div data-place-details></div>
</div>
</div>
</div>

View File

@@ -0,0 +1,2 @@
{{ template "_place_header" .place }}
{{ template "_place_pieces" .place }}

View File

@@ -0,0 +1,4 @@
{{- /* Fragment with specific class for HTMX selection */ -}}
<div class="place-fragment-content p-4 border-t border-slate-200 bg-slate-50">
{{ template "_place_pieces" .model.SelectedPlace }}
</div>

View File

@@ -0,0 +1 @@
{{- /* Empty head for fragment */ -}}

View File

@@ -1,13 +1,53 @@
{{- /* Places overview page body */ -}}
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
{{- /* Main content */ -}}
{{- /* Main content - Places list */ -}}
<div class="lg:col-span-3">
{{- /* Places grid */ -}}
{{- /* Places list */ -}}
{{ if .model.Places }}
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mt-6">
<div class="bg-white border border-slate-200 rounded-lg mt-6">
{{ range $placeID := .model.Sorted }}
{{ $place := index $.model.Places $placeID }}
{{ template "_place_card" $place }}
{{ $pieceCount := index $.model.PlacePieceCounts $placeID }}
{{ $geonames := GetGeonames $place.Geo }}
{{ $mainPlaceName := "" }}
{{ if $place.Names }}
{{ $mainPlaceName = index $place.Names 0 }}
{{ else }}
{{ $mainPlaceName = $place.ID }}
{{ end }}
{{ $modernName := GetModernPlaceName $place.Geo $mainPlaceName }}
<place-accordion class="border-b border-slate-100 last:border-b-0" data-place-id="{{ $place.ID }}" data-place-name="{{ $mainPlaceName }}" data-modern-name="{{ $modernName }}">
<div class="block p-4 hover:bg-slate-50 transition-colors duration-200 cursor-pointer">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<h3 class="font-medium text-slate-800 mb-1 truncate">
{{ if $place.Names }}
{{ index $place.Names 0 }}
{{ else }}
{{ $place.ID }}
{{ end }}
</h3>
{{ if ne $geonames nil }}
{{ $fullInfo := GetFullPlaceInfo $place.Geo $mainPlaceName }}
{{ if ne $fullInfo "" }}
<p class="text-sm text-slate-600 mb-1 truncate">
<i class="ri-map-pin-line mr-1"></i>{{ $fullInfo }}
</p>
{{ end }}
{{ else if $place.Geo }}
<p class="text-sm text-slate-600 truncate">
<i class="ri-map-pin-line mr-1"></i>Geonames verfügbar
</p>
{{ end }}
</div>
<div class="flex-shrink-0 flex items-center gap-2">
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-slate-100 text-slate-700 rounded">
{{ $pieceCount }}
</span>
</div>
</div>
</div>
</place-accordion>
{{ end }}
</div>
{{ else }}

View File

@@ -118,5 +118,158 @@ export class PlacesFilter extends HTMLElement {
}
}
// Register the custom element
customElements.define('places-filter', PlacesFilter);
/**
* Place Accordion Web Component
* Individual accordion for each place with expand/collapse functionality
*/
export class PlaceAccordion extends HTMLElement {
constructor() {
super();
this.isExpanded = false;
this.isLoading = false;
this.hasLoaded = false;
}
connectedCallback() {
this.setupAccordion();
this.setupEventListeners();
}
disconnectedCallback() {
this.cleanupEventListeners();
}
setupAccordion() {
// Add chevron icon if not already present
if (!this.querySelector('.accordion-chevron')) {
const chevron = document.createElement('i');
chevron.className = 'ri-chevron-down-line accordion-chevron transition-transform duration-200 text-slate-400';
// Find the badge and insert chevron before it
const badge = this.querySelector('[class*="bg-slate-100"]');
if (badge) {
badge.parentNode.insertBefore(chevron, badge);
}
}
// Create content container if not exists
if (!this.querySelector('[data-content]')) {
const placeId = this.getAttribute('data-place-id');
const contentContainer = document.createElement('div');
contentContainer.setAttribute('data-content', '');
contentContainer.className = 'accordion-content overflow-hidden transition-all duration-300 max-h-0';
// Add HTMX attributes to override body defaults
contentContainer.setAttribute('hx-get', `/ort/fragment/${placeId}`);
contentContainer.setAttribute('hx-trigger', 'load-content');
contentContainer.setAttribute('hx-swap', 'innerHTML');
contentContainer.setAttribute('hx-target', 'this');
contentContainer.setAttribute('hx-select', '.place-fragment-content');
contentContainer.setAttribute('hx-boost', 'false'); // Override body's hx-boost="true"
this.appendChild(contentContainer);
}
}
setupEventListeners() {
// Add click listener to the entire component
this.addEventListener('click', this.handleClick.bind(this));
}
cleanupEventListeners() {
this.removeEventListener('click', this.handleClick.bind(this));
}
handleClick(event) {
// Only handle clicks on the place name area, not on expanded content
const contentContainer = this.querySelector('[data-content]');
if (contentContainer && contentContainer.contains(event.target)) {
return; // Don't toggle if clicking inside expanded content
}
this.toggle();
}
toggle() {
if (this.isExpanded) {
this.collapse();
} else {
this.expand();
}
}
expand() {
if (this.isLoading) return;
this.isExpanded = true;
this.updateChevron();
const contentContainer = this.querySelector('[data-content]');
if (!contentContainer) return;
// Load content if not already loaded
if (!this.hasLoaded) {
this.loadContent();
} else {
// Just show existing content
contentContainer.style.maxHeight = contentContainer.scrollHeight + 'px';
}
}
collapse() {
this.isExpanded = false;
this.updateChevron();
const contentContainer = this.querySelector('[data-content]');
if (contentContainer) {
contentContainer.style.maxHeight = '0px';
}
}
loadContent() {
this.isLoading = true;
const contentContainer = this.querySelector('[data-content]');
// Show loading state
contentContainer.innerHTML = '<div class="p-4 text-center text-slate-500">Lädt...</div>';
contentContainer.style.maxHeight = contentContainer.scrollHeight + 'px';
// Set up event listeners for HTMX events
const handleAfterRequest = () => {
this.hasLoaded = true;
this.isLoading = false;
// Adjust height after content loads
setTimeout(() => {
contentContainer.style.maxHeight = contentContainer.scrollHeight + 'px';
}, 10);
contentContainer.removeEventListener('htmx:afterRequest', handleAfterRequest);
};
const handleResponseError = () => {
this.isLoading = false;
contentContainer.innerHTML = '<div class="p-4 text-center text-red-500">Fehler beim Laden</div>';
contentContainer.removeEventListener('htmx:responseError', handleResponseError);
};
contentContainer.addEventListener('htmx:afterRequest', handleAfterRequest);
contentContainer.addEventListener('htmx:responseError', handleResponseError);
// Trigger the HTMX request
htmx.trigger(contentContainer, 'load-content');
}
updateChevron() {
const chevron = this.querySelector('.accordion-chevron');
if (chevron) {
if (this.isExpanded) {
chevron.style.transform = 'rotate(180deg)';
} else {
chevron.style.transform = 'rotate(0deg)';
}
}
}
}
// Register the custom elements
customElements.define('places-filter', PlacesFilter);
customElements.define('place-accordion', PlaceAccordion);