mirror of
https://github.com/Theodor-Springmann-Stiftung/kgpz_web.git
synced 2025-10-29 09:05:30 +00:00
Schellfilter; bugfixes; Tagewerk; Anfang Ort Controller
This commit is contained in:
135
CLAUDE.md
135
CLAUDE.md
@@ -12,7 +12,7 @@ The application follows a modular Go architecture:
|
|||||||
|
|
||||||
- **Main Application**: `kgpz_web.go` - Entry point and application lifecycle management
|
- **Main Application**: `kgpz_web.go` - Entry point and application lifecycle management
|
||||||
- **App Core**: `app/kgpz.go` - Core business logic and data processing
|
- **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/`)
|
- **View Models**: Data structures for template rendering with pre-processed business logic (`viewmodels/`)
|
||||||
- **XML Models**: Data structures for parsing source XML files (`xmlmodels/`)
|
- **XML Models**: Data structures for parsing source XML files (`xmlmodels/`)
|
||||||
- **Providers**: External service integrations (Git, GND, XML parsing, search)
|
- **Providers**: External service integrations (Git, GND, XML parsing, search)
|
||||||
@@ -129,6 +129,7 @@ views/
|
|||||||
│ │ └── _piece_summary.gohtml # Individual piece display logic
|
│ │ └── _piece_summary.gohtml # Individual piece display logic
|
||||||
│ ├── datenschutz/ # Privacy policy
|
│ ├── datenschutz/ # Privacy policy
|
||||||
│ ├── edition/ # Edition pages
|
│ ├── edition/ # Edition pages
|
||||||
|
│ ├── filter/ # Quickfilter system
|
||||||
│ ├── kategorie/ # Category pages
|
│ ├── kategorie/ # Category pages
|
||||||
│ ├── kontakt/ # Contact pages
|
│ ├── kontakt/ # Contact pages
|
||||||
│ ├── ort/ # Places pages
|
│ ├── ort/ # Places pages
|
||||||
@@ -455,6 +456,138 @@ const pageUrl = `/${year}/${issue}/${pageNumber}`;
|
|||||||
const beilageUrl = `${window.location.pathname}#beilage-1-page-${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/)
|
## 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.
|
The application provides sophisticated person and organization browsing through dual view systems with advanced navigation and filtering capabilities.
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ const (
|
|||||||
CONTACT_URL = "/kontakt/"
|
CONTACT_URL = "/kontakt/"
|
||||||
CITATION_URL = "/zitation/"
|
CITATION_URL = "/zitation/"
|
||||||
SEARCH_URL = "/suche/"
|
SEARCH_URL = "/suche/"
|
||||||
|
FILTER_URL = "/filter"
|
||||||
|
|
||||||
INDEX_URL = "/jahrgang/1764"
|
INDEX_URL = "/jahrgang/1764"
|
||||||
|
|
||||||
YEAR_OVERVIEW_URL = "/jahrgang/:year"
|
YEAR_OVERVIEW_URL = "/jahrgang/:year"
|
||||||
PLACE_OVERVIEW_URL = "/ort/:place"
|
PLACE_OVERVIEW_URL = "/ort/:place?"
|
||||||
AGENTS_OVERVIEW_URL = "/akteure/:letterorid"
|
AGENTS_OVERVIEW_URL = "/akteure/:letterorid"
|
||||||
CATEGORY_OVERVIEW_URL = "/kategorie/:category"
|
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(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(PLACE_OVERVIEW_URL, controllers.GetPlace(k.Library))
|
||||||
srv.Get(CATEGORY_OVERVIEW_URL, controllers.GetCategory(k.Library))
|
srv.Get(CATEGORY_OVERVIEW_URL, controllers.GetCategory(k.Library))
|
||||||
srv.Get(AGENTS_OVERVIEW_URL, controllers.GetAgents(k.Library))
|
srv.Get(AGENTS_OVERVIEW_URL, controllers.GetAgents(k.Library))
|
||||||
|
|||||||
129
controllers/filter_controller.go
Normal file
129
controllers/filter_controller.go
Normal 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"`
|
||||||
|
}
|
||||||
@@ -1,12 +1,40 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
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/Theodor-Springmann-Stiftung/kgpz_web/xmlmodels"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DEFAULT_PLACE = ""
|
||||||
|
)
|
||||||
|
|
||||||
func GetPlace(kgpz *xmlmodels.Library) fiber.Handler {
|
func GetPlace(kgpz *xmlmodels.Library) fiber.Handler {
|
||||||
return func(c *fiber.Ctx) error {
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ func Run(app *App) {
|
|||||||
func Engine(kgpz *app.KGPZ, c *providers.ConfigProvider) *templating.Engine {
|
func Engine(kgpz *app.KGPZ, c *providers.ConfigProvider) *templating.Engine {
|
||||||
e := templating.NewEngine(&views.LayoutFS, &views.RoutesFS)
|
e := templating.NewEngine(&views.LayoutFS, &views.RoutesFS)
|
||||||
e.AddFuncs(kgpz.Funcs())
|
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
|
return e
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,16 +139,11 @@ func (s *Server) Start() {
|
|||||||
|
|
||||||
srv.Use(recover.New())
|
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.
|
// TODO: Dont cache static assets, bc storage gets huge on images.
|
||||||
// -> Maybe fiber does this already, automatically?
|
// -> Maybe fiber does this already, automatically?
|
||||||
if s.Config.Debug {
|
if !s.Config.Debug {
|
||||||
srv.Use(cache.New(cache.Config{
|
|
||||||
Next: CacheFunc,
|
|
||||||
Expiration: CACHE_TIME,
|
|
||||||
CacheControl: false,
|
|
||||||
Storage: s.cache,
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
srv.Use(cache.New(cache.Config{
|
srv.Use(cache.New(cache.Config{
|
||||||
Next: CacheFunc,
|
Next: CacheFunc,
|
||||||
Expiration: CACHE_TIME,
|
Expiration: CACHE_TIME,
|
||||||
@@ -166,7 +161,6 @@ func (s *Server) Start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.runner(srv)
|
s.runner(srv)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Stop() {
|
func (s *Server) Stop() {
|
||||||
@@ -232,5 +226,4 @@ func (s *Server) runner(srv *fiber.App) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
ASSETS_URL_PREFIX = "/assets"
|
ASSETS_URL_PREFIX = "/assets"
|
||||||
CLEAR_LAYOUT = `
|
CLEAR_LAYOUT = `{{ block "head" . }}{{ end }}{{ block "body" . }}{{ end }}`
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
{{ block "head" . }}{{ end }}
|
|
||||||
</head>
|
|
||||||
{{ block "body" . }}{{ end }}
|
|
||||||
</html>`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
|
|||||||
95
viewmodels/place_view.go
Normal file
95
viewmodels/place_view.go
Normal 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
28
views/layouts/components/_head.gohtml
Normal file
28
views/layouts/components/_head.gohtml
Normal 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 }}" />
|
||||||
@@ -5,8 +5,12 @@
|
|||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav>
|
<nav class="relative">
|
||||||
{{ block "_menu" . }}
|
{{ block "_menu" . }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Filter Expansion Container -->
|
||||||
|
<div id="filter-container" class="mt-6 hidden">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
<div class="flex flex-row justify-center mt-12 mb-8">
|
<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">
|
<div class="w-6/12">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
@@ -14,7 +24,7 @@
|
|||||||
|
|
||||||
<div x-data="{ open: false }">
|
<div x-data="{ open: false }">
|
||||||
<button
|
<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'"
|
:class="open? 'open bg-slate-200' : 'closed'"
|
||||||
@click="open = !open">
|
@click="open = !open">
|
||||||
<i class="ri-menu-line"></i>
|
<i class="ri-menu-line"></i>
|
||||||
@@ -38,14 +48,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
|
||||||
|
|||||||
@@ -1,63 +1,31 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html class="w-full h-full" {{ if .lang }}lang="{{ .lang }}"{{ end }}>
|
<html class="w-full h-full" {{ if .lang }}lang="{{ .lang }}"{{ end }}>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
{{ template "_head" . }}
|
||||||
|
|
||||||
{{ 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>
|
|
||||||
</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" . }}
|
{{ template "_header" . }}
|
||||||
<div class="container flex flex-col min-h-screen max-w-(--breakpoint-2xl) mx-auto">
|
<main class="container flex flex-col min-h-screen max-w-(--breakpoint-2xl) mx-auto">
|
||||||
<main class="">
|
{{ block "body" . }}
|
||||||
{{ block "body" . }}
|
<!-- Default app body... -->
|
||||||
<!-- Default app body... -->
|
{{ end }}
|
||||||
{{ end }}
|
</main>
|
||||||
</main>
|
{{ template "_footer" . }}
|
||||||
</div>
|
|
||||||
{{ block "_footer" . }}
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
<!-- Scroll to Top Button -->
|
<!-- Scroll to Top Button -->
|
||||||
<scroll-to-top-button></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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,35 +1,18 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html class="w-full h-full" {{ if .lang }}lang="{{ .lang }}"{{ end }}>
|
<html class="w-full h-full" {{ if .lang }}lang="{{ .lang }}"{{ end }}>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
{{ template "_head" . }}
|
||||||
|
|
||||||
{{ 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>
|
|
||||||
</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">
|
<div class="flex flex-col min-h-screen">
|
||||||
<!-- Header and menu with constrained width -->
|
<!-- Header and menu with constrained width -->
|
||||||
{{ template "_header" . }}
|
{{ template "_header" . }}
|
||||||
@@ -42,15 +25,11 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Footer with constrained width -->
|
{{ template "_footer" . }}
|
||||||
<div class="container max-w-(--breakpoint-2xl) mx-auto">
|
|
||||||
<footer>
|
|
||||||
{{ block "_footer" . }}
|
|
||||||
{{ end }}
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ EmbedXSLT "xslt/transform-citation.xsl" }}
|
<!-- Error Modal Web Component -->
|
||||||
|
<error-modal></error-modal>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<div class="max-w-7xl mx-auto px-8 py-8">
|
<div class="w-full max-w-7xl mx-auto px-8 py-8">
|
||||||
<div class="bg-white px-6 py-6 rounded">
|
<div class="bg-white px-6 py-6 rounded w-full">
|
||||||
{{ template "_back_navigation" $agent }}
|
{{ template "_back_navigation" $agent }}
|
||||||
{{ template "_akteur" $agent }}
|
{{ template "_akteur" $agent }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{{ $pageCount := len $pages }}
|
{{ $pageCount := len $pages }}
|
||||||
|
|
||||||
<!-- Historical printing layout grid -->
|
<!-- 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) }}
|
{{ template "_historical_layout" (dict "pages" $pages "pageCount" $pageCount "isBeilage" false "targetPage" $.targetPage) }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
{{ $pageCount := len $beilagePages }}
|
{{ $pageCount := len $beilagePages }}
|
||||||
|
|
||||||
<!-- Historical printing layout grid for Beilage -->
|
<!-- 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) }}
|
{{ template "_historical_layout" (dict "pages" $beilagePages "pageCount" $pageCount "isBeilage" true "targetPage" $.targetPage) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,6 +117,9 @@
|
|||||||
{{ $hoverColor := "hover:border-slate-300" }}
|
{{ $hoverColor := "hover:border-slate-300" }}
|
||||||
{{ $bgColor := "bg-blue-50" }}
|
{{ $bgColor := "bg-blue-50" }}
|
||||||
{{ $idPrefix := "page" }}
|
{{ $idPrefix := "page" }}
|
||||||
|
{{ if $isBeilage }}
|
||||||
|
{{ $idPrefix = "beilage" }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
{{ if $isBeilage }}
|
{{ if $isBeilage }}
|
||||||
{{ $borderColor = "border-amber-200" }}
|
{{ $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 }}>
|
<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 -->
|
<!-- Anchor for navigation -->
|
||||||
{{ if $isBeilage }}
|
|
||||||
<div id="beilage-{{ $page.PageNumber }}"></div>
|
|
||||||
{{ else }}
|
|
||||||
<div id="page-{{ $page.PageNumber }}"></div>
|
|
||||||
{{ end }}
|
|
||||||
<!-- Page indicator row -->
|
<!-- Page indicator row -->
|
||||||
<div class="flex {{ if $isLeft }}justify-end{{ else }}justify-start{{ end }} items-center gap-1 mb-2">
|
<div class="flex {{ if $isLeft }}justify-end{{ else }}justify-start{{ end }} items-center gap-1 mb-2">
|
||||||
{{ if $isLeft }}
|
{{ if $isLeft }}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{{ $y := .model.Year }}
|
{{ $y := .model.Year }}
|
||||||
|
|
||||||
|
|
||||||
<div class="mt-6 w-full">
|
<div class="mt-6 w-full">
|
||||||
<div class="mx-auto flex flex-row gap-x-4 w-fit items-end leading-none">
|
<div class="mx-auto flex flex-row gap-x-4 w-fit items-end leading-none">
|
||||||
{{ range $year := .model.AvailableYears }}
|
{{ range $year := .model.AvailableYears }}
|
||||||
@@ -14,63 +13,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="grid grid-cols-11 gap-x-2 gap-y-4 pt-8">
|
||||||
{{ range $index, $month := .model.Issues }}
|
{{ range $index, $month := .model.Issues }}
|
||||||
|
|
||||||
|
|||||||
@@ -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 <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 <a
|
||||||
|
href="https://www.bfdi.bund.de/DE/Infothek/Anschriften_Links/anschriften_links-node.html"
|
||||||
|
>diesem Link</a
|
||||||
|
> 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>
|
||||||
|
|||||||
118
views/routes/filter/body.gohtml
Normal file
118
views/routes/filter/body.gohtml
Normal 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>
|
||||||
|
|
||||||
@@ -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 }}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ExecuteNextSettle } from "./helpers.js";
|
||||||
// ===========================
|
// ===========================
|
||||||
// AKTEURE/AUTHORS SCROLLSPY WEB COMPONENT
|
// AKTEURE/AUTHORS SCROLLSPY WEB COMPONENT
|
||||||
// ===========================
|
// ===========================
|
||||||
@@ -13,7 +14,7 @@ export class AkteureScrollspy extends HTMLElement {
|
|||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
// Small delay to ensure DOM is fully rendered after HTMX swap
|
// Small delay to ensure DOM is fully rendered after HTMX swap
|
||||||
window.ExecuteNextSettle(() => {
|
ExecuteNextSettle(() => {
|
||||||
this.initializeScrollspyAfterDelay();
|
this.initializeScrollspyAfterDelay();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -68,7 +69,7 @@ export class AkteureScrollspy extends HTMLElement {
|
|||||||
this.manualNavigation = true;
|
this.manualNavigation = true;
|
||||||
|
|
||||||
target.scrollIntoView({
|
target.scrollIntoView({
|
||||||
behavior: "smooth",
|
behavior: "instant",
|
||||||
block: "start",
|
block: "start",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
83
views/transform/error-modal.js
Normal file
83
views/transform/error-modal.js
Normal 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 };
|
||||||
35
views/transform/helpers.js
Normal file
35
views/transform/helpers.js
Normal 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 };
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import "./site.css";
|
import "./site.css";
|
||||||
|
import "./search.js";
|
||||||
import "./akteure.js";
|
import "./akteure.js";
|
||||||
import { SinglePageViewer } from "./single-page-viewer.js";
|
import { SinglePageViewer } from "./single-page-viewer.js";
|
||||||
import { ScrollToTopButton } from "./scroll-to-top.js";
|
import { ScrollToTopButton } from "./scroll-to-top.js";
|
||||||
import { InhaltsverzeichnisScrollspy } from "./inhaltsverzeichnis-scrollspy.js";
|
import { InhaltsverzeichnisScrollspy } from "./inhaltsverzeichnis-scrollspy.js";
|
||||||
|
import { ErrorModal } from "./error-modal.js";
|
||||||
|
import { ExecuteSettleQueue } from "./helpers.js";
|
||||||
import {
|
import {
|
||||||
enlargePage,
|
enlargePage,
|
||||||
closeModal,
|
closeModal,
|
||||||
@@ -91,29 +94,7 @@ function applyPageBackdrop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function queue system for HTMX settle events
|
// Export functions for global access - moved outside setup() so they're available immediately
|
||||||
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
|
|
||||||
window.enlargePage = enlargePage;
|
window.enlargePage = enlargePage;
|
||||||
window.closeModal = closeModal;
|
window.closeModal = closeModal;
|
||||||
window.scrollToPreviousPage = scrollToPreviousPage;
|
window.scrollToPreviousPage = scrollToPreviousPage;
|
||||||
@@ -124,44 +105,35 @@ window.generateCitation = generateCitation;
|
|||||||
window.copyPagePermalink = copyPagePermalink;
|
window.copyPagePermalink = copyPagePermalink;
|
||||||
window.generatePageCitation = generatePageCitation;
|
window.generatePageCitation = generatePageCitation;
|
||||||
|
|
||||||
// INFO: This is intended to be called once on website load
|
// Apply page-specific backdrop styling
|
||||||
function setup() {
|
applyPageBackdrop();
|
||||||
// Apply page-specific backdrop styling
|
|
||||||
applyPageBackdrop();
|
|
||||||
|
|
||||||
// Update citation links on initial load
|
// Update citation links on initial load
|
||||||
updateCitationLinks();
|
updateCitationLinks();
|
||||||
|
|
||||||
// Initialize newspaper layout if present
|
// Initialize newspaper layout if present
|
||||||
if (document.querySelector(".newspaper-page-container")) {
|
if (document.querySelector(".newspaper-page-container")) {
|
||||||
initializeNewspaperLayout();
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
410
views/transform/search.js
Normal 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);
|
||||||
@@ -255,9 +255,11 @@ export class SinglePageViewer extends HTMLElement {
|
|||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
// Dispatch event for scrollspy
|
// Dispatch event for scrollspy
|
||||||
document.dispatchEvent(new CustomEvent('singlepageviewer:opened', {
|
document.dispatchEvent(
|
||||||
detail: { pageNumber: this.currentPageNumber, isBeilage: this.currentIsBeilage }
|
new CustomEvent("singlepageviewer:opened", {
|
||||||
}));
|
detail: { pageNumber: this.currentPageNumber, isBeilage: this.currentIsBeilage },
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
@@ -268,9 +270,11 @@ export class SinglePageViewer extends HTMLElement {
|
|||||||
document.body.style.overflow = "";
|
document.body.style.overflow = "";
|
||||||
|
|
||||||
// Dispatch event for scrollspy
|
// Dispatch event for scrollspy
|
||||||
document.dispatchEvent(new CustomEvent('singlepageviewer:closed', {
|
document.dispatchEvent(
|
||||||
detail: { pageNumber: this.currentPageNumber, isBeilage: this.currentIsBeilage }
|
new CustomEvent("singlepageviewer:closed", {
|
||||||
}));
|
detail: { pageNumber: this.currentPageNumber, isBeilage: this.currentIsBeilage },
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
@@ -282,16 +286,14 @@ export class SinglePageViewer extends HTMLElement {
|
|||||||
|
|
||||||
// Clean up keyboard event listeners
|
// Clean up keyboard event listeners
|
||||||
if (this.keyboardHandler) {
|
if (this.keyboardHandler) {
|
||||||
document.removeEventListener('keydown', this.keyboardHandler);
|
document.removeEventListener("keydown", this.keyboardHandler);
|
||||||
this.keyboardHandler = null;
|
this.keyboardHandler = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Restore background scrolling
|
// Restore background scrolling
|
||||||
document.body.style.overflow = "";
|
document.body.style.overflow = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Generate icon HTML from Go icon type - matches templating/engine.go PageIcon function
|
// Generate icon HTML from Go icon type - matches templating/engine.go PageIcon function
|
||||||
generateIconFromType(iconType) {
|
generateIconFromType(iconType) {
|
||||||
switch (iconType) {
|
switch (iconType) {
|
||||||
@@ -314,24 +316,24 @@ export class SinglePageViewer extends HTMLElement {
|
|||||||
setupKeyboardNavigation() {
|
setupKeyboardNavigation() {
|
||||||
// Remove any existing listener to avoid duplicates
|
// Remove any existing listener to avoid duplicates
|
||||||
if (this.keyboardHandler) {
|
if (this.keyboardHandler) {
|
||||||
document.removeEventListener('keydown', this.keyboardHandler);
|
document.removeEventListener("keydown", this.keyboardHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create bound handler
|
// Create bound handler
|
||||||
this.keyboardHandler = (event) => {
|
this.keyboardHandler = (event) => {
|
||||||
// Only handle keyboard events when the viewer is visible
|
// Only handle keyboard events when the viewer is visible
|
||||||
if (this.style.display === 'none') return;
|
if (this.style.display === "none") return;
|
||||||
|
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'ArrowLeft':
|
case "ArrowLeft":
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.goToPreviousPage();
|
this.goToPreviousPage();
|
||||||
break;
|
break;
|
||||||
case 'ArrowRight':
|
case "ArrowRight":
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.goToNextPage();
|
this.goToNextPage();
|
||||||
break;
|
break;
|
||||||
case 'Escape':
|
case "Escape":
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.close();
|
this.close();
|
||||||
break;
|
break;
|
||||||
@@ -339,10 +341,9 @@ export class SinglePageViewer extends HTMLElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Add event listener
|
// Add event listener
|
||||||
document.addEventListener('keydown', this.keyboardHandler);
|
document.addEventListener("keydown", this.keyboardHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Share current page
|
// Share current page
|
||||||
shareCurrentPage() {
|
shareCurrentPage() {
|
||||||
if (typeof copyPagePermalink === "function") {
|
if (typeof copyPagePermalink === "function") {
|
||||||
@@ -411,11 +412,12 @@ export class SinglePageViewer extends HTMLElement {
|
|||||||
getAdjacentPages() {
|
getAdjacentPages() {
|
||||||
// Get all page containers of the same type (main or beilage)
|
// Get all page containers of the same type (main or beilage)
|
||||||
let containerSelector;
|
let containerSelector;
|
||||||
if (this.currentIsBeilage) {
|
containerSelector = ".newspaper-page-container";
|
||||||
containerSelector = '.newspaper-page-container[data-beilage="true"]';
|
// if (this.currentIsBeilage) {
|
||||||
} else {
|
// containerSelector = '.newspaper-page-container[data-beilage="true"]';
|
||||||
containerSelector = ".newspaper-page-container:not([data-beilage])";
|
// } else {
|
||||||
}
|
// containerSelector = ".newspaper-page-container:not([data-beilage])";
|
||||||
|
// }
|
||||||
|
|
||||||
const pageContainers = Array.from(document.querySelectorAll(containerSelector));
|
const pageContainers = Array.from(document.querySelectorAll(containerSelector));
|
||||||
console.log(
|
console.log(
|
||||||
@@ -433,8 +435,7 @@ export class SinglePageViewer extends HTMLElement {
|
|||||||
console.log("Container page:", pageAttr, "parsed:", pageNum);
|
console.log("Container page:", pageAttr, "parsed:", pageNum);
|
||||||
return pageNum;
|
return pageNum;
|
||||||
})
|
})
|
||||||
.filter((p) => p !== null)
|
.filter((p) => p !== null);
|
||||||
.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
console.log("All pages found:", allPages);
|
console.log("All pages found:", allPages);
|
||||||
console.log("Current page:", this.currentPageNumber);
|
console.log("Current page:", this.currentPageNumber);
|
||||||
@@ -535,9 +536,11 @@ export class SinglePageViewer extends HTMLElement {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Dispatch event for scrollspy to update highlighting
|
// Dispatch event for scrollspy to update highlighting
|
||||||
document.dispatchEvent(new CustomEvent('singlepageviewer:pagechanged', {
|
document.dispatchEvent(
|
||||||
detail: { pageNumber: this.currentPageNumber, isBeilage: this.currentIsBeilage }
|
new CustomEvent("singlepageviewer:pagechanged", {
|
||||||
}));
|
detail: { pageNumber: this.currentPageNumber, isBeilage: this.currentIsBeilage },
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,29 +302,30 @@ body.page-edition::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.single-page .newspaper-page-image {
|
.single-page .newspaper-page-image {
|
||||||
max-width: min(400px, 100%);
|
width: auto;
|
||||||
width: 100%;
|
height: 750px;
|
||||||
height: auto;
|
object-fit: contain;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Larger screens */
|
/* Larger screens */
|
||||||
@media (min-width: 1280px) {
|
@media (min-width: 1280px) {
|
||||||
.single-page .newspaper-page-image {
|
.single-page .newspaper-page-image {
|
||||||
max-width: min(600px, 100%);
|
height: 900px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Very wide screens */
|
/* Very wide screens */
|
||||||
@media (min-width: 1536px) {
|
@media (min-width: 1536px) {
|
||||||
.single-page .newspaper-page-image {
|
.single-page .newspaper-page-image {
|
||||||
max-width: min(700px, 100%);
|
height: 1050px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile constraints */
|
/* Mobile constraints */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.single-page .newspaper-page-image {
|
.single-page .newspaper-page-image {
|
||||||
max-width: 100%;
|
height: 600px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user