Schellfilter; bugfixes; Tagewerk; Anfang Ort Controller

This commit is contained in:
Simon Martens
2025-09-24 18:00:33 +02:00
parent 6ddded953b
commit 209da5b509
28 changed files with 2019 additions and 514 deletions

135
CLAUDE.md
View File

@@ -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
<button id="filter-toggle"
hx-get="/filter"
hx-target="#filter-container > div"
hx-swap="innerHTML"
onclick="toggleFilter()">
<i class="ri-filter-2-line"></i> Schnellfilter
</button>
```
### 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
<!-- Header container expands naturally -->
<div id="filter-container" class="mt-6 hidden">
<div class="flex justify-center">
<!-- Filter content loaded here via HTMX -->
</div>
</div>
```
### 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
<!-- Filter automatically available in all pages via header -->
<!-- No additional template includes needed -->
```
**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.

View File

@@ -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))

View File

@@ -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"`
}

View File

@@ -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,
})
}
}

View File

@@ -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
}

View File

@@ -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) {
}
}
}()
}

View File

@@ -19,13 +19,7 @@ import (
const (
ASSETS_URL_PREFIX = "/assets"
CLEAR_LAYOUT = `
<html>
<head>
{{ block "head" . }}{{ end }}
</head>
{{ block "body" . }}{{ end }}
</html>`
CLEAR_LAYOUT = `{{ block "head" . }}{{ end }}{{ block "body" . }}{{ end }}`
)
type Engine struct {

95
viewmodels/place_view.go Normal file
View File

@@ -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
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,28 @@
<meta charset="UTF-8" />
{{ block "head" . }}
<!-- Default Head elements -->
{{ end }}
{{ if .isDev }}
<link rel="icon" href="/assets/logo/dev_favicon.png" />
{{ else }}
<link rel="icon" href="/assets/logo/favicon.png" />
{{ end }}
<link href="/assets/css/remixicon.css" rel="stylesheet" />
<script src="/assets/scripts.js" type="module"></script>
<script src="/assets/js/htmx.min.js" defer></script>
<script src="/assets/js/htmx-response-targets.js" defer></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof htmx !== 'undefined') {
htmx.config.scrollBehavior = 'instant';
}
});
</script>
<script src="/assets/js/alpine.min.js" defer></script>
<link rel="stylesheet" type="text/css" href="/assets/css/fonts.css?v={{ .timestamp }}" />
<link rel="stylesheet" type="text/css" href="/assets/style.css?v={{ .timestamp }}" />

View File

@@ -5,8 +5,12 @@
</a>
</header>
<nav>
<nav class="relative">
{{ block "_menu" . }}
{{ end }}
</nav>
<!-- Filter Expansion Container -->
<div id="filter-container" class="mt-6 hidden">
</div>
</div>

View File

@@ -1,4 +1,14 @@
<div class="flex flex-row justify-center mt-12 mb-8">
<!-- Schnellfilter Button -->
<div>
<button
id="filter-toggle"
class="mr-2 text-lg border px-4 h-full hover:bg-slate-200 transition-colors cursor-pointer"
title="Schnellfilter öffnen/schließen">
<i class="ri-filter-2-line"></i> <div class="inline-block text-lg">Schnellauswahl</div>
</button>
</div>
<div class="w-6/12">
<input
type="search"
@@ -14,7 +24,7 @@
<div x-data="{ open: false }">
<button
class="ml-2 text-2xl border px-4 h-full"
class="ml-2 text-2xl border px-4 h-full hover:bg-slate-200 transition-colors cursor-pointer"
:class="open? 'open bg-slate-200' : 'closed'"
@click="open = !open">
<i class="ri-menu-line"></i>
@@ -38,14 +48,3 @@
</div>
</div>
<script>
document.body.addEventListener("htmx:configRequest", function (event) {
console.log("Before request event triggered");
let t = event.detail.elt; // Get the element triggering the request
if (t.id === "search" && t.value === "") {
event.detail.parameters = {};
event.detail.path = window.location.pathname + window.location.search;
}
});
</script>

View File

@@ -1,63 +1,31 @@
<!doctype html>
<html class="w-full h-full" {{ if .lang }}lang="{{ .lang }}"{{ end }}>
<head>
<meta charset="UTF-8" />
{{ block "head" . }}
<!-- Default Head elements -->
{{ end }}
{{ if .isDev }}
<link rel="icon" href="/assets/logo/dev_favicon.png" />
{{ else }}
<link rel="icon" href="/assets/logo/favicon.png" />
{{ end }}
<link href="/assets/css/remixicon.css" rel="stylesheet" />
<script>
// Configure HTMX scroll behavior
document.addEventListener('DOMContentLoaded', function() {
if (typeof htmx !== 'undefined') {
htmx.config.scrollBehavior = 'instant';
}
});
// Fallback configuration
window.addEventListener('load', function() {
if (typeof htmx !== 'undefined') {
htmx.config.scrollBehavior = 'instant';
}
});
</script>
<script src="/assets/js/alpine.min.js" defer></script>
<script src="/assets/js/htmx.min.js" defer></script>
<script src="/assets/js/htmx-response-targets.js" defer></script>
<script src="/assets/js/client-side-templates.js" defer></script>
<link rel="stylesheet" type="text/css" href="/assets/css/fonts.css?v=1.0" />
<link rel="stylesheet" type="text/css" href="/assets/style.css?v=1.0" />
<script type="module">
import { setup } from "/assets/scripts.js";
setup();
</script>
{{ template "_head" . }}
</head>
<body class="w-full" hx-ext="response-targets" hx-boost="true">
<body class="w-full"
hx-ext="response-targets"
hx-boost="true"
hx-swap="outerHTML show:window:top"
hx-select="main"
hx-target="main"
hx-target-4="#error-content"
hx-target-5="#error-content"
>
{{ template "_header" . }}
<div class="container flex flex-col min-h-screen max-w-(--breakpoint-2xl) mx-auto">
<main class="">
{{ block "body" . }}
<!-- Default app body... -->
{{ end }}
</main>
</div>
{{ block "_footer" . }}
{{ end }}
<main class="container flex flex-col min-h-screen max-w-(--breakpoint-2xl) mx-auto">
{{ block "body" . }}
<!-- Default app body... -->
{{ end }}
</main>
{{ template "_footer" . }}
<!-- Scroll to Top Button -->
<scroll-to-top-button></scroll-to-top-button>
{{ EmbedXSLT "xslt/transform-citation.xsl" }}
<!-- Error Modal Web Component -->
<error-modal></error-modal>
</body>
</html>

View File

