mirror of
https://github.com/Theodor-Springmann-Stiftung/kgpz_web.git
synced 2025-10-28 16:45:32 +00:00
Start places overhaul
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
74
views/routes/ort/components/README.md
Normal file
74
views/routes/ort/components/README.md
Normal 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
|
||||
179
views/routes/ort/components/_place_details_fragment.gohtml
Normal file
179
views/routes/ort/components/_place_details_fragment.gohtml
Normal 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>
|
||||
79
views/routes/ort/components/_place_expandable.gohtml
Normal file
79
views/routes/ort/components/_place_expandable.gohtml
Normal 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>
|
||||
2
views/routes/ort/details/body.gohtml
Normal file
2
views/routes/ort/details/body.gohtml
Normal file
@@ -0,0 +1,2 @@
|
||||
{{ template "_place_header" .place }}
|
||||
{{ template "_place_pieces" .place }}
|
||||
4
views/routes/ort/fragment/body.gohtml
Normal file
4
views/routes/ort/fragment/body.gohtml
Normal 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>
|
||||
1
views/routes/ort/fragment/head.gohtml
Normal file
1
views/routes/ort/fragment/head.gohtml
Normal file
@@ -0,0 +1 @@
|
||||
{{- /* Empty head for fragment */ -}}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user