Some serious layout changes

This commit is contained in:
Simon Martens
2025-09-27 23:30:37 +02:00
parent 31b4be14ae
commit c92d25752c
24 changed files with 1832 additions and 1796 deletions

1269
CLAUDE.md

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@ const (
YEAR_OVERVIEW_URL = "/jahrgang/:year"
PLACE_OVERVIEW_URL = "/ort/:place?"
AGENTS_OVERVIEW_URL = "/akteure/:letterorid"
CATEGORY_OVERVIEW_URL = "/kategorie/:category"
CATEGORY_OVERVIEW_URL = "/kategorie/:category?/:year?"
PIECE_URL = "/beitrag/:id"
PIECE_PAGE_URL = "/beitrag/:id/:page"

View File

@@ -140,12 +140,80 @@ func GetQuickFilter(kgpz *xmlmodels.Library) fiber.Handler {
return strings.Compare(a.ID, b.ID)
})
// Get all categories with their first available year
categories := make([]CategorySummary, 0)
categoryYears := make(map[string][]int) // categoryID -> list of years
// First pass: collect all years for each category
for _, piece := range kgpz.Pieces.Array {
// Get years for this piece
pieceYears := make(map[int]bool)
for _, issueRef := range piece.IssueRefs {
if issueRef.When.Year > 0 {
pieceYears[issueRef.When.Year] = true
}
}
// Process CategoryRefs
for _, catRef := range piece.CategoryRefs {
if catRef.Ref != "" {
for year := range pieceYears {
categoryYears[catRef.Ref] = append(categoryYears[catRef.Ref], year)
}
}
}
// Process WorkRefs with categories (rezension)
for _, workRef := range piece.WorkRefs {
categoryID := workRef.Category
if categoryID == "" {
categoryID = "rezension" // Default category for WorkRefs
}
for year := range pieceYears {
categoryYears[categoryID] = append(categoryYears[categoryID], year)
}
}
}
// Build categories list with first year for each
kgpz.Categories.Lock()
for _, category := range kgpz.Categories.Array {
if yearsList, exists := categoryYears[category.ID]; exists && len(yearsList) > 0 {
// Find the earliest year for this category
slices.Sort(yearsList)
firstYear := yearsList[0]
// Get the primary name (first name in the list)
var name string
if len(category.Names) > 0 {
name = category.Names[0]
} else {
name = category.ID // fallback to ID if no names
}
categorySummary := CategorySummary{
ID: category.ID,
Name: name,
FirstYear: firstYear,
}
categories = append(categories, categorySummary)
}
}
kgpz.Categories.Unlock()
// Sort categories list by name
slices.SortFunc(categories, func(a, b CategorySummary) int {
return strings.Compare(a.Name, b.Name)
})
// Prepare data for the filter template
data := fiber.Map{
"AvailableYears": availableYears,
"Persons": persons,
"Authors": authors,
"Places": places,
"Categories": categories,
"IssuesByYearJSON": string(issuesByYearJSON),
}
@@ -168,6 +236,13 @@ type PlaceSummary struct {
Geo string
}
// CategorySummary represents a simplified category for the filter list
type CategorySummary struct {
ID string
Name string
FirstYear int
}
// IssueSummary represents an issue for the Jahr/Ausgabe filter
type IssueSummary struct {
Number int `json:"number"`

View File

@@ -1,12 +1,111 @@
package controllers
import (
"slices"
"strconv"
"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_CATEGORY = ""
)
func GetCategory(kgpz *xmlmodels.Library) fiber.Handler {
return func(c *fiber.Ctx) error {
return c.Render("/kategorie/", nil)
categoryID := c.Params("category", DEFAULT_CATEGORY)
categoryID = strings.ToLower(categoryID)
yearParam := c.Params("year", "")
// Case 1: No category specified - show categories list with years
if categoryID == "" {
categories := viewmodels.CategoriesView(kgpz)
if len(categories.Categories) == 0 {
logging.Error(nil, "No categories found")
return c.SendStatus(fiber.StatusNotFound)
}
return c.Render("/kategorie/list/", fiber.Map{
"model": categories,
})
}
// Case 2: Both category and year specified - show pieces
if yearParam != "" {
year, err := strconv.Atoi(yearParam)
if err != nil {
logging.Error(err, "Invalid year parameter: "+yearParam)
return c.SendStatus(fiber.StatusBadRequest)
}
categoryPieces := viewmodels.NewCategoryPiecesView(categoryID, year, kgpz)
if categoryPieces == nil {
logging.Error(nil, "Category not found: "+categoryID)
return c.SendStatus(fiber.StatusNotFound)
}
return c.Render("/kategorie/pieces/", fiber.Map{
"model": categoryPieces,
})
}
// Case 3: Category specified but no year - find first available year and redirect
firstYear := findFirstYearForCategory(categoryID, kgpz)
if firstYear == 0 {
// Category exists but has no pieces, redirect to category list
logging.Error(nil, "Category has no pieces: "+categoryID)
return c.Redirect("/kategorie/")
}
// Redirect to category with first available year
return c.Redirect("/kategorie/" + categoryID + "/" + strconv.Itoa(firstYear))
}
}
// findFirstYearForCategory finds the earliest year that has pieces for the given category
func findFirstYearForCategory(categoryID string, kgpz *xmlmodels.Library) int {
categoryYears := make([]int, 0)
for _, piece := range kgpz.Pieces.Array {
matchesCategory := false
// Check direct CategoryRefs
for _, catRef := range piece.CategoryRefs {
if catRef.Ref == categoryID {
matchesCategory = true
break
}
}
// Check WorkRefs with categories if not found in direct refs
if !matchesCategory {
for _, workRef := range piece.WorkRefs {
if workRef.Category == categoryID || (workRef.Category == "" && categoryID == "rezension") {
matchesCategory = true
break
}
}
}
if matchesCategory {
// Extract years from IssueRefs
for _, issueRef := range piece.IssueRefs {
year := issueRef.When.Year
if year > 0 {
categoryYears = append(categoryYears, year)
}
}
}
}
if len(categoryYears) == 0 {
return 0 // No pieces found for this category
}
// Find the earliest year
slices.Sort(categoryYears)
return categoryYears[0]
}

291
viewmodels/category_view.go Normal file
View File

@@ -0,0 +1,291 @@
package viewmodels
import (
"slices"
"github.com/Theodor-Springmann-Stiftung/kgpz_web/xmlmodels"
)
// CategoriesListView represents the data for the categories overview page
type CategoriesListView struct {
Categories map[string]CategoryWithPieceCount
Sorted []string
}
// CategoryDetailView represents a specific category with its available years
type CategoryDetailView struct {
Category xmlmodels.Category
CategoryReadable map[string]interface{}
AvailableYears []YearWithPieceCount
PieceCount int
}
// CategoryPiecesView represents pieces filtered by category and year
type CategoryPiecesView struct {
Category xmlmodels.Category
CategoryReadable map[string]interface{}
Year int
Pieces []xmlmodels.Piece
PieceCount int
AvailableYears []YearWithPieceCount
}
// CategoryWithPieceCount represents a category with total piece count and available years
type CategoryWithPieceCount struct {
Category xmlmodels.Category
Readable map[string]interface{}
PieceCount int
AvailableYears []YearWithPieceCount
}
// YearWithPieceCount represents a year with piece count for a specific category
type YearWithPieceCount struct {
Year int
PieceCount int
}
// CategoriesView returns categories data for the overview page
func CategoriesView(lib *xmlmodels.Library) *CategoriesListView {
res := CategoriesListView{Categories: make(map[string]CategoryWithPieceCount)}
// Count pieces per category and year
categoryPieceCounts := make(map[string]int)
categoryYearCounts := make(map[string]map[int]int) // categoryID -> year -> count
for _, piece := range lib.Pieces.Array {
// Get years for this piece
pieceYears := make(map[int]bool)
for _, issueRef := range piece.IssueRefs {
if issueRef.When.Year > 0 {
pieceYears[issueRef.When.Year] = true
}
}
// Process CategoryRefs
for _, catRef := range piece.CategoryRefs {
if catRef.Ref != "" {
categoryPieceCounts[catRef.Ref]++
if categoryYearCounts[catRef.Ref] == nil {
categoryYearCounts[catRef.Ref] = make(map[int]int)
}
for year := range pieceYears {
categoryYearCounts[catRef.Ref][year]++
}
}
}
// Process WorkRefs with categories
for _, workRef := range piece.WorkRefs {
categoryID := workRef.Category
if categoryID == "" {
categoryID = "rezension" // Default category for WorkRefs
}
categoryPieceCounts[categoryID]++
if categoryYearCounts[categoryID] == nil {
categoryYearCounts[categoryID] = make(map[int]int)
}
for year := range pieceYears {
categoryYearCounts[categoryID][year]++
}
}
}
// Build categories list with piece counts and available years
for _, category := range lib.Categories.Array {
if categoryPieceCounts[category.ID] > 0 {
// Merge readable and readable HTML data
readable := category.Readable(lib)
readableHTML := category.ReadableHTML()
for k, v := range readableHTML {
readable[k] = v
}
// Build available years list for this category
var availableYears []YearWithPieceCount
if yearCounts, exists := categoryYearCounts[category.ID]; exists {
years := make([]int, 0, len(yearCounts))
for year := range yearCounts {
years = append(years, year)
}
slices.Sort(years)
for _, year := range years {
availableYears = append(availableYears, YearWithPieceCount{
Year: year,
PieceCount: yearCounts[year],
})
}
}
res.Categories[category.ID] = CategoryWithPieceCount{
Category: category,
Readable: readable,
PieceCount: categoryPieceCounts[category.ID],
AvailableYears: availableYears,
}
res.Sorted = append(res.Sorted, category.ID)
}
}
// Sort by category ID
slices.Sort(res.Sorted)
return &res
}
// CategoryDetailView returns category data with available years
func CategoryView(categoryID string, lib *xmlmodels.Library) *CategoryDetailView {
// Get the category
category := lib.Categories.Item(categoryID)
if category == nil {
return nil
}
// Count pieces per year for this category
yearPieceCounts := make(map[int]int)
totalPieces := 0
for _, piece := range lib.Pieces.Array {
matchesCategory := false
// Check direct CategoryRefs
for _, catRef := range piece.CategoryRefs {
if catRef.Ref == categoryID {
matchesCategory = true
break
}
}
// Check WorkRefs with categories if not found in direct refs
if !matchesCategory {
for _, workRef := range piece.WorkRefs {
if workRef.Category == categoryID || (workRef.Category == "" && categoryID == "rezension") {
matchesCategory = true
break
}
}
}
if matchesCategory {
totalPieces++
// Extract years from IssueRefs
for _, issueRef := range piece.IssueRefs {
year := issueRef.When.Year
if year > 0 {
yearPieceCounts[year]++
}
}
}
}
// Build available years list
var availableYears []YearWithPieceCount
years := make([]int, 0, len(yearPieceCounts))
for year := range yearPieceCounts {
years = append(years, year)
}
slices.Sort(years)
for _, year := range years {
availableYears = append(availableYears, YearWithPieceCount{
Year: year,
PieceCount: yearPieceCounts[year],
})
}
// Merge readable and readable HTML data
readable := category.Readable(lib)
readableHTML := category.ReadableHTML()
for k, v := range readableHTML {
readable[k] = v
}
return &CategoryDetailView{
Category: *category,
CategoryReadable: readable,
AvailableYears: availableYears,
PieceCount: totalPieces,
}
}
// NewCategoryPiecesView returns pieces filtered by category and year
func NewCategoryPiecesView(categoryID string, year int, lib *xmlmodels.Library) *CategoryPiecesView {
// Get the category
category := lib.Categories.Item(categoryID)
if category == nil {
return nil
}
var pieces []xmlmodels.Piece
yearPieceCounts := make(map[int]int)
for _, piece := range lib.Pieces.Array {
matchesCategory := false
// Check direct CategoryRefs
for _, catRef := range piece.CategoryRefs {
if catRef.Ref == categoryID {
matchesCategory = true
break
}
}
// Check WorkRefs with categories if not found in direct refs
if !matchesCategory {
for _, workRef := range piece.WorkRefs {
if workRef.Category == categoryID || (workRef.Category == "" && categoryID == "rezension") {
matchesCategory = true
break
}
}
}
if matchesCategory {
// Count all years for this category (for navigation)
for _, issueRef := range piece.IssueRefs {
if issueRef.When.Year > 0 {
yearPieceCounts[issueRef.When.Year]++
}
}
// Check if piece appears in the specified year
for _, issueRef := range piece.IssueRefs {
if issueRef.When.Year == year {
pieces = append(pieces, piece)
break
}
}
}
}
// Build available years list
var availableYears []YearWithPieceCount
years := make([]int, 0, len(yearPieceCounts))
for yearVal := range yearPieceCounts {
years = append(years, yearVal)
}
slices.Sort(years)
for _, yearVal := range years {
availableYears = append(availableYears, YearWithPieceCount{
Year: yearVal,
PieceCount: yearPieceCounts[yearVal],
})
}
// Merge readable and readable HTML data
readable := category.Readable(lib)
readableHTML := category.ReadableHTML()
for k, v := range readableHTML {
readable[k] = v
}
return &CategoryPiecesView{
Category: *category,
CategoryReadable: readable,
Year: year,
Pieces: pieces,
PieceCount: len(pieces),
AvailableYears: availableYears,
}
}

View File

@@ -15,6 +15,7 @@ type PlacesListView struct {
Places map[string]xmlmodels.Place
Sorted []string
SelectedPlace *PlaceDetailView
TotalPiecesWithPlaces int
}
// PlaceDetailView represents a specific place with its associated pieces
@@ -28,11 +29,18 @@ 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
// Get all places that are referenced in pieces and count total pieces with places
referencedPlaces := make(map[string]bool)
totalPiecesWithPlaces := 0
for _, piece := range lib.Pieces.Array {
hasPlace := false
for _, placeRef := range piece.PlaceRefs {
referencedPlaces[placeRef.Ref] = true
hasPlace = true
}
if hasPlace {
totalPiecesWithPlaces++
}
}
@@ -56,6 +64,7 @@ func PlacesView(placeID string, lib *xmlmodels.Library) *PlacesListView {
res.AvailableLetters = slices.Collect(maps.Keys(av))
slices.Sort(res.AvailableLetters)
slices.Sort(res.Sorted)
res.TotalPiecesWithPlaces = totalPiecesWithPlaces
return &res
}

View File

@@ -2,7 +2,7 @@ document.body.addEventListener("htmx:configRequest", function(a) {
let e = a.detail.elt;
e.id === "search" && e.value === "" && (a.detail.parameters = {}, a.detail.path = window.location.pathname + window.location.search);
});
class H extends HTMLElement {
class A extends HTMLElement {
constructor() {
super();
}
@@ -35,34 +35,36 @@ class H extends HTMLElement {
});
}
}
customElements.define("person-jump-filter", H);
class B extends HTMLElement {
constructor() {
super();
}
customElements.define("person-jump-filter", A);
class H extends HTMLElement {
connectedCallback() {
this.setupEventListeners();
}
setupEventListeners() {
const e = this.querySelector("#place-search"), t = this.querySelector("#all-places");
!e || !t || e.addEventListener("input", (i) => {
const n = i.target.value.toLowerCase().trim();
this.filterPlaces(n);
});
}
filterPlaces(e) {
const t = this.querySelector("#all-places");
if (!t)
return;
t.querySelectorAll(".place-item").forEach((n) => {
var r;
const s = ((r = n.querySelector(".place-name")) == null ? void 0 : r.textContent) || "";
!e || s.toLowerCase().includes(e) ? n.style.display = "block" : n.style.display = "none";
const e = this.querySelector("#place-search");
e && e.addEventListener("input", (t) => {
const i = t.target.value.toLowerCase().trim();
this.querySelectorAll(".place-item").forEach((s) => {
var l;
const o = ((l = s.querySelector(".place-name")) == null ? void 0 : l.textContent) || "", r = !i || o.toLowerCase().includes(i);
s.style.display = r ? "block" : "none";
});
});
}
}
customElements.define("place-jump-filter", B);
class A extends HTMLElement {
customElements.define("place-jump-filter", H);
class B extends HTMLElement {
connectedCallback() {
const e = this.querySelector("#category-search");
e && e.addEventListener("input", (t) => {
const i = t.target.value.toLowerCase().trim();
this.querySelectorAll(".category-item").forEach((s) => {
var l;
const o = ((l = s.querySelector(".category-name")) == null ? void 0 : l.textContent) || "", r = !i || o.toLowerCase().includes(i);
s.style.display = r ? "block" : "none";
});
});
}
}
customElements.define("category-jump-filter", B);
class M extends HTMLElement {
constructor() {
super(), this.issuesByYear = {};
}
@@ -177,8 +179,8 @@ class A extends HTMLElement {
i.disabled = !o;
}
}
customElements.define("year-jump-filter", A);
class M extends HTMLElement {
customElements.define("year-jump-filter", M);
class N extends HTMLElement {
constructor() {
super(), this.isOpen = !1;
}
@@ -240,8 +242,8 @@ class M extends HTMLElement {
this.isOpen && t && i && !t.contains(e.target) && !this.contains(e.target) && this.hideFilter();
}
}
customElements.define("schnellauswahl-button", M);
class N extends HTMLElement {
customElements.define("schnellauswahl-button", N);
class $ extends HTMLElement {
constructor() {
super(), this.isOpen = !1;
}
@@ -264,7 +266,7 @@ class N extends HTMLElement {
<div>Übersicht nach</div>
<div class="ml-2 flex flex-col gap-y-2 mt-2">
<a href="/">Jahrgängen</a>
<a href="/akteure/a">Personen</a>
<a href="/akteure/a">Personen &amp; Werke</a>
<a href="/kategorie/">Betragsarten</a>
<a href="/ort/">Orten</a>
</div>
@@ -317,7 +319,7 @@ class N extends HTMLElement {
this.isOpen && !this.contains(e.target) && this.hideMenu();
}
}
customElements.define("navigation-menu", N);
customElements.define("navigation-menu", $);
document.addEventListener("DOMContentLoaded", function() {
document.addEventListener("click", function(a) {
const e = a.target.closest('a[href^="/akteure/"], a[href^="/ort/"]'), t = document.getElementById("filter-container");
@@ -490,9 +492,9 @@ class O extends HTMLElement {
document.documentElement.offsetHeight
), s = window.innerHeight, o = n - s, r = o > 0 ? window.scrollY / o : 0, l = t.clientHeight, d = t.scrollHeight - l;
if (d > 0) {
const u = r * d, h = i.getBoundingClientRect(), p = t.getBoundingClientRect(), f = h.top - p.top + t.scrollTop, m = l / 2, I = f - m, v = 0.7, T = v * u + (1 - v) * I, y = Math.max(0, Math.min(d, T)), q = t.scrollTop;
Math.abs(y - q) > 10 && t.scrollTo({
top: y,
const u = r * d, h = i.getBoundingClientRect(), p = t.getBoundingClientRect(), f = h.top - p.top + t.scrollTop, m = l / 2, T = f - m, y = 0.7, I = y * u + (1 - y) * T, v = Math.max(0, Math.min(d, I)), q = t.scrollTop;
Math.abs(v - q) > 10 && t.scrollTo({
top: v,
behavior: "smooth"
});
}
@@ -512,7 +514,7 @@ class O extends HTMLElement {
}
}
customElements.define("akteure-scrollspy", O);
class $ extends HTMLElement {
class V extends HTMLElement {
constructor() {
super(), this.searchInput = null, this.placeCards = [], this.countElement = null, this.debounceTimer = null, this.originalCount = 0;
}
@@ -564,8 +566,82 @@ class $ 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", $);
class V extends HTMLElement {
customElements.define("places-filter", V);
class R extends HTMLElement {
constructor() {
super(), this.searchInput = null, this.itemCards = [], this.countElement = null, this.debounceTimer = null, this.originalCount = 0;
}
connectedCallback() {
this.placeholderText = this.getAttribute("placeholder") || "Suchen...", this.itemSelector = this.getAttribute("item-selector") || "[data-filter-item]", this.searchAttributes = (this.getAttribute("search-attributes") || "data-filter-text").split(","), this.countSelector = this.getAttribute("count-selector") || "[data-filter-count]", this.itemType = this.getAttribute("item-type") || "Einträge", this.itemTypeSingular = this.getAttribute("item-type-singular") || "Eintrag", this.render(), this.setupEventListeners(), this.initializeItems();
}
disconnectedCallback() {
this.cleanupEventListeners(), this.debounceTimer && clearTimeout(this.debounceTimer);
}
render() {
this.innerHTML = `
<div class="mb-6">
<input
type="text"
id="generic-search"
placeholder="${this.placeholderText}"
autocomplete="off"
class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400"
>
</div>
`;
}
setupEventListeners() {
this.searchInput = this.querySelector("#generic-search"), this.searchInput && this.searchInput.addEventListener("input", this.handleSearchInput.bind(this));
}
cleanupEventListeners() {
this.searchInput && this.searchInput.removeEventListener("input", this.handleSearchInput.bind(this));
}
initializeItems() {
this.itemCards = Array.from(document.querySelectorAll(this.itemSelector));
const e = this.closest(".filter-sidebar") || this.closest(".sidebar") || document;
this.countElement = e.querySelector(this.countSelector), console.log("GenericFilter initialized:", {
itemSelector: this.itemSelector,
itemsFound: this.itemCards.length,
countElement: this.countElement,
searchAttributes: this.searchAttributes
}), this.countElement && (this.originalCount = this.itemCards.length);
}
handleSearchInput(e) {
this.debounceTimer && clearTimeout(this.debounceTimer), this.debounceTimer = setTimeout(() => {
this.filterItems(e.target.value.trim());
}, 150);
}
filterItems(e) {
if (!this.itemCards.length) return;
const t = e.toLowerCase();
let i = 0;
this.itemCards.forEach((n) => {
var o;
let s = e === "";
if (!s) {
for (const r of this.searchAttributes)
if ((((o = n.getAttribute(r.trim())) == null ? void 0 : o.toLowerCase()) || "").includes(t)) {
s = !0;
break;
}
}
s ? (n.style.display = "", i++) : n.style.display = "none";
}), this.updateCountDisplay(i, e);
}
updateCountDisplay(e, t) {
if (this.countElement)
if (t === "")
this.countElement.textContent = `Alle ${this.itemType} (${this.originalCount})`;
else if (e === 0)
this.countElement.textContent = `Keine ${this.itemType} gefunden für "${t}"`;
else {
const i = e === 1 ? this.itemTypeSingular : this.itemType;
this.countElement.textContent = `${e} von ${this.originalCount} ${i}`;
}
}
}
customElements.define("generic-filter", R);
class z extends HTMLElement {
constructor() {
super(), this.resizeObserver = null;
}
@@ -918,7 +994,7 @@ class V extends HTMLElement {
return "KGPZ";
}
}
customElements.define("single-page-viewer", V);
customElements.define("single-page-viewer", z);
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());
@@ -927,7 +1003,7 @@ window.addEventListener("beforeunload", function() {
const a = document.querySelector("single-page-viewer");
a && a.close();
});
class R extends HTMLElement {
class D extends HTMLElement {
constructor() {
super(), this.isVisible = !1, this.scrollHandler = null, this.htmxAfterSwapHandler = null;
}
@@ -968,8 +1044,8 @@ class R extends HTMLElement {
});
}
}
customElements.define("scroll-to-top-button", R);
class z extends HTMLElement {
customElements.define("scroll-to-top-button", D);
class j extends HTMLElement {
constructor() {
super(), this.pageObserver = null, this.pageContainers = /* @__PURE__ */ new Map(), this.singlePageViewerActive = !1, this.singlePageViewerCurrentPage = null, this.boundHandleSinglePageViewer = this.handleSinglePageViewer.bind(this);
}
@@ -1088,8 +1164,8 @@ class z 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", z);
class j extends HTMLElement {
customElements.define("inhaltsverzeichnis-scrollspy", j);
class F 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">
@@ -1137,11 +1213,11 @@ class j extends HTMLElement {
window.showErrorModal = (e) => this.show(e), window.closeErrorModal = () => this.close();
}
}
customElements.define("error-modal", j);
customElements.define("error-modal", F);
window.currentPageContainers = window.currentPageContainers || [];
window.currentActiveIndex = window.currentActiveIndex || 0;
window.pageObserver = window.pageObserver || null;
function D(a, e, t, i = null) {
function K(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");
@@ -1159,7 +1235,7 @@ function D(a, e, t, i = null) {
function E() {
document.getElementById("pageModal").classList.add("hidden");
}
function F() {
function W() {
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(
@@ -1180,7 +1256,7 @@ function F() {
});
}
}
function K() {
function Z() {
if (window.currentActiveIndex > 0) {
let a = -1;
const e = [];
@@ -1201,7 +1277,7 @@ function K() {
}, 100));
}
}
function Z() {
function J() {
if (window.currentActiveIndex < window.currentPageContainers.length - 1) {
let a = -1;
const e = [];
@@ -1222,7 +1298,7 @@ function Z() {
}, 100));
}
}
function W() {
function G() {
if (C()) {
const e = document.querySelector("#newspaper-content .newspaper-page-container");
e && e.scrollIntoView({
@@ -1262,7 +1338,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 J() {
function Y() {
const a = document.getElementById("shareLinkBtn");
let e = "";
if (window.currentActiveIndex !== void 0 && window.currentPageContainers && window.currentPageContainers[window.currentActiveIndex]) {
@@ -1297,7 +1373,7 @@ function x(a, e) {
}
}
}
function Y() {
function U() {
const a = document.getElementById("citationBtn"), e = document.title || "KGPZ";
let t = window.location.origin + window.location.pathname;
t.includes("#") && (t = t.split("#")[0]);
@@ -1350,7 +1426,7 @@ function g(a, e) {
}, 200);
}, 2e3);
}
function G(a, e, t = !1) {
function X(a, e, t = !1) {
let i = "";
if (t)
i = window.location.origin + window.location.pathname + `#beilage-1-page-${a}`;
@@ -1382,7 +1458,7 @@ function G(a, e, t = !1) {
}
}
}
function U(a, e) {
function Q(a, e) {
const t = document.title || "KGPZ", i = window.location.pathname.split("/");
let n;
if (i.length >= 3) {
@@ -1411,7 +1487,7 @@ function U(a, e) {
}
}
function L() {
F(), window.addEventListener("scroll", function() {
W(), window.addEventListener("scroll", function() {
clearTimeout(window.scrollTimeout), window.scrollTimeout = setTimeout(() => {
b();
}, 50);
@@ -1448,21 +1524,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 = D;
window.enlargePage = K;
window.closeModal = E;
window.scrollToPreviousPage = K;
window.scrollToNextPage = Z;
window.scrollToBeilage = W;
window.shareCurrentPage = J;
window.generateCitation = Y;
window.copyPagePermalink = G;
window.generatePageCitation = U;
window.scrollToPreviousPage = Z;
window.scrollToNextPage = J;
window.scrollToBeilage = G;
window.shareCurrentPage = Y;
window.generateCitation = U;
window.copyPagePermalink = X;
window.generatePageCitation = Q;
k();
P();
document.querySelector(".newspaper-page-container") && L();
let X = function(a) {
let _ = function(a) {
k(), P(), S(), setTimeout(() => {
document.querySelector(".newspaper-page-container") && L();
}, 50);
};
document.body.addEventListener("htmx:afterSettle", X);
document.body.addEventListener("htmx:afterSettle", _);

File diff suppressed because one or more lines are too long

View File

@@ -1,14 +1,13 @@
<div class="max-w-7xl mx-auto px-8 py-8">
<div class="mb-10 2xl:mb-0">
<div class="max-w-none mx-auto px-8">
<div class="mb-10 2xl:mb-0 sticky top-0 ">
{{ template "_header_with_toggle" .model }}
</div>
<!-- Two Column Layout: Scrollspy + Content -->
<div class="flex gap-8">
{{ template "_scrollspy_layout" .model }}
<!-- People List - Main Content -->
<div class="flex-1 space-y-6 bg-white">
<div class="flex-1 space-y-6 bg-white mt-4">
{{ range $_, $id := $.model.Sorted }}
{{ $a := index $.model.Agents $id }}
<div id="author-{{ $id }}" class="p-6 scroll-mt-8 author-section">
@@ -17,4 +16,4 @@
{{ end }}
</div>
</div>
</div>
</div>

View File

@@ -1,23 +1,40 @@
<div class="max-w-7xl mx-auto px-8 py-8">
<div class="mb-10 2xl:mb-0">
{{ template "_header_with_toggle" .model }}
<!-- Alphabet Navigation -->
{{ template "_alphabet_nav" .model }}
</div>
<!-- Two Column Layout: Scrollspy + Content -->
<div class="max-w-none mx-auto px-8">
<!-- Two Column Layout: Header+Scrollspy + Content -->
<div class="flex gap-8">
<!-- Left Column: Header + Scrollspy -->
{{ template "_scrollspy_layout" .model }}
<!-- Mobile Header (shown on smaller screens) -->
<div class="2xl:hidden mb-10 w-full">
{{ template "_header_with_toggle" .model }}
</div>
<!-- People List - Main Content -->
<div class="flex-1 space-y-6 bg-white">
<div class="flex-1 space-y-6 flex flex-col">
<!-- Alphabet Navigation -->
<div class="mb-6 w-full pt-4">
<div class="bg-white px-8 py-4 rounded">
<div class="mx-auto flex flex-row flex-wrap gap-x-6 gap-y-3 w-fit items-end leading-none justify-center">
{{ range $_, $l := .model.AvailableLetters }}
{{ if eq $l (Upper $.model.Search) }}
<!-- This is the active letter -->
<span class="no-underline leading-none !m-0 !p-0 text-3xl font-bold text-red-600 pointer-events-none" aria-current="true">{{ $l }}</span>
{{ else }}
<!-- This is an inactive letter -->
<a href="/akteure/{{ $l }}" class="no-underline leading-none !m-0 !p-0 text-xl font-medium text-gray-700 hover:text-red-600 transition-colors">{{ $l }}</a>
{{ end }}
{{ end }}
</div>
</div>
</div>
<dv class="flex-1 space-y-6 bg-white">
{{ range $_, $id := $.model.Sorted }}
{{ $a := index $.model.Agents $id }}
<div id="author-{{ $id }}" class="p-6 scroll-mt-8 author-section">
{{ template "_akteur" $a }}
</div>
{{ end }}
</div>
</div>
</div>
</div>
</div>

View File

@@ -4,9 +4,9 @@
{{- $sorted := .Sorted -}}
<div class="hidden 2xl:block w-96 flex-shrink-0">
<div class="sticky top-0 max-h-screen bg-white rounded py-4 flex flex-col">
<div class="sticky top-0 max-h-screen flex flex-col">
<!-- Compact header for 2xl+ screens -->
<div class="px-3 pb-4 border-b border-gray-200 mb-4">
<div class="px-3 pb-4 border-b border-gray-200 bg-slate-50 rounded-t py-8">
{{ if eq .Search "autoren" }}
<h2 class="text-2xl font-bold font-serif text-gray-900 mb-1">Autor:innen</h2>
<p class="text-base text-gray-600 mb-3">Personen, die Beiträge in der Zeitung verfasst haben</p>
@@ -47,7 +47,7 @@
</div>
</div>
<nav class="flex-1 overflow-y-auto overscroll-contain relative" id="scrollspy-nav">
<nav class="flex-1 py-4 overflow-y-auto overscroll-contain relative bg-white rounded-b" id="scrollspy-nav">
<!-- Sliding red background element -->
<div id="scrollspy-slider" class="absolute bg-red-100 rounded-sm transition-all duration-300 ease-out opacity-0 z-0" style="width: calc(100% - 1.5rem); height: 0; top: 0; left: 0.75rem;"></div>

View File

@@ -126,7 +126,7 @@
{{- $placeObj := GetPlace $placeRef.Ref -}}
{{- if gt (len $placeObj.Names) 0 -}}
{{- $placeName := index $placeObj.Names 0 -}}
{{- $placeTag = printf "%s <a href=\"/ort/%s\"><span class=\"place-tag inline-block bg-slate-200 text-slate-700 text-xs px-2 py-0.5 rounded-md whitespace-nowrap\">%s</span></a>" $placeTag $placeObj.ID $placeName -}}
{{- $placeTag = printf "%s <a href=\"/ort/%s\" class=\"ml-0\"><span class=\"place-tag inline-block bg-slate-200 text-slate-700 text-xs px-2 py-0.5 rounded-md whitespace-nowrap hover:bg-slate-300 hover:text-slate-800 transition-colors duration-150\">%s</span></a>" $placeTag $placeObj.ID $placeName -}}
{{- end -}}
{{- end -}}
{{- end -}}
@@ -134,7 +134,7 @@
{{- /* Author prefix for colon format (place view) */ -}}
{{- $colonPrefix := "" -}}
{{- if $useColonFormat -}}
{{- $colonPrefix = ": " -}}
{{- $colonPrefix = ":" -}}
{{- else -}}
{{- $colonPrefix = " mit " -}}
{{- end -}}
@@ -561,7 +561,7 @@
{{- end -}}
{{- if gt (len $authorElements) 0 -}}
{{- if $title }}: <em>{{ $title }}</em>{{ end }}{{ if $workTitle }}
{{ if $title }}, {{ end }}{{ if $categoryFlags.Uebersetzung }}Übersetzung aus:{{ else }}Auszug aus:{{ end }} {{ if $workAuthorName }}
{{ if and $title (not $categoryFlags.Uebersetzung) }}, {{ end }}{{ if $categoryFlags.Uebersetzung }}Übersetzung aus:{{ else }}Auszug aus:{{ end }} {{ if $workAuthorName }}
<a href="/akteure/{{ $workAuthorID }}" class="author-link text-slate-700 hover:text-slate-900 underline decoration-slate-400 hover:decoration-slate-600">{{ $workAuthorName }}</a>, {{ end }}<em
class="work-title"
data-short-title="{{ $workTitle }}"
@@ -570,7 +570,7 @@
{{ end }}
{{- else -}}
{{- if $title }}<em>{{ $title }}</em>{{ end }}{{ if $workTitle }}
{{ if $title }}, {{ end }}{{ if $categoryFlags.Uebersetzung }}Übersetzung aus:{{ else }}Auszug aus:{{ end }} {{ if $workAuthorName }}
{{ if and $title (not $categoryFlags.Uebersetzung) }}, {{ end }}{{ if $categoryFlags.Uebersetzung }}Übersetzung aus:{{ else }}Auszug aus:{{ end }} {{ if $workAuthorName }}
<a href="/akteure/{{ $workAuthorID }}" class="author-link text-slate-700 hover:text-slate-900 underline decoration-slate-400 hover:decoration-slate-600">{{ $workAuthorName }}</a>, {{ end }}<em
class="work-title"
data-short-title="{{ $workTitle }}"
@@ -580,7 +580,7 @@
{{- end -}}{{ Safe $placeTag }}
{{- else -}}
{{ Safe $fortsPrefix }}{{ if $title }}<em>{{ $title }}</em>{{ end }}{{ if $workTitle }}
{{ if $title }}, {{ end }}{{ if $categoryFlags.Uebersetzung }}Übersetzung aus:{{ else }}Auszug aus:{{ end }} {{ if $workAuthorName }}
{{ if and $title (not $categoryFlags.Uebersetzung) }}, {{ end }}{{ if $categoryFlags.Uebersetzung }}Übersetzung aus:{{ else }}Auszug aus:{{ end }} {{ if $workAuthorName }}
<a href="/akteure/{{ $workAuthorID }}" class="author-link text-slate-700 hover:text-slate-900 underline decoration-slate-400 hover:decoration-slate-600">{{ $workAuthorName }}</a>, {{ end }}<em
class="work-title"
data-short-title="{{ $workTitle }}"
@@ -590,7 +590,7 @@
{{- end -}}
{{- else -}}
{{ Safe $fortsPrefix }}{{ if $title }}<em>{{ $title }}</em>{{ end }}{{ if $workTitle }}
{{ if $title }}, {{ end }}{{ if $categoryFlags.Uebersetzung }}Übersetzung aus:{{ else }}Auszug aus:{{ end }} {{ if $workAuthorName }}
{{ if and $title (not $categoryFlags.Uebersetzung) }}, {{ end }}{{ if $categoryFlags.Uebersetzung }}Übersetzung aus:{{ else }}Auszug aus:{{ end }} {{ if $workAuthorName }}
<a href="/akteure/{{ $workAuthorID }}" class="author-link text-slate-700 hover:text-slate-900 underline decoration-slate-400 hover:decoration-slate-600">{{ $workAuthorName }}</a>, {{ end }}<em
class="work-title"
data-short-title="{{ $workTitle }}"

View File

@@ -1,8 +1,8 @@
<div class="flex flex-row justify-center gap-4 h-96" id="filter">
<div class="p-4 w-full max-w-md flex flex-col h-full">
<div class="p-4 w-full max-w-sm flex flex-col h-full">
<h3 class="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
<i class="ri-calendar-line text-slate-600"></i>
Auswahl nach Datum, Nummer od. Seite
Nach Datum, Nummer oder Seite
</h3>
<!-- Unified Year Jump Filter -->
@@ -54,11 +54,11 @@
</year-jump-filter>
</div>
<div class="p-4 w-full max-w-md flex flex-col h-full">
<div class="p-4 w-full max-w-sm flex flex-col h-full">
<h3 class="text-lg font-semibold text-slate-800 mb-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="ri-user-line text-slate-600"></i>
Auswahl nach Person
Nach Person
</div>
<div class="flex items-center gap-1">
<i class="ri-arrow-right-line"></i>
@@ -123,11 +123,11 @@
</person-jump-filter>
</div>
<div class="p-4 w-full max-w-md flex flex-col h-full">
<div class="p-4 w-full max-w-sm flex flex-col h-full">
<h3 class="text-lg font-semibold text-slate-800 mb-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="ri-map-pin-line text-slate-600"></i>
Auswahl nach Ort
Nach Ort
</div>
<div class="flex items-center gap-1">
<i class="ri-arrow-right-line"></i>
@@ -169,5 +169,49 @@
</div>
</place-jump-filter>
</div>
<div class="p-4 w-full max-w-sm flex flex-col h-full">
<h3 class="text-lg font-semibold text-slate-800 mb-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="ri-price-tag-3-line text-slate-600"></i>
Nach Kategorie
</div>
<div class="flex items-center gap-1">
<i class="ri-arrow-right-line"></i>
<a href="/kategorie/" class="text-sm text-slate-600 hover:underline">Kategorien</a>
</div>
</h3>
<!-- Category Jump Filter -->
<category-jump-filter class="flex-1 flex flex-col min-h-0">
<div class="space-y-3 flex flex-col h-full">
<div class="flex items-center gap-2">
<label for="category-search" class="hidden text-sm text-slate-600 w-16">Filter</label>
<input
type="text"
id="category-search"
placeholder="Kategorie eingeben..."
autocomplete="off"
class="flex-1 px-2 py-1 border border-slate-300 rounded text-sm bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400"
>
</div>
<div class="flex-1 min-h-0 overflow-y-auto border border-slate-300 rounded bg-white text-sm">
<!-- All Categories List -->
<div id="all-categories">
{{ range $category := .Categories }}
<div class="category-item odd:bg-slate-50 even:bg-white">
<a href="/kategorie/{{ $category.ID }}" class="block px-2 py-1 hover:bg-blue-50 border-b border-slate-100 last:border-b-0">
<span class="category-name font-medium text-slate-800">{{ $category.Name }}</span>
<span class="text-xs text-slate-400 ml-2">ab {{ $category.FirstYear }}</span>
</a>
</div>
{{ end }}
</div>
</div>
</div>
</category-jump-filter>
</div>
</div>

View File

@@ -0,0 +1,101 @@
{{- /* Categories list page body */ -}}
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
{{- /* Main content */ -}}
<div class="lg:col-span-3">
{{- /* Categories grid */ -}}
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mt-6">
{{- range $categoryID := .model.Sorted -}}
{{- $categoryData := index $.model.Categories $categoryID -}}
{{- $category := $categoryData.Category -}}
{{- $readable := $categoryData.Readable -}}
<div class="bg-white rounded p-4 hover:bg-slate-50 transition-colors duration-200"
data-filter-item
data-category-name="{{ if $category.Names }}{{ index $category.Names 0 }}{{ else }}{{ $category.ID }}{{ end }}"
data-category-description="{{ if $readable.Annotations }}{{ $readable.Annotations | html }}{{ end }}{{ if $readable.Notes }} {{ $readable.Notes | html }}{{ end }}">
<div class="mb-3">
<h2 class="text-lg font-medium mb-1 text-slate-900">
{{ if $category.Names }}{{ index $category.Names 0 }}{{ else }}{{ $category.ID }}{{ end }}
</h2>
<p class="text-sm text-slate-500">
{{ $categoryData.PieceCount }} {{ if eq $categoryData.PieceCount 1 }}Beitrag{{ else }}Beiträge{{ end }}
</p>
</div>
{{- /* Show category annotations and notes */ -}}
{{- if $readable.AnnotationsHTML -}}
<div class="text-sm text-slate-700 mb-2">
{{- range $annotation := $readable.AnnotationsHTML -}}
{{ $annotation }}
{{- end -}}
</div>
{{- end -}}
{{- if $readable.NotesHTML -}}
<div class="text-sm text-slate-600 italic">
{{- range $note := $readable.NotesHTML -}}
{{ $note }}
{{- end -}}
</div>
{{- end -}}
{{- /* Available years */ -}}
{{- if $categoryData.AvailableYears -}}
<div class="mt-3 pt-2 border-t border-slate-100">
<div class="flex flex-wrap gap-1">
{{- range $yearData := $categoryData.AvailableYears -}}
<a href="/kategorie/{{ $category.ID }}/{{ $yearData.Year }}"
class="inline-block px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200 hover:text-blue-800 transition-colors duration-150"
title="{{ $yearData.PieceCount }} {{ if eq $yearData.PieceCount 1 }}Beitrag{{ else }}Beiträge{{ end }}">
{{ $yearData.Year }}
</a>
{{- end -}}
</div>
</div>
{{- end -}}
</div>
{{- end -}}
</div>
</div>
{{- /* Sidebar */ -}}
<div class="lg:col-span-1 sticky top-0 self-start">
<div class="bg-slate-50 p-6 filter-sidebar">
<h1 class="text-2xl font-bold text-slate-800 mb-4">Kategorien</h1>
<p class="text-slate-600 mb-6">
Verzeichnis aller Kategorien von Beiträgen in der Königsberger Gelehrten und Politischen Zeitung
</p>
{{- /* Search Filter */ -}}
<div class="mb-4">
<generic-filter
placeholder="Kategorien durchsuchen..."
item-selector="[data-filter-item]"
search-attributes="data-category-name,data-category-description"
count-selector="[data-filter-count]"
item-type="Kategorien"
item-type-singular="Kategorie">
</generic-filter>
</div>
<div class="text-sm text-slate-700 mb-4" data-filter-count>
{{ len .model.Categories }} Kategorien
</div>
</div>
{{- /* Statistics */ -}}
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4 mt-4">
<div class="text-sm text-slate-700">
<p class="mb-2">
<span class="font-medium">{{ len .model.Categories }}</span> Kategorien verfügbar
</p>
{{- $totalPieces := 0 -}}
{{- range $categoryData := .model.Categories -}}
{{- $totalPieces = add $totalPieces $categoryData.PieceCount -}}
{{- end -}}
<p>
<span class="font-medium">{{ $totalPieces }}</span> Beiträge insgesamt
</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,3 @@
{{- /* Head template for categories list page */ -}}
<title>Kategorien — KGPZ Digital</title>
<meta name="description" content="Übersicht aller Kategorien von Beiträgen in der Königsberger Gelehrten und Politischen Zeitung.">

View File

@@ -0,0 +1,153 @@
{{- /* Category pieces page body - matches single place view layout */ -}}
<!-- Single Category/Year Detail View -->
<div class="max-w-7xl mx-auto px-8 py-8">
{{- /* Year Navigation with Back Button - Outside main content box */ -}}
{{- if .model.AvailableYears -}}
<div class="mb-6 w-full">
<div class="bg-white px-8 py-6 rounded">
<div class="flex items-center justify-between gap-8">
{{- /* Back Navigation */ -}}
<div class="flex-shrink-0">
<a href="/kategorie/" class="inline-flex items-center hover:text-black text-gray-600 transition-colors text-xl no-underline font-bold">
<i class="ri-arrow-left-line mr-1 text-xl font-bold"></i>
Kategorien
</a>
</div>
{{- /* Year Navigation */ -}}
<div class="flex flex-row flex-wrap gap-x-6 gap-y-3 items-end leading-none justify-center flex-1">
{{- range $yearData := .model.AvailableYears -}}
{{- if eq $yearData.Year $.model.Year -}}
<!-- This is the active year -->
<span class="no-underline leading-none !m-0 !p-0 text-3xl font-bold text-red-600 pointer-events-none" aria-current="true">{{ $yearData.Year }}</span>
{{- else -}}
<!-- This is an inactive year -->
<a href="/kategorie/{{ $.model.Category.ID }}/{{ $yearData.Year }}" class="no-underline leading-none !m-0 !p-0 text-xl font-medium text-gray-700 hover:text-red-600 transition-colors" title="{{ $yearData.PieceCount }} {{ if eq $yearData.PieceCount 1 }}Beitrag{{ else }}Beiträge{{ end }}">{{ $yearData.Year }}</a>
{{- end -}}
{{- end -}}
</div>
</div>
</div>
</div>
{{- else -}}
{{- /* Fallback: Just back navigation if no years available */ -}}
<div class="mb-6">
<div class="bg-white px-8 py-6 rounded">
<a href="/kategorie/" class="inline-flex items-center hover:text-black text-gray-600 transition-colors text-xl no-underline font-bold">
<i class="ri-arrow-left-line mr-1 text-xl font-bold"></i>
Kategorien
</a>
</div>
</div>
{{- end -}}
<div class="bg-white px-6 py-6 rounded w-full lg:min-w-[800px] xl:min-w-[900px]">
{{- /* Category Header */ -}}
<div class="mb-8">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<h1 class="text-3xl font-bold text-slate-800 mb-2">
{{ index .model.Category.Names 0 }}
</h1>
{{- /* Category description */ -}}
{{- if .model.CategoryReadable.AnnotationsHTML -}}
<div class="text-lg text-slate-700 mb-2">
{{- range $annotation := .model.CategoryReadable.AnnotationsHTML -}}
<div>{{ $annotation }}</div>
{{- end -}}
</div>
{{- end -}}
<p class="text-slate-600">
<span class="font-medium">{{ .model.PieceCount }}</span>
{{ if eq .model.PieceCount 1 }}Beitrag{{ else }}Beiträge{{ end }}
in der Kategorie „{{ index .model.Category.Names 0 }}" aus dem Jahr {{ .model.Year }}
</p>
</div>
</div>
</div>
{{- /* Pieces List */ -}}
<div>
<h2 class="text-xl font-semibold text-slate-800 mb-4">
<i class="ri-newspaper-line mr-2"></i><u class="decoration underline-offset-3">Beiträge</u> ({{ .model.PieceCount }})
</h2>
{{- if .model.Pieces -}}
<div class="space-y-6 max-w-[85ch]">
<div>
<h3 class="text-lg font-bold font-serif text-slate-800 mb-3">{{ .model.Year }}</h3>
<div class="space-y-1">
{{- /* Group pieces by title within the year */ -}}
{{- $groupedPieces := dict -}}
{{- range $_, $p := .model.Pieces -}}
{{- $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>
<div class="pb-1 text-lg indent-4">
{{- /* 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 $issue.When.Year $.model.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 -}}
{{ " " }}<div 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 }}" class="">
Ganzer Beitrag
</a>
</div>
{{- end }}
</div>
</div>
{{- end -}}
</div>
</div>
</div>
{{- else -}}
<div class="bg-slate-50 rounded-lg p-8 text-center">
<div class="text-slate-500 mb-2">
<svg class="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<h3 class="text-lg font-medium text-slate-900 mb-2">Keine Beiträge gefunden</h3>
<p class="text-slate-600">
Für die Kategorie „{{ index .model.Category.Names 0 }}" wurden im Jahr {{ .model.Year }} keine Beiträge gefunden.
</p>
</div>
{{- end -}}
</div>
</div>

View File

@@ -0,0 +1,3 @@
{{- /* Head template for category pieces page */ -}}
<title>Kategorie „{{ index .model.Category.Names 0 }}" {{ .model.Year }} — KGPZ Digital</title>
<meta name="description" content="Beiträge der Kategorie &#8222;{{ index .model.Category.Names 0 | html }}&#8220; aus dem Jahr {{ .model.Year }} in der Königsberger Gelehrten und Politischen Zeitung.">

View File

@@ -7,7 +7,7 @@
{{ $mainPlaceName = .ID }}
{{ end }}
{{ $modernName := GetModernPlaceName .Geo $mainPlaceName }}
<div class="border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors h-20" data-place-name="{{ $mainPlaceName }}" data-modern-name="{{ $modernName }}">
<div class="bg-white rounded hover:bg-slate-50 transition-colors duration-200 h-20" data-place-name="{{ $mainPlaceName }}" data-modern-name="{{ $modernName }}">
<a href="/ort/{{ .ID }}" class="block p-4 h-full flex flex-col justify-between">
<div class="flex items-start justify-between gap-2">
<div class="flex-1">

View File

@@ -1,27 +1,73 @@
<!-- Places Overview -->
<div class="max-w-7xl mx-auto px-8 py-8">
<div class="bg-white px-6 py-6 rounded w-full">
<h1 class="text-3xl font-bold text-slate-800 mb-8">Orte</h1>
<!-- Places List -->
{{- /* Places overview page body */ -}}
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
{{- /* Main content */ -}}
<div class="lg:col-span-3">
{{- /* Places grid */ -}}
{{ if .model.Places }}
<div>
<!-- Search Filter -->
<places-filter></places-filter>
<h2 class="text-lg font-semibold text-slate-700 mb-4" data-places-count>
Alle Orte ({{ len .model.Places }})
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 lg:min-w-[768px] xl:min-w-[1024px]">
{{ range $placeID := .model.Sorted }}
{{ $place := index $.model.Places $placeID }}
{{ template "_place_card" $place }}
{{ end }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mt-6">
{{ range $placeID := .model.Sorted }}
{{ $place := index $.model.Places $placeID }}
{{ template "_place_card" $place }}
{{ end }}
</div>
{{ else }}
<p class="text-slate-500 italic">Keine Orte gefunden.</p>
<div class="bg-slate-50 rounded-lg p-8 text-center mt-6">
<div class="text-slate-500 mb-2">
<svg class="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</div>
<h3 class="text-lg font-medium text-slate-900 mb-2">Keine Orte gefunden</h3>
<p class="text-slate-600">Es wurden keine Orte in der Datenbank gefunden.</p>
</div>
{{ end }}
</div>
{{- /* Sidebar */ -}}
<div class="lg:col-span-1 sticky top-0 self-start">
<div class="bg-slate-50 p-6 filter-sidebar">
<h1 class="text-2xl font-bold text-slate-800 mb-4">Orte</h1>
<p class="text-slate-600 mb-6">
Verzeichnis aller in der Zeitung erwähnten Orte und Lokalitäten
</p>
{{- /* Search Filter */ -}}
<div class="mb-4">
<generic-filter
placeholder="Ortsnamen eingeben..."
item-selector="[data-place-name]"
search-attributes="data-place-name,data-modern-name"
count-selector="[data-filter-count]"
item-type="Orte"
item-type-singular="Ort">
</generic-filter>
</div>
<div class="text-sm text-slate-700 mb-4" data-filter-count>
Alle Orte ({{ len .model.Places }})
</div>
</div>
{{- /* Statistics */ -}}
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4 mt-4">
<div class="text-sm text-slate-700 space-y-2">
<div class="flex justify-between">
<span>Orte gesamt:</span>
<span class="font-medium">{{ len .model.Places }}</span>
</div>
<div class="flex justify-between">
<span>Beiträge mit Ort:</span>
<span class="font-medium">{{ .model.TotalPiecesWithPlaces }}</span>
</div>
{{- if .model.SelectedPlace -}}
<div class="flex justify-between">
<span>Beiträge hier:</span>
<span class="font-medium">{{ len .model.SelectedPlace.Pieces }}</span>
</div>
{{- end -}}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,147 @@
/**
* Generic Filter Web Component
* Provides search functionality for filtering cards/items in lists
* Can be configured for different types of content (places, categories, etc.)
*/
export class GenericFilter extends HTMLElement {
constructor() {
super();
this.searchInput = null;
this.itemCards = [];
this.countElement = null;
this.debounceTimer = null;
this.originalCount = 0;
}
connectedCallback() {
// Configuration attributes
this.placeholderText = this.getAttribute('placeholder') || 'Suchen...';
this.itemSelector = this.getAttribute('item-selector') || '[data-filter-item]';
this.searchAttributes = (this.getAttribute('search-attributes') || 'data-filter-text').split(',');
this.countSelector = this.getAttribute('count-selector') || '[data-filter-count]';
this.itemType = this.getAttribute('item-type') || 'Einträge';
this.itemTypeSingular = this.getAttribute('item-type-singular') || 'Eintrag';
this.render();
this.setupEventListeners();
this.initializeItems();
}
disconnectedCallback() {
this.cleanupEventListeners();
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
}
render() {
this.innerHTML = `
<div class="mb-6">
<input
type="text"
id="generic-search"
placeholder="${this.placeholderText}"
autocomplete="off"
class="w-full px-3 py-2 border border-slate-300 rounded-md text-sm bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400"
>
</div>
`;
}
setupEventListeners() {
this.searchInput = this.querySelector('#generic-search');
if (this.searchInput) {
this.searchInput.addEventListener('input', this.handleSearchInput.bind(this));
}
}
cleanupEventListeners() {
if (this.searchInput) {
this.searchInput.removeEventListener('input', this.handleSearchInput.bind(this));
}
}
initializeItems() {
// Find all items and the count element using semantic containers
this.itemCards = Array.from(document.querySelectorAll(this.itemSelector));
// Count element should be in the same semantic container as the filter
const filterContainer = this.closest('.filter-sidebar') || this.closest('.sidebar') || document;
this.countElement = filterContainer.querySelector(this.countSelector);
console.log('GenericFilter initialized:', {
itemSelector: this.itemSelector,
itemsFound: this.itemCards.length,
countElement: this.countElement,
searchAttributes: this.searchAttributes
});
if (this.countElement) {
this.originalCount = this.itemCards.length;
}
}
handleSearchInput(event) {
// Clear previous debounce timer
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
// Debounce the search to avoid excessive filtering
this.debounceTimer = setTimeout(() => {
this.filterItems(event.target.value.trim());
}, 150);
}
filterItems(searchTerm) {
if (!this.itemCards.length) return;
const normalizedSearch = searchTerm.toLowerCase();
let visibleCount = 0;
this.itemCards.forEach(card => {
let isMatch = searchTerm === '';
// Check all configured search attributes
if (!isMatch) {
for (const attr of this.searchAttributes) {
const attrValue = card.getAttribute(attr.trim())?.toLowerCase() || '';
if (attrValue.includes(normalizedSearch)) {
isMatch = true;
break;
}
}
}
if (isMatch) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
// Update the count display
this.updateCountDisplay(visibleCount, searchTerm);
}
updateCountDisplay(visibleCount, searchTerm) {
if (!this.countElement) return;
if (searchTerm === '') {
// Show original count when no search
this.countElement.textContent = `Alle ${this.itemType} (${this.originalCount})`;
} else if (visibleCount === 0) {
// Show no results message
this.countElement.textContent = `Keine ${this.itemType} gefunden für "${searchTerm}"`;
} else {
// Show filtered count
const itemTypeText = visibleCount === 1 ? this.itemTypeSingular : this.itemType;
this.countElement.textContent = `${visibleCount} von ${this.originalCount} ${itemTypeText}`;
}
}
}
// Register the custom element
customElements.define('generic-filter', GenericFilter);

View File

@@ -2,6 +2,7 @@ import "./site.css";
import "./search.js";
import "./akteure.js";
import "./places.js";
import "./generic-filter.js";
import { SinglePageViewer } from "./single-page-viewer.js";
import { ScrollToTopButton } from "./scroll-to-top.js";
import { InhaltsverzeichnisScrollspy } from "./inhaltsverzeichnis-scrollspy.js";

File diff suppressed because it is too large Load Diff