@@ -1,35 +1,18 @@
<!doctype html>
<html class="w-full h-full" {{ if .lang }}lang="{{ .lang }}"{{ end }}>
<head>
<meta charset="UTF-8" />
{{ block "head" . }}
<!-- Default Head elements -->
{{ end }}
{{ if .isDev }}
<link rel="icon" href="/assets/logo/dev_favicon.png" />
{{ else }}
<link rel="icon" href="/assets/logo/favicon.png" />
{{ end }}
<link href="/assets/css/remixicon.css" rel="stylesheet" />
<script src="/assets/js/alpine.min.js" defer></script>
<script src="/assets/js/htmx.min.js" defer></script>
<script src="/assets/js/htmx-response-targets.js" defer></script>
<script src="/assets/js/client-side-templates.js" defer></script>
<link rel="stylesheet" type="text/css" href="/assets/css/fonts.css" />
<link rel="stylesheet" type="text/css" href="/assets/style.css" />
<script type="module">
import { setup } from "/assets/scripts.js";
setup();
</script>
{{ template "_head" . }}
</head>
<body class="w-full" hx-ext="response-targets" hx-boost="true">
<body class="w-full"
hx-ext="response-targets"
hx-boost="true"
hx-swap="outerHTML show:window:top"
hx-select="main"
hx-target="main"
hx-target-4="#error-content"
hx-target-5="#error-content"
>
<div class="flex flex-col min-h-screen">
<!-- Header and menu with constrained width -->
{{ template "_header" . }}
@@ -42,15 +25,11 @@
{{ end }}
</main>
<!-- Footer with constrained width -->
<div class="container max-w-(--breakpoint-2xl) mx-auto">
<footer>
{{ block "_footer" . }}
{{ end }}
</footer>
</div>
{{ template "_footer" . }}
</div>
{{ EmbedXSLT "xslt/transform-citation.xsl" }}
<!-- Error Modal Web Component -->
<error-modal></error-modal>
</body>
</html>

View File

@@ -9,8 +9,8 @@
</div>
</div>
{{ else }}
<div class="max-w-7xl mx-auto px-8 py-8">
<div class="bg-white px-6 py-6 rounded">
<div class="w-full max-w-7xl mx-auto px-8 py-8">
<div class="bg-white px-6 py-6 rounded w-full">
{{ template "_back_navigation" $agent }}
{{ template "_akteur" $agent }}
</div>

View File

@@ -8,7 +8,7 @@
{{ $pageCount := len $pages }}
<!-- Historical printing layout grid -->
<div class="grid grid-cols-2">
<div class="grid grid-cols-2 gap-x-2">
{{ template "_historical_layout" (dict "pages" $pages "pageCount" $pageCount "isBeilage" false "targetPage" $.targetPage) }}
</div>
{{ end }}
@@ -26,7 +26,7 @@
{{ $pageCount := len $beilagePages }}
<!-- Historical printing layout grid for Beilage -->
<div class="grid grid-cols-2">
<div class="grid grid-cols-2 gap-x-2">
{{ template "_historical_layout" (dict "pages" $beilagePages "pageCount" $pageCount "isBeilage" true "targetPage" $.targetPage) }}
</div>
</div>
@@ -117,6 +117,9 @@
{{ $hoverColor := "hover:border-slate-300" }}
{{ $bgColor := "bg-blue-50" }}
{{ $idPrefix := "page" }}
{{ if $isBeilage }}
{{ $idPrefix = "beilage" }}
{{ end }}
{{ if $isBeilage }}
{{ $borderColor = "border-amber-200" }}
@@ -127,11 +130,6 @@
<div class="newspaper-page-container pt-4" id="{{ $idPrefix }}-{{ $page.PageNumber }}" data-page-container="{{ $page.PageNumber }}" data-page-icon-type="{{ $page.PageIcon }}"{{ if $isBeilage }} data-beilage="true"{{ end }}>
<!-- Anchor for navigation -->
{{ if $isBeilage }}
<div id="beilage-{{ $page.PageNumber }}"></div>
{{ else }}
<div id="page-{{ $page.PageNumber }}"></div>
{{ end }}
<!-- Page indicator row -->
<div class="flex {{ if $isLeft }}justify-end{{ else }}justify-start{{ end }} items-center gap-1 mb-2">
{{ if $isLeft }}

View File

@@ -1,6 +1,5 @@
{{ $y := .model.Year }}
<div class="mt-6 w-full">
<div class="mx-auto flex flex-row gap-x-4 w-fit items-end leading-none">
{{ range $year := .model.AvailableYears }}
@@ -14,63 +13,6 @@
</div>
</div>
<!-- Page Jump Form -->
<div class="mt-8 w-full">
<div class="mx-auto text-center">
<h3 class="text-lg font-medium text-slate-700 mb-6">Direkt zu Seite springen</h3>
<form hx-post="/jump" hx-target="#jump-errors" hx-swap="innerHTML" hx-target-4*="#jump-errors" hx-target-5*="#jump-errors" hx-ext="response-targets" class="inline-flex items-center gap-3">
<!-- Year Selection -->
<div class="flex items-center gap-2">
<label for="jump-year" class="text-sm text-slate-600 whitespace-nowrap">Jahr</label>
<select id="jump-year" name="year" value="{{ $y }}" class="px-2 py-1 border border-slate-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400">
{{ range $year := .model.AvailableYears }}
<option value="{{ $year }}" {{ if eq $year $y }}selected{{ end }}>{{ $year }}</option>
{{ end }}
</select>
</div>
<!-- Page Input -->
<div class="flex items-center gap-2">
<label for="jump-page" class="text-sm text-slate-600 whitespace-nowrap">Seite</label>
<input type="number" id="jump-page" name="page" min="1" placeholder="42" class="w-20 px-2 py-1 border border-slate-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400">
</div>
<!-- Submit Button -->
<button type="submit" class="inline-flex items-center px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1">
<i class="ri-arrow-right-line mr-1"></i>
Springen
</button>
</form>
<!-- Error Display Area -->
<div id="jump-errors" class="mt-3 min-h-[1.5rem]"></div>
<!-- Instructions -->
<div class="mt-4 text-sm text-slate-500">
<p>Geben Sie Jahr und Seitenzahl ein, um direkt zur entsprechenden Ausgabe zu springen.</p>
</div>
</div>
</div>
<script>
// Clear errors when user starts typing
document.addEventListener('DOMContentLoaded', function() {
const errorContainer = document.getElementById('jump-errors');
const yearSelect = document.getElementById('jump-year');
const pageInput = document.getElementById('jump-page');
function clearErrors() {
if (errorContainer) {
errorContainer.innerHTML = '';
}
}
if (yearSelect) yearSelect.addEventListener('change', clearErrors);
if (pageInput) pageInput.addEventListener('input', clearErrors);
});
</script>
<div class="grid grid-cols-11 gap-x-2 gap-y-4 pt-8">
{{ range $index, $month := .model.Issues }}

View File

