diff --git a/CLAUDE.md b/CLAUDE.md index 659c3b1..f87bc51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ The application follows a modular Go architecture: - **Main Application**: `kgpz_web.go` - Entry point and application lifecycle management - **App Core**: `app/kgpz.go` - Core business logic and data processing -- **Controllers**: Route handlers for different content types (issues, agents, places, categories, search) +- **Controllers**: Route handlers for different content types (issues, agents, places, categories, search, quickfilters) - **View Models**: Data structures for template rendering with pre-processed business logic (`viewmodels/`) - **XML Models**: Data structures for parsing source XML files (`xmlmodels/`) - **Providers**: External service integrations (Git, GND, XML parsing, search) @@ -129,6 +129,7 @@ views/ │ │ └── _piece_summary.gohtml # Individual piece display logic │ ├── datenschutz/ # Privacy policy │ ├── edition/ # Edition pages +│ ├── filter/ # Quickfilter system │ ├── kategorie/ # Category pages │ ├── kontakt/ # Contact pages │ ├── ort/ # Places pages @@ -455,6 +456,138 @@ const pageUrl = `/${year}/${issue}/${pageNumber}`; const beilageUrl = `${window.location.pathname}#beilage-1-page-${pageNumber}`; ``` +## Quickfilter System (/filter) + +The application provides a universal quickfilter system accessible from any page via a header button, offering quick access to common navigation and filtering tools. + +### Architecture & Integration + +**Header Integration** (`views/layouts/components/_header.gohtml` & `_menu.gohtml`): +- **Universal Access**: Schnellfilter button available in every page header +- **Expandable Design**: Header expands downwards to show filter content +- **HTMX-Powered**: Dynamic loading of filter content without page refresh +- **Seamless UI**: Integrates with existing header styling and layout + +**Controller** (`controllers/filter_controller.go`): +- `GetQuickFilter(kgpz *xmlmodels.Library)` - Renders filter interface +- Uses "clear" layout for partial HTML fragments +- Dynamically extracts available years from issue data + +**Template System** (`views/routes/filter/body.gohtml`): +- Clean, responsive filter interface with modern styling +- Expandable structure for future filter options +- Integrates existing functionality (page jump) in unified interface + +### Current Features + +**Page Jump Integration**: +- **Moved from year pages**: "Direkt zu Seite springen" functionality relocated from `/jahrgang/` pages to header +- **Universal availability**: Now accessible from any page in the application +- **Same functionality**: Year dropdown, page input, error handling, HTMX validation +- **Consistent UX**: Maintains all existing behavior and error feedback + +**UI Components**: +- **Toggle Button**: Filter icon in header with hover effects and visual feedback +- **Expandable Container**: Header expands naturally to accommodate filter content +- **Responsive Design**: Mobile-friendly with proper touch interactions +- **Click-Outside Close**: Filter closes when clicking outside the container + +### Technical Implementation + +**URL Structure**: +- **Filter Endpoint**: `GET /filter` - Renders filter interface using clear layout +- **Route Configuration**: `FILTER_URL = "/filter"` defined in `app/kgpz.go` + +**JavaScript Functionality** (`views/layouts/components/_menu.gohtml`): +```javascript +// Toggle filter visibility +function toggleFilter() { + const filterContainer = document.getElementById('filter-container'); + const filterButton = document.getElementById('filter-toggle'); + + if (filterContainer.classList.contains('hidden')) { + filterContainer.classList.remove('hidden'); + filterButton.classList.add('bg-slate-200'); + } else { + filterContainer.classList.add('hidden'); + filterButton.classList.remove('bg-slate-200'); + } +} + +// Close filter when clicking outside +document.addEventListener('click', function(event) { + // Automatic close functionality +}); +``` + +**HTMX Integration**: +```html + +``` + +### Layout System + +**Header Expansion**: +- **Natural Flow**: Filter container expands header downwards using normal document flow +- **Content Displacement**: Page content moves down automatically when filter is open +- **Visual Consistency**: Uses same `bg-slate-50` background as header +- **Centered Content**: Filter content centered within expanded header area + +**Template Structure**: +```html + +
+``` + +### Extensible Design + +**Future Enhancement Ready**: +- Modular template structure allows easy addition of new filter options +- Controller can be extended to handle additional filter types +- Template includes placeholder section for "Weitere Filter" +- Architecture supports complex filtering without performance impact + +**Data Processing**: +- Efficient year extraction from issue data using same pattern as `year_view.go` +- Sorted year list generation with proper deduplication +- Ready for additional data aggregation (categories, agents, places) + +### Usage Examples + +**Template Integration**: +```gohtml + + +``` + +**Controller Extension**: +```go +// Example of extending filter data +data := fiber.Map{ + "AvailableYears": availableYears, + "Categories": categories, // Future enhancement + "TopAgents": topAgents, // Future enhancement +} +``` + +### Migration Impact + +**Improved User Experience**: +- **Reduced Page Clutter**: Removed page jump form from year overview pages +- **Universal Access**: Page jumping now available from anywhere in the application +- **Cleaner Year Pages**: `/jahrgang/` pages now focus purely on year navigation +- **Consistent Interface**: Single location for all quick navigation tools + ## Agents/Authors View System (/akteure/ and /autoren/) The application provides sophisticated person and organization browsing through dual view systems with advanced navigation and filtering capabilities. diff --git a/app/kgpz.go b/app/kgpz.go index f534328..18abd33 100644 --- a/app/kgpz.go +++ b/app/kgpz.go @@ -29,11 +29,12 @@ const ( CONTACT_URL = "/kontakt/" CITATION_URL = "/zitation/" SEARCH_URL = "/suche/" + FILTER_URL = "/filter" INDEX_URL = "/jahrgang/1764" YEAR_OVERVIEW_URL = "/jahrgang/:year" - PLACE_OVERVIEW_URL = "/ort/:place" + PLACE_OVERVIEW_URL = "/ort/:place?" AGENTS_OVERVIEW_URL = "/akteure/:letterorid" CATEGORY_OVERVIEW_URL = "/kategorie/:category" @@ -148,6 +149,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(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)) diff --git a/controllers/filter_controller.go b/controllers/filter_controller.go new file mode 100644 index 0000000..22615d2 --- /dev/null +++ b/controllers/filter_controller.go @@ -0,0 +1,129 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + "strings" + + "github.com/Theodor-Springmann-Stiftung/kgpz_web/xmlmodels" + "github.com/gofiber/fiber/v2" +) + +// GetQuickFilter handles the request to display the quick filter interface +func GetQuickFilter(kgpz *xmlmodels.Library) fiber.Handler { + return func(c *fiber.Ctx) error { + // Get all available years and issues for filters + years := make(map[int]bool) + issuesByYear := make(map[int][]IssueSummary) + + kgpz.Issues.Lock() + for _, issue := range kgpz.Issues.Array { + year := issue.Datum.When.Year + years[year] = true + + // Format date for display (DD.MM.YYYY) + dateStr := fmt.Sprintf("%02d.%02d.%d", + issue.Datum.When.Day, + issue.Datum.When.Month, + issue.Datum.When.Year) + + issueSummary := IssueSummary{ + Number: issue.Number.No, + Date: dateStr, + } + + issuesByYear[year] = append(issuesByYear[year], issueSummary) + } + kgpz.Issues.Unlock() + + // Convert map to sorted slice using the same approach as year_view.go + availableYears := slices.Collect(maps.Keys(years)) + slices.Sort(availableYears) + + // Sort issues within each year by issue number + for year := range issuesByYear { + slices.SortFunc(issuesByYear[year], func(a, b IssueSummary) int { + return a.Number - b.Number + }) + } + + // Convert issuesByYear to JSON string for the web component + issuesByYearJSON, err := json.Marshal(issuesByYear) + if err != nil { + issuesByYearJSON = []byte("{}") + } + + // Get all persons and identify authors + persons := make([]PersonSummary, 0) + authors := make([]PersonSummary, 0) + + // Find all agents who have pieces (same logic as AuthorsView) + authorIDs := make(map[string]bool) + for _, piece := range kgpz.Pieces.Array { + for _, agentRef := range piece.AgentRefs { + if agentRef.Category == "" || agentRef.Category == "autor" { + authorIDs[agentRef.Ref] = true + } + } + } + + kgpz.Agents.Lock() + for _, agent := range kgpz.Agents.Array { + // Get the primary name (first name in the list) + var name string + if len(agent.Names) > 0 { + name = agent.Names[0] + } else { + name = agent.ID // fallback to ID if no names + } + + person := PersonSummary{ + ID: agent.ID, + Name: name, + Life: agent.Life, + } + + persons = append(persons, person) + + // Add to authors list if this person is an author + if authorIDs[agent.ID] { + authors = append(authors, person) + } + } + kgpz.Agents.Unlock() + + // Sort both lists by ID + slices.SortFunc(persons, func(a, b PersonSummary) int { + return strings.Compare(a.ID, b.ID) + }) + slices.SortFunc(authors, func(a, b PersonSummary) int { + return strings.Compare(a.ID, b.ID) + }) + + // Prepare data for the filter template + data := fiber.Map{ + "AvailableYears": availableYears, + "Persons": persons, + "Authors": authors, + "IssuesByYearJSON": string(issuesByYearJSON), + } + + // Render the filter body using clear layout (no page layout) + return c.Render("/filter/", data, "clear") + } +} + +// PersonSummary represents a simplified person for the filter list +type PersonSummary struct { + ID string + Name string + Life string +} + +// IssueSummary represents an issue for the Jahr/Ausgabe filter +type IssueSummary struct { + Number int `json:"number"` + Date string `json:"date"` +} \ No newline at end of file diff --git a/controllers/ort_controller.go b/controllers/ort_controller.go index b322f29..ac3847d 100644 --- a/controllers/ort_controller.go +++ b/controllers/ort_controller.go @@ -1,12 +1,40 @@ package controllers import ( + "strings" + + "github.com/Theodor-Springmann-Stiftung/kgpz_web/helpers/logging" + "github.com/Theodor-Springmann-Stiftung/kgpz_web/viewmodels" "github.com/Theodor-Springmann-Stiftung/kgpz_web/xmlmodels" "github.com/gofiber/fiber/v2" ) +const ( + DEFAULT_PLACE = "" +) + func GetPlace(kgpz *xmlmodels.Library) fiber.Handler { return func(c *fiber.Ctx) error { - return c.Render("/ort/", nil) + 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) + } + + return c.Render("/ort/", fiber.Map{ + "model": places, + }) } } diff --git a/kgpz_web.go b/kgpz_web.go index fce51a3..1c17ba7 100644 --- a/kgpz_web.go +++ b/kgpz_web.go @@ -151,6 +151,7 @@ func Run(app *App) { func Engine(kgpz *app.KGPZ, c *providers.ConfigProvider) *templating.Engine { e := templating.NewEngine(&views.LayoutFS, &views.RoutesFS) e.AddFuncs(kgpz.Funcs()) - e.Globals(fiber.Map{"isDev": c.Config.Debug, "name": "KGPZ", "lang": "de"}) + timestamp := time.Now().Unix() + e.Globals(fiber.Map{"isDev": c.Config.Debug, "name": "KGPZ", "lang": "de", "timestamp": timestamp}) return e } diff --git a/server/server.go b/server/server.go index 5dec658..b6184cb 100644 --- a/server/server.go +++ b/server/server.go @@ -139,16 +139,11 @@ func (s *Server) Start() { srv.Use(recover.New()) + // INFO: No caching middleware in debug mode to avoid cache issues during development + // We cant do it with cach busting the files via ?v=XXX, since we also cache the templates. // TODO: Dont cache static assets, bc storage gets huge on images. // -> Maybe fiber does this already, automatically? - if s.Config.Debug { - srv.Use(cache.New(cache.Config{ - Next: CacheFunc, - Expiration: CACHE_TIME, - CacheControl: false, - Storage: s.cache, - })) - } else { + if !s.Config.Debug { srv.Use(cache.New(cache.Config{ Next: CacheFunc, Expiration: CACHE_TIME, @@ -166,7 +161,6 @@ func (s *Server) Start() { } s.runner(srv) - } func (s *Server) Stop() { @@ -232,5 +226,4 @@ func (s *Server) runner(srv *fiber.App) { } } }() - } diff --git a/templating/engine.go b/templating/engine.go index ba8fff4..de04c85 100644 --- a/templating/engine.go +++ b/templating/engine.go @@ -19,13 +19,7 @@ import ( const ( ASSETS_URL_PREFIX = "/assets" - CLEAR_LAYOUT = ` - - - {{ block "head" . }}{{ end }} - - {{ block "body" . }}{{ end }} -` + CLEAR_LAYOUT = `{{ block "head" . }}{{ end }}{{ block "body" . }}{{ end }}` ) type Engine struct { diff --git a/viewmodels/place_view.go b/viewmodels/place_view.go new file mode 100644 index 0000000..11486ce --- /dev/null +++ b/viewmodels/place_view.go @@ -0,0 +1,95 @@ +package viewmodels + +import ( + "maps" + "slices" + "strings" + + "github.com/Theodor-Springmann-Stiftung/kgpz_web/xmlmodels" +) + +// PlacesListView represents the data for the places overview +type PlacesListView struct { + Search string + AvailableLetters []string + Places map[string]xmlmodels.Place + Sorted []string + SelectedPlace *PlaceDetailView +} + +// PlaceDetailView represents a specific place with its associated pieces +type PlaceDetailView struct { + Place xmlmodels.Place + Pieces []xmlmodels.Piece +} + +// 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)} + av := make(map[string]bool) + + // Get all places that are referenced in pieces + referencedPlaces := make(map[string]bool) + for _, piece := range lib.Pieces.Array { + for _, placeRef := range piece.PlaceRefs { + referencedPlaces[placeRef.Ref] = true + } + } + + // Build available letters and places list + for _, place := range lib.Places.Array { + // Only include places that are actually referenced in pieces + if referencedPlaces[place.ID] { + av[strings.ToUpper(place.ID[:1])] = true + res.Sorted = append(res.Sorted, place.ID) + res.Places[place.ID] = place + } + } + + // If a specific place is requested, get its details + if placeID != "" && len(placeID) > 1 { + if place, exists := res.Places[placeID]; exists { + res.SelectedPlace = GetPlaceDetail(place, lib) + } + } + + res.AvailableLetters = slices.Collect(maps.Keys(av)) + slices.Sort(res.AvailableLetters) + slices.Sort(res.Sorted) + + return &res +} + +// GetPlaceDetail returns detailed information for a specific place including associated pieces +func GetPlaceDetail(place xmlmodels.Place, lib *xmlmodels.Library) *PlaceDetailView { + detail := &PlaceDetailView{ + Place: place, + Pieces: make([]xmlmodels.Piece, 0), + } + + // Find all pieces that reference this place + for _, piece := range lib.Pieces.Array { + for _, placeRef := range piece.PlaceRefs { + if placeRef.Ref == place.ID { + detail.Pieces = append(detail.Pieces, piece) + break // Don't add the same piece multiple times + } + } + } + + // Sort pieces by title for consistent display + slices.SortFunc(detail.Pieces, func(a, b xmlmodels.Piece) int { + // Get first title from each piece, or use empty string if no titles + titleA := "" + if len(a.Title) > 0 { + titleA = a.Title[0] + } + titleB := "" + if len(b.Title) > 0 { + titleB = b.Title[0] + } + return strings.Compare(strings.ToLower(titleA), strings.ToLower(titleB)) + }) + + return detail +} \ No newline at end of file diff --git a/views/assets/scripts.js b/views/assets/scripts.js index 4b465ac..03be4f8 100644 --- a/views/assets/scripts.js +++ b/views/assets/scripts.js @@ -1,9 +1,198 @@ -class H extends HTMLElement { +const x = document.getElementById("filter-toggle"); +x && x.addEventListener("click", P); +function P() { + const o = document.getElementById("filter-container"), e = document.getElementById("filter-toggle"), t = o == null ? void 0 : o.querySelector("div.flex.justify-center"); + o.classList.contains("hidden") ? (o.classList.remove("hidden"), e.classList.add("bg-slate-200"), t && t.querySelector("div, form, h3") || htmx.ajax("GET", "/filter", { + target: "#filter-container", + select: "#filter", + swap: "innerHTML" + }).then(() => { + console.log("HTMX request completed"), document.querySelector("#filter-container .flex.justify-center"); + }).catch((n) => { + console.log("HTMX request failed:", n); + })) : (o.classList.add("hidden"), e.classList.remove("bg-slate-200")); +} +window.toggleFilter = P; +document.addEventListener("click", function(o) { + const e = document.getElementById("filter-container"), t = document.getElementById("filter-toggle"); + e && t && !e.contains(o.target) && !t.contains(o.target) && (e.classList.contains("hidden") || (e.classList.add("hidden"), t.classList.remove("bg-slate-200"))); +}); +document.body.addEventListener("htmx:configRequest", function(o) { + let e = o.detail.elt; + e.id === "search" && e.value === "" && (o.detail.parameters = {}, o.detail.path = window.location.pathname + window.location.search); +}); +class A extends HTMLElement { + constructor() { + super(); + } + connectedCallback() { + this.setupEventListeners(); + } + setupEventListeners() { + const e = this.querySelector("#person-search"), t = this.querySelector("#authors-only"), i = this.querySelector("#all-persons"), n = this.querySelector("#authors-only-list"); + !e || !t || !i || !n || (e.addEventListener("input", (s) => { + const r = s.target.value.toLowerCase().trim(); + this.filterPersons(r); + }), t.addEventListener("change", () => { + this.togglePersonsList(); + const s = e.value.toLowerCase().trim(); + this.filterPersons(s); + })); + } + togglePersonsList() { + const e = this.querySelector("#authors-only"), t = this.querySelector("#all-persons"), i = this.querySelector("#authors-only-list"); + !e || !t || !i || (e.checked ? (t.style.display = "none", i.style.display = "block") : (t.style.display = "block", i.style.display = "none")); + } + filterPersons(e) { + const t = this.querySelector("#authors-only"), i = t != null && t.checked ? this.querySelector("#authors-only-list") : this.querySelector("#all-persons"); + if (!i) + return; + i.querySelectorAll(".person-item").forEach((s) => { + var c, d; + const r = ((c = s.querySelector(".person-name")) == null ? void 0 : c.textContent) || "", a = ((d = s.querySelector(".person-life")) == null ? void 0 : d.textContent) || ""; + !e || r.toLowerCase().includes(e) || a.toLowerCase().includes(e) ? s.style.display = "block" : s.style.display = "none"; + }); + } +} +customElements.define("person-jump-filter", A); +class N extends HTMLElement { + constructor() { + super(), this.issuesByYear = {}; + } + connectedCallback() { + this.parseIssuesData(), this.setupEventListeners(); + } + parseIssuesData() { + const e = this.dataset.issues; + if (e) + try { + this.issuesByYear = JSON.parse(e); + } catch (t) { + console.error("Failed to parse issues data:", t); + } + } + setupEventListeners() { + const e = this.querySelector("#year-select"), t = this.querySelector("#issue-number-select"), i = this.querySelector("#issue-date-select"), n = this.querySelector("#page-input"), s = this.querySelector("#page-jump-btn"); + if (!e) + return; + e.addEventListener("change", () => { + this.updateIssueOptions(), this.updatePageInputState(), this.clearPageErrors(); + }), t && t.addEventListener("change", () => { + const a = e.value, l = t.value; + a && l && (window.location.href = `/${a}/${l}`); + }), i && i.addEventListener("change", () => { + const a = e.value, l = i.value; + a && l && (window.location.href = `/${a}/${l}`); + }), n && (n.addEventListener("input", () => { + this.updatePageJumpButton(), this.clearPageErrors(); + }), n.addEventListener("keydown", (a) => { + a.key === "Enter" && (a.preventDefault(), this.handlePageJump()); + })), s && s.addEventListener("click", () => { + this.handlePageJump(); + }); + const r = this.querySelector("#page-jump-form"); + r && r.addEventListener("submit", (a) => { + a.preventDefault(), this.handlePageJump(); + }), this.updateIssueOptions(), this.updatePageInputState(), this.updatePageJumpButton(); + } + updateIssueOptions() { + const e = this.querySelector("#year-select"), t = this.querySelector("#issue-number-select"), i = this.querySelector("#issue-date-select"); + if (!e || !t || !i) + return; + const n = e.value, s = this.issuesByYear[n] || []; + t.innerHTML = '', i.innerHTML = '', s.forEach((a) => { + const l = document.createElement("option"); + l.value = a.number, l.textContent = a.number, t.appendChild(l); + const c = document.createElement("option"); + c.value = a.number, c.textContent = `${a.date} [${a.number}]`, i.appendChild(c); + }); + const r = s.length > 0 && n; + t.disabled = !r, i.disabled = !r; + } + async handlePageJump() { + const e = this.querySelector("#year-select"), t = this.querySelector("#page-input"), i = this.querySelector("#jump-errors"); + if (!e || !t) + return; + const n = e.value, s = t.value; + if (!n || !s) { + this.showError("Bitte Jahr und Seite auswählen."); + return; + } + try { + const r = new FormData(); + r.append("year", n), r.append("page", s); + const a = await fetch("/jump", { + method: "POST", + body: r, + redirect: "manual" + }), l = a.headers.get("HX-Redirect"); + if (l) { + window.location.href = l; + return; + } + if (a.status === 302 || a.status === 301) { + const c = a.headers.get("Location"); + if (c) { + window.location.href = c; + return; + } + } + if (a.ok) + i && (i.innerHTML = ""); + else { + const c = await a.text(); + i && (i.innerHTML = c); + } + } catch (r) { + console.error("Page jump failed:", r), this.showError("Fehler beim Suchen der Seite."); + } + } + showError(e) { + const t = this.querySelector("#jump-errors"); + t && (t.innerHTML = `