@@ -0,0 +1,238 @@
<div class="container-normal">
<div class="text [&_*]:!indent-0 border px-8 py-7">
<h1 class="!mt-0">Impressum</h1>
<div>
<p>
<strong>Telemedienanbieter im Sinne des § 5 TMG:</strong><br />Theodor Springmann
Stiftung<br />Hirschgasse 2<br />69120 Heidelberg<br /><br />Telefon +49 6221 436235<br />Email&nbsp;<a
href="mailto:info@theodor-springmann-stiftung.de"
>info@theodor-springmann-stiftung.de</a
>
</p>
<p>
<strong>Rechtsform und Sitz:</strong><br />Die Theodor Springmann Stiftung ist eine
rechtsfähige Stiftung bürgerlichen Rechts. Sitz der Stiftung ist Heidelberg.
</p>
<p>
<strong>Vorstand der Theodor Springmann Stiftung:</strong><br />Dr. Randolf Straky
(Präsident)
</p>
<p>
<strong>Geschäftsführung:</strong><br />Dr. Randolf Straky<br />Theodor Springmann
Stiftung<br />Hirschgasse 2<br />69120 Heidelberg
</p>
<p>
<strong>Zuständige Aufsichtsbehörde:</strong><br />Regierungspräsidium Karlsruhe<br />Schlossplatz
1-3<br />76131 Karlsruhe<br /><br /><a
href="https://rp.baden-wuerttemberg.de/Themen/Stiftung/Seiten/Ansprechpartner.aspx"
>Ansprechpartner</a
>
</p>
<p>
<strong>Redaktionelle Verantwortung nach § 55 Abs. 2 RStV:</strong><br />Janina Reibold
<br />Theodor Springmann Stiftung<br />Hirschgasse 2<br />69120 Heidelberg
</p>
</div>
2024-10-17 09:52:17.412Z
</div>
<div class="text">
<h1 class="mt-12">Datenschutzerklärung</h1>
<div class="prose">
<div>
<h2>Präambel</h2>
<p>
Diese Datenschutzerklärung informiert Sie über die Art, den Umfang und den Zweck der
personenbezogenen Daten, die im Rahmen dieser Onlinepräsenz von Ihnen erhoben und von uns
verarbeitet werden, sowie die Ihnen zustehenden Rechte.<br />Personenbezogene Daten sind
alle Informationen, die sich auf eine identifizierte oder identifizierbare natürliche
Person beziehen. Als identifizierbar wird eine natürliche Person angesehen, die direkt
oder indirekt identifiziert werden kann. Im Hinblick auf weitere verwendete
Begrifflichkeiten verweisen wir auf die Definitionen der Datenschutz-Grundverordnung
(DSGVO), Artikel 4.<br />Erfolgt die Verarbeitung personenbezogener Daten auf Grundlage
des Art. 6 Abs. 1 lit. f DSGVO, so besteht unser berechtigtes Interesse in der Erfüllung
unseres satzungsgemäßen Stiftungszwecks. Zweck der Stiftung ist es, in gemeinnütziger
Weise Wissenschaft und Kunst, Völkerverständigung und Entwicklungshilfe zu fördern und zum
menschlichen Selbstverständnis sowie zum Erkennen und Lindern strukturell bedingter Not
und Bedürftigkeit von Menschen in aller Welt beizutragen.
</p>
<h2>Verantwortlicher</h2>
<p>
Verantwortlich im Sinne von Art. 4 Abs. 7 DSGVO für die Verarbeitung personenbezogener
Daten ist:<br />Theodor Springmann Stiftung<br />Hirschgasse 2<br />69120 Heidelberg<br /><br /><a
href="mailto:info@theodor-springmann-stiftung.de"
>info@theodor-springmann-stiftung.de</a
>
</p>
<h2>Automatisch bei Nutzung unserer Onlinepräsenz erfasste Daten</h2>
<p>
Mit der Nutzung unserer Onlinepräsenz werden automatisch personenbezogene und allgemeine
Daten vom Nutzer übermittelt und von uns erfasst und gespeichert. Die von uns erhobenen
Daten umfassen:
</p>
<ol>
<li>
die IP-Adresse des ans Internet angeschlossenen Netzwerkgeräts (Computer oder Router des
Nutzers),
</li>
<li>den verwendeten Browsertyp und dessen Version,</li>
<li>das verwendete Betriebssystem und dessen Version,</li>
<li>die Internetseite, die den Nutzer auf unsere Onlinepräsenz verweist (Referrer),</li>
<li>die Unterwebseiten, die der Nutzer auf unserer Onlinepräsenz aufruft,</li>
<li>das Datum und die Uhrzeit des Aufrufs,</li>
<li>den Internet-Service-Provider des Nutzers,</li>
<li>sonstige ähnliche Daten.</li>
</ol>
<p>
Die Theodor Springmann Stiftung zieht aus diesen Daten keine Rückschlüsse auf die
betroffenen Nutzer. Diese Informationen werden benötigt, um
</p>
<ol>
<li>die vom Nutzer angeforderten Inhalte korrekt auszuliefern,</li>
<li>
technische Probleme zu diagnostizieren, unsere IT-Systeme vor Angriffen zu schützen und
im Falle eines Angriffs den zuständigen Behörden notwendige Informationen zur
Strafverfolgung bereitstellen zu können,
</li>
<li>die Inhalte und die Bedienung unserer Onlinepräsenz zu optimieren.</li>
</ol>
<p>
Die oben genannten Verarbeitungszwecke werden von Subsystemen erfüllt, die unabhängig von
anderen Subsystemen ihre jeweils eigene Kopie der vom Nutzer erhobenen Daten erhalten,
verarbeiten und löschen. Dabei werden den Subsystemen lediglich die zur Ausführung ihrer
Aufgabe erforderlichen Daten übergeben. Alle Subsysteme befinden sich auf Servern der
Theodor Springmann Stiftung.
</p>
<h3>Auslieferung der vom Nutzer angeforderten Inhalte</h3>
<p>
Mit der Anforderung einer Seite dieser Onlinepräsenz übermittelt der Browser des Nutzers
automatisch die IP-Adresse seines ans Internet angeschlossenen Netzwerkgeräts (Computer
oder Router) und andere Daten. Ohne diese IP-Adresse können die Inhalte dieser
Online-Präsenz nicht an den Nutzer zurückgesendet werden. Daher ist die Speicherung und
Verarbeitung der IP-Adresse für den Betrieb dieser Onlinepräsenz notwendig. Ebenfalls
übermittelte Daten über den verwendeten Browser usw. werden gegebenenfalls verwendet, um
die Anzeige der Webseite an das Anzeigegerät oder den Browser anzupassen. Die zum Zwecke
der Auslieferung von Inhalten gesammelten Daten werden direkt nach Übertragung der Inhalte
vom Subsystem gelöscht. Die Verarbeitung dieser Daten erfolgt auf der Grundlage eines
berechtigten Interesses nach Art. 6 Abs. 1 lit. f DSGVO.
</p>
<h3>Diagnose und Schutz der IT-Systeme</h3>
<p>
Daneben erfolgt eine Speicherung der oben genannten Daten in Diagnose-Protokollen. Die
Protokollierung ist notwendig, um etwaige technische Probleme analysieren oder Angriffe
erkennen und abwehren zu können. Bei Angriffen auf unsere IT-Systeme übergeben wir die
Diagnose-Protokolle den zuständigen Strafverfolgungsbehörden. Die Diagnose-Protokolle
werden nach 14 Tagen automatisch gelöscht. Die Speicherung und Verarbeitung dieser Daten
erfolgt auf Grundlage eines berechtigten Interesses nach Art. 6 Abs. 1 lit. f DSGVO.
</p>
<h3>Analyse zur Optimierung der Inhalte und der Bedienung</h3>
<p>
Die oben genannten Daten werden von einem Webanalyseprogramm verarbeitet. Dabei werden die
Daten pseudonymisiert und aggregiert, so dass sie einzelnen Nutzern nicht mehr zugeordnet
werden können. Das Analyseprogramm wird auf einem von uns betriebenen Server ausgeführt,
daher werden keine Daten an Dritte übermittelt. Der Zweck dieser Erfassung besteht in der
Analyse der Nutzung unseres Angebots, durch die eine Verbesserung unserer Webseiten, der
Inhalte und der Bedienung ermöglicht wird. Das Analyseprogramm speichert die
anonymisierten Daten ohne zeitliche Begrenzung. Die Verarbeitung dieser Daten erfolgt auf
der Grundlage eines berechtigten Interesses (Art. 6 Abs. 1 lit. f DSGVO).
</p>
<h2>Sonstige Daten</h2>
<p>
Sämtliche Daten, die mit dem Aufruf einer Webseite von unserer Onlinepräsenz an den Nutzer
übertragen werden, werden von unseren Servern bereitgestellt. Wir nutzen kein von Dritten
bereitgestelltes Content Delivery Network (CDN), um Teile unserer Onlinepräsenz (z. B.
Javascript- oder Webfont-Dateien) von dort an den Nutzer zu übermitteln. Wir verlinken
auch nicht in soziale Netzwerke. Insofern können von Dritten keine personenbezogenen Daten
über unsere Onlinepräsenz erhoben werden, es sei denn, die Datenerfassung durch Dritte
basiert auf einer gesetzlichen Grundlage und/oder wurde behördlich angeordnet.<br />In
unseren Inhalten können sich jedoch Hyperlinks („Links“) auf fremde Onlineangebote
befinden. Mit dem Anklicken eines solchen Links verlässt der Nutzer unsere Onlinepräsenz
und damit den Geltungsbereich dieser Datenschutzerklärung.
</p>
<h2>Erhobene Daten bei Kontakt</h2>
<p>
Unsere Onlinepräsenz hält unter anderem aufgrund gesetzlicher Vorgaben verschiedene
Möglichkeiten bereit, mit uns in Kontakt zu treten. Sofern eine betroffene Person Kontakt
zu uns aufnimmt, werden die von der betroffenen Person übermittelten personenbezogenen
Daten automatisch gespeichert (z. B. Telefonnummer, E-Mail-Adresse). Diese Angaben werden
zum Zwecke der Bearbeitung sowie für sich möglicherweise anschließende Kommunikation von
uns gespeichert. Sobald die Speicherung dieser Daten nicht mehr erforderlich ist und keine
gesetzlichen Archivierungsgründe vorliegen, werden die Daten gelöscht. Die
Erforderlichkeit wird alle zwei Jahre überprüft. Es erfolgt keine Weitergabe dieser
personenbezogenen Daten an Dritte.<br />Von betroffenen Personen übermittelte
Informationen zur Bearbeitung einer Kontaktanfrage werden gemäß Art. 6 Abs. 1 lit. b
(vertragliche und vorvertragliche Beziehungen) oder lit. f (andere Anfragen) der DSGVO
verarbeitet.
</p>
<h2>Speicherung und Löschung von personenbezogenen Daten</h2>
<p>
Personenbezogene Daten werden von uns für die Dauer der entsprechenden gesetzlichen
Aufbewahrungspflichten oder für den zur Vertragserfüllung erforderlichen Zeitraum
gespeichert. Liegen solche Gründe nicht vor, werden personenbezogene Daten für den
Zeitraum, der zur Erreichung des Speicherzwecks erforderlich ist, verarbeitet und
gespeichert, sofern in dieser Datenschutzerklärung nicht ausdrücklich anderes angegeben
ist. Sobald die Daten für ihre Zweckbestimmung nicht mehr erforderlich sind oder der
Speicherungszweck entfällt, werden sie nach Maßgabe der gesetzlichen Vorschriften gelöscht
oder in ihrer Verarbeitung eingeschränkt.<br />Sollten die Daten nicht gelöscht werden
dürfen, weil sie einer gesetzlichen Aufbewahrungspflicht unterliegen, wird deren
Verarbeitung durch Archivierung eingeschränkt. Die Daten werden erst nach Ablauf der
gesetzlich vorgeschriebenen Speicherfrist gelöscht.<br />Alle sechs Monate wird
routinemäßig geprüft, ob der Speicherungszweck weggefallen bzw. die Aufbewahrungspflicht
abgelaufen ist. Anschließend wird gegebenenfalls die Löschung durchgeführt.
</p>
<h2>Rechte der betroffenen Personen</h2>
<p>
Unter den angegebenen Kontaktdaten können betroffene Personen jederzeit nachgenannte
Rechte ausüben. Eine betroffene Person kann von uns verlangen, dass
</p>
<ul>
<li>
Auskunft über sie betreffende, bei uns gespeicherte Daten und deren Verarbeitung erteilt
wird (Art. 15 DSGVO),
</li>
<li>
sie betreffende unrichtige personenbezogene Daten berichtigt werden (Art. 16 DSGVO),
</li>
<li>
sie betreffende, bei uns gespeicherte personenbezogene Daten gelöscht werden (Art. 17
DSGVO),
</li>
<li>
die Verarbeitung sie betreffender, bei uns gespeicherter Daten, die aufgrund
gesetzlicher Vorschriften oder anderer Gründe nicht gelöscht werden dürfen,
eingeschränkt wird (Art. 18 DSGVO),
</li>
<li>
sie betreffende Daten übertragbar sind, soweit sie in die Datenverarbeitung eingewilligt
oder einen Vertrag mit uns geschlossen hat (Art. 20 DSGVO),
</li>
<li>
sie betreffende Daten nach einem Widerspruch nicht weiter von uns verarbeitet werden
(Art. 21 DSGVO).
</li>
</ul>
<p>Eine betroffene Person hat ferner jederzeit das Recht,</p>
<ul>
<li>
eine erteilte Einwilligung in die Erhebung und Verarbeitung personenbezogener Daten nach
Art. 6 Abs. 1 lit. a oder Art. 9 Abs. 2 lit. a DSGVO für die Zukunft zu widerrufen (Art.
7 Abs. 3 DSGVO),
</li>
<li>
sich mit einer Beschwerde an eine Aufsichtsbehörde zu wenden (Art. 15 Abs. 1 lit. f
DSGVO). Eine Liste der Aufsichtsbehörden lässt sich unter&nbsp;<a
href="https://www.bfdi.bund.de/DE/Infothek/Anschriften_Links/anschriften_links-node.html"
>diesem Link</a
>&nbsp;abrufen.
</li>
</ul>
<h2>Änderung unserer Datenschutzerklärung</h2>
<p>
Wir behalten uns vor, diese Datenschutzerklärung ohne vorherige Ankündigung an neue
rechtliche oder technische Sachverhalte sowie an Änderungen unserer Leistungen oder
Prozesse anzupassen. Es gilt die jeweils auf der Onlinepräsenz veröffentlichte Version der
Datenschutzerklärung.
</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,118 @@
<div class="flex flex-row justify-center gap-4" id="filter">
<div class="bg-white border border-slate-200 rounded-lg shadow-sm p-4 w-full max-w-md">
<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
</h3>
<!-- Unified Year Jump Filter -->
<year-jump-filter data-issues="{{ .IssuesByYearJSON }}">
<div class="space-y-3">
<!-- Year Selection -->
<div class="flex items-center gap-2 mb-4">
<label for="year-select" class="text-sm text-slate-600 w-12 hidden">Jahr wählen...</label>
<select id="year-select" class="tabular-nums flex-1 px-2 py-1 border border-slate-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400" style="max-height: 200px; overflow-y: auto;">
<option value="">Jahr wählen</option>
{{ range $year := .AvailableYears }}
<option value="{{ $year }}">{{ $year }}</option>
{{ end }}
</select>
</div>
<!-- Ausgabe Selection - Two Selects -->
<div class="flex items-center gap-2">
<select id="issue-number-select" disabled class="flex-1 px-2 py-1 border border-slate-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400 disabled:bg-slate-100 disabled:cursor-not-allowed">
<option value="">Nr.</option>
</select>
<select id="issue-date-select" disabled class="flex-1 px-2 py-1 border border-slate-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400 disabled:bg-slate-100 disabled:cursor-not-allowed">
<option value="">Datum</option>
</select>
</div>
<!-- OR Divider -->
<div class="flex items-center my-3">
<div class="flex-1 border-t border-slate-300"></div>
<span class="px-2 text-xs text-slate-500 bg-white">oder</span>
<div class="flex-1 border-t border-slate-300"></div>
</div>
<!-- Page Input -->
<div class="flex items-center gap-2">
<label for="page-input" class="text-sm text-slate-600 w-12 hidden"> oder Seite eingeben...</label>
<input type="number" id="page-input" min="1" placeholder="Seite eingeben" disabled class="flex-1 px-2 py-1 border border-slate-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400 disabled:bg-slate-100 disabled:cursor-not-allowed disabled:text-slate-500">
</div>
<!-- Page Jump Button -->
<button id="page-jump-btn" type="button" disabled class="w-full inline-flex items-center justify-center px-3 py-2 bg-slate-400 text-white text-sm rounded transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-slate-300 focus:ring-offset-1 disabled:bg-slate-400 disabled:cursor-not-allowed enabled:bg-blue-600 enabled:hover:bg-blue-700 enabled:focus:ring-blue-500">
<i class="ri-arrow-right-line mr-2"></i>
Zur Seite springen
</button>
<!-- Error Display Area -->
<div id="jump-errors" class="mt-2 min-h-[1rem]"></div>
</div>
</year-jump-filter>
</div>
<div class="bg-white border border-slate-200 rounded-lg shadow-sm p-4 w-full max-w-md">
<h3 class="text-lg font-semibold text-slate-800 mb-4 flex items-center gap-2">
<i class="ri-user-line text-slate-600"></i>
Auswahl nach Person
</h3>
<!-- Person Jump Filter -->
<person-jump-filter>
<div class="space-y-3">
<div class="flex items-center gap-2">
<label for="person-search" class="hidden text-sm text-slate-600 w-16">Filter</label>
<input
type="text"
id="person-search"
placeholder="Name oder Lebensdaten eingeben..."
class="flex-1 px-2 py-1 border border-slate-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-blue-400 focus:border-blue-400"
>
</div>
<div class="flex items-center gap-2">
<input
type="checkbox"
id="authors-only"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
>
<label for="authors-only" class="text-sm text-slate-600">Nur Autor:innen anzeigen</label>
</div>
<div class="max-h-48 overflow-y-auto border border-slate-300 rounded bg-white text-sm">
<!-- All Persons List -->
<div id="all-persons">
{{ range $person := .Persons }}
<div class="person-item odd:bg-slate-50 even:bg-white">
<a href="/akteure/{{ $person.ID }}" class="block px-2 py-1 hover:bg-blue-50 border-b border-slate-100 last:border-b-0">
<span class="person-name font-medium text-slate-800">{{ $person.Name }}</span>
{{ if $person.Life }}
<span class="person-life text-xs text-slate-500 ml-2">{{ $person.Life }}</span>
{{ end }}
</a>
</div>
{{ end }}
</div>
<!-- Authors Only List (initially hidden) -->
<div id="authors-only-list" style="display: none;">
{{ range $author := .Authors }}
<div class="person-item odd:bg-slate-50 even:bg-white">
<a href="/akteure/{{ $author.ID }}" class="block px-2 py-1 hover:bg-blue-50 border-b border-slate-100 last:border-b-0">
<span class="person-name font-medium text-slate-800">{{ $author.Name }}</span>
{{ if $author.Life }}
<span class="person-life text-xs text-slate-500 ml-2">{{ $author.Life }}</span>
{{ end }}
</a>
</div>
{{ end }}
</div>
</div>
</div>
</person-jump-filter>
</div>
</div>

View File

@@ -0,0 +1,125 @@
{{ if .model.SelectedPlace }}
<!-- Single Place Detail View -->
<div class="max-w-7xl mx-auto px-8 py-8">
<div class="bg-white px-6 py-6 rounded w-full">
<!-- Back Navigation -->
<div class="mb-6">
<a href="/ort/" class="inline-flex items-center text-blue-600 hover:text-blue-700 text-sm">
<i class="ri-arrow-left-line mr-2"></i>
Zur<75>ck zur <20>bersicht
</a>
</div>
<!-- Place Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-slate-800 mb-2">
{{ if .model.SelectedPlace.Place.Names }}
{{ index .model.SelectedPlace.Place.Names 0 }}
{{ else }}
{{ .model.SelectedPlace.Place.ID }}
{{ end }}
</h1>
{{ if .model.SelectedPlace.Place.Geo }}
<p class="text-slate-600">
<i class="ri-map-pin-line mr-1"></i>
Geonames: {{ .model.SelectedPlace.Place.Geo }}
</p>
{{ end }}
</div>
<!-- Associated Pieces -->
<div>
<h2 class="text-xl font-semibold text-slate-800 mb-4">
Beitr<74>ge ({{ len .model.SelectedPlace.Pieces }})
</h2>
{{ if .model.SelectedPlace.Pieces }}
<div class="space-y-4">
{{ range $piece := .model.SelectedPlace.Pieces }}
<div class="border border-slate-200 rounded-lg p-4 hover:bg-slate-50">
<h3 class="font-medium text-slate-800 mb-2">
{{ if $piece.Title }}
{{ index $piece.Title 0 }}
{{ else }}
Untitled
{{ end }}
</h3>
{{ if $piece.IssueRefs }}
<div class="text-sm text-slate-600">
{{ range $issueRef := $piece.IssueRefs }}
<span class="inline-block mr-4">
<i class="ri-calendar-line mr-1"></i>
<a href="/{{ $issueRef.When.Year }}/{{ $issueRef.IssueNumber }}" class="text-blue-600 hover:text-blue-700">
{{ $issueRef.When.Year }} Nr. {{ $issueRef.IssueNumber }}
</a>
</span>
{{ end }}
</div>
{{ end }}
</div>
{{ end }}
</div>
{{ else }}
<p class="text-slate-500 italic">Keine Beitr<74>ge f<>r diesen Ort gefunden.</p>
{{ end }}
</div>
</div>
</div>
{{ else }}
<!-- 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>
<!-- Available Letters Navigation -->
{{ if .model.AvailableLetters }}
<div class="mb-8">
<h2 class="text-lg font-semibold text-slate-700 mb-4">Nach Anfangsbuchstabe</h2>
<div class="flex flex-wrap gap-2">
{{ range $letter := .model.AvailableLetters }}
<span class="px-3 py-2 bg-slate-100 text-slate-700 rounded text-sm">
{{ $letter }}
</span>
{{ end }}
</div>
</div>
{{ end }}
<!-- Places List -->
{{ if .model.Places }}
<div>
<h2 class="text-lg font-semibold text-slate-700 mb-4">
Alle Orte ({{ len .model.Places }})
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{{ range $placeID := .model.Sorted }}
{{ $place := index $.model.Places $placeID }}
<div class="border border-slate-200 rounded-lg hover:bg-slate-50">
<a href="/ort/{{ $place.ID }}" class="block p-4">
<h3 class="font-medium text-slate-800 mb-1">
{{ if $place.Names }}
{{ index $place.Names 0 }}
{{ else }}
{{ $place.ID }}
{{ end }}
</h3>
{{ if $place.Geo }}
<p class="text-sm text-slate-600">
<i class="ri-map-pin-line mr-1"></i>
{{ $place.Geo }}
</p>
{{ end }}
</a>
</div>
{{ end }}
</div>
</div>
{{ else }}
<p class="text-slate-500 italic">Keine Orte gefunden.</p>
{{ end }}
</div>
</div>
{{ end }}

View File

@@ -1,3 +1,4 @@
import { ExecuteNextSettle } from "./helpers.js";
// ===========================
// AKTEURE/AUTHORS SCROLLSPY WEB COMPONENT
// ===========================
@@ -13,7 +14,7 @@ export class AkteureScrollspy extends HTMLElement {
connectedCallback() {
// Small delay to ensure DOM is fully rendered after HTMX swap
window.ExecuteNextSettle(() => {
ExecuteNextSettle(() => {
this.initializeScrollspyAfterDelay();
});
}
@@ -68,7 +69,7 @@ export class AkteureScrollspy extends HTMLElement {
this.manualNavigation = true;
target.scrollIntoView({
behavior: "smooth",
behavior: "instant",
block: "start",
});

View File

@@ -0,0 +1,83 @@
/**
* ErrorModal Web Component
* A reusable error modal component without shadow DOM for Tailwind compatibility
*/
class ErrorModal extends HTMLElement {
constructor() {
super();
// No shadow DOM - use regular DOM for Tailwind styling
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">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 max-h-96 overflow-y-auto">
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-red-600 flex items-center gap-2">
<i class="ri-error-warning-line text-xl"></i>
Fehler
</h3>
<button class="close-btn text-gray-400 hover:text-gray-600 transition-colors">
<i class="ri-close-line text-xl"></i>
</button>
</div>
<div class="error-content text-slate-700">
<!-- Error content will be loaded here -->
</div>
<div class="mt-6 flex justify-end">
<button class="close-btn px-4 py-2 bg-slate-600 hover:bg-slate-700 text-white rounded transition-colors">
Schließen
</button>
</div>
</div>
</div>
</div>
`;
this.modal = this.querySelector('#error-modal');
this.errorContent = this.querySelector('.error-content');
this.closeButtons = this.querySelectorAll('.close-btn');
this.setupEventListeners();
}
setupEventListeners() {
// Close button clicks
this.closeButtons.forEach(btn => {
btn.addEventListener('click', () => this.close());
});
// Close on ESC key
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !this.modal.classList.contains('hidden')) {
this.close();
}
});
// Close when clicking outside modal
this.modal.addEventListener('click', (event) => {
if (event.target === this.modal) {
this.close();
}
});
}
show(content) {
this.errorContent.innerHTML = content;
this.modal.classList.remove('hidden');
}
close() {
this.modal.classList.add('hidden');
}
// Global helper functions for backward compatibility
connectedCallback() {
// Make functions globally available
window.showErrorModal = (content) => this.show(content);
window.closeErrorModal = () => this.close();
}
}
// Define the custom element
customElements.define('error-modal', ErrorModal);
export { ErrorModal };

View File

@@ -0,0 +1,35 @@
// This is a queue that stores functions to be executed after the DOM is settled.
// It is used to ensure the DOM is fully rendered before executing certain actions.
// Works as well as a DOMContentLoaded event listener.
const settleQueue = [];
document.addEventListener("DOMContentLoaded", () => {
ExecuteSettleQueue();
});
const ExecuteNextSettle = function (fn) {
if (typeof fn === "function") {
settleQueue.push(fn);
}
};
const DeleteNextSettle = function (fn) {
const index = settleQueue.indexOf(fn);
if (index !== -1) {
settleQueue.splice(index, 1);
}
};
const ExecuteSettleQueue = function () {
while (settleQueue.length > 0) {
const fn = settleQueue.shift();
try {
fn();
} catch (error) {
console.error("Error executing settle queue function:", error);
}
}
};
export { ExecuteNextSettle, ExecuteSettleQueue, DeleteNextSettle };

View File

@@ -1,8 +1,11 @@
import "./site.css";
import "./search.js";
import "./akteure.js";
import { SinglePageViewer } from "./single-page-viewer.js";
import { ScrollToTopButton } from "./scroll-to-top.js";
import { InhaltsverzeichnisScrollspy } from "./inhaltsverzeichnis-scrollspy.js";
import { ErrorModal } from "./error-modal.js";
import { ExecuteSettleQueue } from "./helpers.js";
import {
enlargePage,
closeModal,
@@ -91,29 +94,7 @@ function applyPageBackdrop() {
}
}
// Function queue system for HTMX settle events
let settleQueue = [];
// Global function to register functions for next settle event
window.ExecuteNextSettle = function(fn) {
if (typeof fn === 'function') {
settleQueue.push(fn);
}
};
// Execute and clear the queue
function executeSettleQueue() {
while (settleQueue.length > 0) {
const fn = settleQueue.shift();
try {
fn();
} catch (error) {
console.error('Error executing settle queue function:', error);
}
}
}
// Export functions for global access
// Export functions for global access - moved outside setup() so they're available immediately
window.enlargePage = enlargePage;
window.closeModal = closeModal;
window.scrollToPreviousPage = scrollToPreviousPage;
@@ -124,44 +105,35 @@ window.generateCitation = generateCitation;
window.copyPagePermalink = copyPagePermalink;
window.generatePageCitation = generatePageCitation;
// INFO: This is intended to be called once on website load
function setup() {
// Apply page-specific backdrop styling
applyPageBackdrop();
// Apply page-specific backdrop styling
applyPageBackdrop();
// Update citation links on initial load
updateCitationLinks();
// Update citation links on initial load
updateCitationLinks();
// Initialize newspaper layout if present
if (document.querySelector(".newspaper-page-container")) {
initializeNewspaperLayout();
}
// Akteure scrollspy web component will auto-initialize when present in template
// HTMX event handling for newspaper layout, scrollspy, and scroll-to-top button
let htmxAfterSwapHandler = function (event) {
// Apply page-specific backdrop styling after navigation
applyPageBackdrop();
// Update citation links after navigation
updateCitationLinks();
// Execute all queued functions
executeSettleQueue();
// Use shorter delay since afterSettle ensures DOM is ready
setTimeout(() => {
if (document.querySelector(".newspaper-page-container")) {
initializeNewspaperLayout();
}
}, 50);
};
let htmxBeforeRequestHandler = function (event) {};
document.body.addEventListener("htmx:afterSettle", htmxAfterSwapHandler);
document.body.addEventListener("htmx:afterSettle", updateCitationLinks);
document.body.addEventListener("htmx:beforeRequest", htmxBeforeRequestHandler);
// Initialize newspaper layout if present
if (document.querySelector(".newspaper-page-container")) {
initializeNewspaperLayout();
}
export { setup };
// Akteure scrollspy web component will auto-initialize when present in template
// HTMX event handling for newspaper layout, scrollspy, and scroll-to-top button
let htmxAfterSwapHandler = function (event) {
// Apply page-specific backdrop styling after navigation
applyPageBackdrop();
// Update citation links after navigation
updateCitationLinks();
// Execute all queued functions
ExecuteSettleQueue();
// Use shorter delay since afterSettle ensures DOM is ready
setTimeout(() => {
if (document.querySelector(".newspaper-page-container")) {
initializeNewspaperLayout();
}
}, 50);
};
document.body.addEventListener("htmx:afterSettle", htmxAfterSwapHandler);

410
views/transform/search.js Normal file
View File

@@ -0,0 +1,410 @@
const filterBtn = document.getElementById("filter-toggle");
if (filterBtn) {
filterBtn.addEventListener("click", toggleFilter);
}
// Filter toggle functionality
function toggleFilter() {
const filterContainer = document.getElementById("filter-container");
const filterButton = document.getElementById("filter-toggle");
const filterContentDiv = filterContainer?.querySelector("div.flex.justify-center");
if (filterContainer.classList.contains("hidden")) {
// Show the filter
filterContainer.classList.remove("hidden");
filterButton.classList.add("bg-slate-200");
// Load content only if it doesn't exist - check for actual content
const hasContent = filterContentDiv && filterContentDiv.querySelector("div, form, h3");
if (!hasContent) {
htmx
.ajax("GET", "/filter", {
target: "#filter-container",
select: "#filter",
swap: "innerHTML",
})
.then(() => {
console.log("HTMX request completed");
// Re-query the element to see if it changed
const updatedDiv = document.querySelector("#filter-container .flex.justify-center");
})
.catch((error) => {
console.log("HTMX request failed:", error);
});
}
} else {
filterContainer.classList.add("hidden");
filterButton.classList.remove("bg-slate-200");
}
}
// Export for global access
window.toggleFilter = toggleFilter;
// Close filter when clicking outside
document.addEventListener("click", function (event) {
const filterContainer = document.getElementById("filter-container");
const filterButton = document.getElementById("filter-toggle");
if (
filterContainer &&
filterButton &&
!filterContainer.contains(event.target) &&
!filterButton.contains(event.target)
) {
if (!filterContainer.classList.contains("hidden")) {
filterContainer.classList.add("hidden");
filterButton.classList.remove("bg-slate-200");
}
}
});
// Handle search input logic
document.body.addEventListener("htmx:configRequest", function (event) {
let element = event.detail.elt;
if (element.id === "search" && element.value === "") {
event.detail.parameters = {};
event.detail.path = window.location.pathname + window.location.search;
}
});
/**
* PersonJumpFilter - Web component for filtering persons list
* Works with server-rendered person list and provides client-side filtering
*/
class PersonJumpFilter extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.setupEventListeners();
}
setupEventListeners() {
const searchInput = this.querySelector('#person-search');
const authorsCheckbox = this.querySelector('#authors-only');
const allPersonsList = this.querySelector('#all-persons');
const authorsOnlyList = this.querySelector('#authors-only-list');
if (!searchInput || !authorsCheckbox || !allPersonsList || !authorsOnlyList) {
return;
}
// Search functionality
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase().trim();
this.filterPersons(query);
});
// Checkbox functionality
authorsCheckbox.addEventListener('change', () => {
this.togglePersonsList();
// Clear and re-apply search filter
const query = searchInput.value.toLowerCase().trim();
this.filterPersons(query);
});
}
togglePersonsList() {
const authorsCheckbox = this.querySelector('#authors-only');
const allPersonsList = this.querySelector('#all-persons');
const authorsOnlyList = this.querySelector('#authors-only-list');
if (!authorsCheckbox || !allPersonsList || !authorsOnlyList) {
return;
}
if (authorsCheckbox.checked) {
allPersonsList.style.display = 'none';
authorsOnlyList.style.display = 'block';
} else {
allPersonsList.style.display = 'block';
authorsOnlyList.style.display = 'none';
}
}
filterPersons(query) {
// Filter items in the currently visible list
const authorsCheckbox = this.querySelector('#authors-only');
const currentList = authorsCheckbox?.checked ?
this.querySelector('#authors-only-list') :
this.querySelector('#all-persons');
if (!currentList) {
return;
}
const personItems = currentList.querySelectorAll('.person-item');
personItems.forEach(item => {
const name = item.querySelector('.person-name')?.textContent || '';
const life = item.querySelector('.person-life')?.textContent || '';
const matches = !query ||
name.toLowerCase().includes(query) ||
life.toLowerCase().includes(query);
if (matches) {
item.style.display = 'block';
} else {
item.style.display = 'none';
}
});
}
}
// Register the custom element
customElements.define('person-jump-filter', PersonJumpFilter);
/**
* YearJumpFilter - Unified web component for Jahr-based navigation
* Allows jumping by Jahr/Ausgabe or Jahr/Seite
*/
class YearJumpFilter extends HTMLElement {
constructor() {
super();
this.issuesByYear = {};
}
connectedCallback() {
this.parseIssuesData();
this.setupEventListeners();
}
parseIssuesData() {
// Parse issues data from data attributes
const issuesData = this.dataset.issues;
if (issuesData) {
try {
this.issuesByYear = JSON.parse(issuesData);
} catch (e) {
console.error('Failed to parse issues data:', e);
}
}
}
setupEventListeners() {
const yearSelect = this.querySelector('#year-select');
const issueNumberSelect = this.querySelector('#issue-number-select');
const issueDateSelect = this.querySelector('#issue-date-select');
const pageInput = this.querySelector('#page-input');
const pageJumpBtn = this.querySelector('#page-jump-btn');
if (!yearSelect) {
return;
}
// Year selection change handler
yearSelect.addEventListener('change', () => {
this.updateIssueOptions();
this.updatePageInputState();
this.clearPageErrors();
});
// Issue number selection change handler - jump immediately
if (issueNumberSelect) {
issueNumberSelect.addEventListener('change', () => {
const year = yearSelect.value;
const issueNum = issueNumberSelect.value;
if (year && issueNum) {
window.location.href = `/${year}/${issueNum}`;
}
});
}
// Issue date selection change handler - jump immediately
if (issueDateSelect) {
issueDateSelect.addEventListener('change', () => {
const year = yearSelect.value;
const issueNum = issueDateSelect.value; // value contains issue number
if (year && issueNum) {
window.location.href = `/${year}/${issueNum}`;
}
});
}
// Page input handlers
if (pageInput) {
pageInput.addEventListener('input', () => {
this.updatePageJumpButton();
this.clearPageErrors();
});
// Handle Enter key in page input
pageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.handlePageJump();
}
});
}
// Page jump button
if (pageJumpBtn) {
pageJumpBtn.addEventListener('click', () => {
this.handlePageJump();
});
}
// Page jump form submission
const pageForm = this.querySelector('#page-jump-form');
if (pageForm) {
pageForm.addEventListener('submit', (e) => {
e.preventDefault();
this.handlePageJump();
});
}
// Initialize everything
this.updateIssueOptions();
this.updatePageInputState();
this.updatePageJumpButton();
}
updateIssueOptions() {
const yearSelect = this.querySelector('#year-select');
const issueNumberSelect = this.querySelector('#issue-number-select');
const issueDateSelect = this.querySelector('#issue-date-select');
if (!yearSelect || !issueNumberSelect || !issueDateSelect) {
return;
}
const selectedYear = yearSelect.value;
const issues = this.issuesByYear[selectedYear] || [];
// Clear existing options
issueNumberSelect.innerHTML = '<option value="">Nr.</option>';
issueDateSelect.innerHTML = '<option value="">Datum</option>';
// Add options for selected year
issues.forEach(issue => {
// Issue number select - just the number
const numberOption = document.createElement('option');
numberOption.value = issue.number;
numberOption.textContent = issue.number;
issueNumberSelect.appendChild(numberOption);
// Issue date select - date with issue number as value
const dateOption = document.createElement('option');
dateOption.value = issue.number; // value is still issue number for navigation
dateOption.textContent = `${issue.date} [${issue.number}]`;
issueDateSelect.appendChild(dateOption);
});
const hasIssues = issues.length > 0 && selectedYear;
issueNumberSelect.disabled = !hasIssues;
issueDateSelect.disabled = !hasIssues;
}
async handlePageJump() {
const yearSelect = this.querySelector('#year-select');
const pageInput = this.querySelector('#page-input');
const errorContainer = this.querySelector('#jump-errors');
if (!yearSelect || !pageInput) {
return;
}
const year = yearSelect.value;
const page = pageInput.value;
if (!year || !page) {
this.showError('Bitte Jahr und Seite auswählen.');
return;
}
try {
const formData = new FormData();
formData.append('year', year);
formData.append('page', page);
const response = await fetch('/jump', {
method: 'POST',
body: formData,
redirect: 'manual'
});
// Check for HTMX redirect header
const hxRedirect = response.headers.get('HX-Redirect');
if (hxRedirect) {
window.location.href = hxRedirect;
return;
}
if (response.status === 302 || response.status === 301) {
const location = response.headers.get('Location');
if (location) {
window.location.href = location;
return;
}
}
if (response.ok) {
if (errorContainer) {
errorContainer.innerHTML = '';
}
} else {
const errorText = await response.text();
if (errorContainer) {
errorContainer.innerHTML = errorText;
}
}
} catch (error) {
console.error('Page jump failed:', error);
this.showError('Fehler beim Suchen der Seite.');
}
}
showError(message) {
const errorContainer = this.querySelector('#jump-errors');
if (errorContainer) {
errorContainer.innerHTML = `<div class="text-red-600 text-sm mt-1">${message}</div>`;
}
}
clearPageErrors() {
const errorContainer = this.querySelector('#jump-errors');
if (errorContainer) {
errorContainer.innerHTML = '';
}
}
updatePageInputState() {
const yearSelect = this.querySelector('#year-select');
const pageInput = this.querySelector('#page-input');
if (!yearSelect || !pageInput) {
return;
}
const hasYear = yearSelect.value;
pageInput.disabled = !hasYear;
if (!hasYear) {
pageInput.value = '';
this.updatePageJumpButton();
}
}
updatePageJumpButton() {
const yearSelect = this.querySelector('#year-select');
const pageInput = this.querySelector('#page-input');
const pageJumpBtn = this.querySelector('#page-jump-btn');
if (!yearSelect || !pageInput || !pageJumpBtn) {
return;
}
const hasYear = yearSelect.value;
const hasPage = pageInput.value && pageInput.value.trim();
const shouldEnable = hasYear && hasPage;
pageJumpBtn.disabled = !shouldEnable;
}
}
// Register the custom element
customElements.define('year-jump-filter', YearJumpFilter);

View File

@@ -255,9 +255,11 @@ export class SinglePageViewer extends HTMLElement {
document.body.style.overflow = "hidden";
// Dispatch event for scrollspy
document.dispatchEvent(new CustomEvent('singlepageviewer:opened', {
detail: { pageNumber: this.currentPageNumber, isBeilage: this.currentIsBeilage }
}));
document.dispatchEvent(
new CustomEvent("singlepageviewer:opened", {
detail: { pageNumber: this.currentPageNumber, isBeilage: this.currentIsBeilage },
}),
);
}
close() {
@@ -268,9 +270,11 @@ export class SinglePageViewer extends HTMLElement {
document.body.style.overflow = "";
// Dispatch event for scrollspy
document.dispatchEvent(new CustomEvent('singlepageviewer:closed', {
detail: { pageNumber: this.currentPageNumber, isBeilage: this.currentIsBeilage }
}));
document.dispatchEvent(
new CustomEvent("singlepageviewer:closed", {
detail: { pageNumber: this.currentPageNumber, isBeilage: this.currentIsBeilage },
}),
);
}
disconnectedCallback() {
@@ -282,16 +286,14 @@ export class SinglePageViewer extends HTMLElement {
// Clean up keyboard event listeners
if (this.keyboardHandler) {
document.removeEventListener('keydown', this.keyboardHandler);
document.removeEventListener("keydown", this.keyboardHandler);
this.keyboardHandler = null;
}
// Restore background scrolling
document.body.style.overflow = "";
}
// Generate icon HTML from Go icon type - matches templating/engine.go PageIcon function
generateIconFromType(iconType) {
switch (iconType) {
@@ -314,24 +316,24 @@ export class SinglePageViewer extends HTMLElement {
setupKeyboardNavigation() {
// Remove any existing listener to avoid duplicates
if (this.keyboardHandler) {
document.removeEventListener('keydown', this.keyboardHandler);
document.removeEventListener("keydown", this.keyboardHandler);
}
// Create bound handler
this.keyboardHandler = (event) => {
// Only handle keyboard events when the viewer is visible
if (this.style.display === 'none') return;
if (this.style.display === "none") return;
switch (event.key) {
case 'ArrowLeft':
case "ArrowLeft":
event.preventDefault();
this.goToPreviousPage();
break;
case 'ArrowRight':
case "ArrowRight":
event.preventDefault();
this.goToNextPage();
break;
case 'Escape':
case "Escape":
event.preventDefault();
this.close();
break;
@@ -339,10 +341,9 @@ export class SinglePageViewer extends HTMLElement {
};
// Add event listener
document.addEventListener('keydown', this.keyboardHandler);
document.addEventListener("keydown", this.keyboardHandler);
}
// Share current page
shareCurrentPage() {
if (typeof copyPagePermalink === "function") {
@@ -411,11 +412,12 @@ export class SinglePageViewer extends HTMLElement {
getAdjacentPages() {
// Get all page containers of the same type (main or beilage)
let containerSelector;
if (this.currentIsBeilage) {
containerSelector = '.newspaper-page-container[data-beilage="true"]';
} else {
containerSelector = ".newspaper-page-container:not([data-beilage])";
}
containerSelector = ".newspaper-page-container";
// if (this.currentIsBeilage) {
// containerSelector = '.newspaper-page-container[data-beilage="true"]';
// } else {
// containerSelector = ".newspaper-page-container:not([data-beilage])";
// }
const pageContainers = Array.from(document.querySelectorAll(containerSelector));
console.log(
@@ -433,8 +435,7 @@ export class SinglePageViewer extends HTMLElement {
console.log("Container page:", pageAttr, "parsed:", pageNum);
return pageNum;
})
.filter((p) => p !== null)
.sort((a, b) => a - b);
.filter((p) => p !== null);
console.log("All pages found:", allPages);
console.log("Current page:", this.currentPageNumber);
@@ -535,9 +536,11 @@ export class SinglePageViewer extends HTMLElement {
);
// Dispatch event for scrollspy to update highlighting
document.dispatchEvent(new CustomEvent('singlepageviewer:pagechanged', {
detail: { pageNumber: this.currentPageNumber, isBeilage: this.currentIsBeilage }
}));
document.dispatchEvent(
new CustomEvent("singlepageviewer:pagechanged", {
detail: { pageNumber: this.currentPageNumber, isBeilage: this.currentIsBeilage },
}),
);
}
}
}

View File

@@ -302,29 +302,30 @@ body.page-edition::before {
}
.single-page .newspaper-page-image {
max-width: min(400px, 100%);
width: 100%;
height: auto;
width: auto;
height: 750px;
object-fit: contain;
max-width: 100%;
}
/* Larger screens */
@media (min-width: 1280px) {
.single-page .newspaper-page-image {
max-width: min(600px, 100%);
height: 900px;
}
}
/* Very wide screens */
@media (min-width: 1536px) {
.single-page .newspaper-page-image {
max-width: min(700px, 100%);
height: 1050px;
}
}
/* Mobile constraints */
@media (max-width: 640px) {
.single-page .newspaper-page-image {
max-width: 100%;
height: 600px;
}
}