mirror of
https://github.com/Theodor-Springmann-Stiftung/musenalm.git
synced 2026-02-04 02:25:30 +00:00
+/baende endpoint
This commit is contained in:
214
app/pb.go
214
app/pb.go
@@ -8,6 +8,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/middleware"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/middleware"
|
||||||
@@ -31,17 +32,59 @@ type BootFunc = func(e *core.BootstrapEvent) error
|
|||||||
|
|
||||||
// INFO: this is the main application that mainly is a pocketbase wrapper
|
// INFO: this is the main application that mainly is a pocketbase wrapper
|
||||||
type App struct {
|
type App struct {
|
||||||
PB *pocketbase.PocketBase
|
PB *pocketbase.PocketBase
|
||||||
MAConfig Config
|
MAConfig Config
|
||||||
Pages []pagemodels.IPage
|
Pages []pagemodels.IPage
|
||||||
dataCache *PrefixCache
|
dataCache *PrefixCache
|
||||||
dataMutex sync.RWMutex
|
dataMutex sync.RWMutex
|
||||||
htmlCache *PrefixCache
|
htmlCache *PrefixCache
|
||||||
htmlMutex sync.RWMutex
|
htmlMutex sync.RWMutex
|
||||||
pagesCache map[string]PageMetaData
|
pagesCache map[string]PageMetaData
|
||||||
pagesMutex sync.RWMutex
|
pagesMutex sync.RWMutex
|
||||||
imagesCache map[string]*dbmodels.Image
|
imagesCache map[string]*dbmodels.Image
|
||||||
imagesMutex sync.RWMutex
|
imagesMutex sync.RWMutex
|
||||||
|
baendeCache *BaendeCache
|
||||||
|
baendeCacheMutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaendeCache struct {
|
||||||
|
Entries []*dbmodels.Entry
|
||||||
|
Series map[string]*dbmodels.Series
|
||||||
|
EntriesSeries map[string][]*dbmodels.REntriesSeries
|
||||||
|
Places map[string]*dbmodels.Place
|
||||||
|
Agents map[string]*dbmodels.Agent
|
||||||
|
EntriesAgents map[string][]*dbmodels.REntriesAgents
|
||||||
|
Items map[string][]*dbmodels.Item
|
||||||
|
CachedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implement BaendeCacheInterface methods
|
||||||
|
func (bc *BaendeCache) GetEntries() interface{} {
|
||||||
|
return bc.Entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bc *BaendeCache) GetSeries() interface{} {
|
||||||
|
return bc.Series
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bc *BaendeCache) GetEntriesSeries() interface{} {
|
||||||
|
return bc.EntriesSeries
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bc *BaendeCache) GetPlaces() interface{} {
|
||||||
|
return bc.Places
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bc *BaendeCache) GetAgents() interface{} {
|
||||||
|
return bc.Agents
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bc *BaendeCache) GetEntriesAgents() interface{} {
|
||||||
|
return bc.EntriesAgents
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bc *BaendeCache) GetItems() interface{} {
|
||||||
|
return bc.Items
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -486,6 +529,155 @@ func (app *App) ensureImagesCache() {
|
|||||||
app.imagesMutex.Unlock()
|
app.imagesMutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (app *App) ResetBaendeCache() {
|
||||||
|
app.baendeCacheMutex.Lock()
|
||||||
|
defer app.baendeCacheMutex.Unlock()
|
||||||
|
app.baendeCache = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) EnsureBaendeCache() (*BaendeCache, error) {
|
||||||
|
// Check if cache is valid with read lock
|
||||||
|
app.baendeCacheMutex.RLock()
|
||||||
|
if app.baendeCache != nil && time.Since(app.baendeCache.CachedAt) < time.Hour {
|
||||||
|
cache := app.baendeCache
|
||||||
|
app.baendeCacheMutex.RUnlock()
|
||||||
|
return cache, nil
|
||||||
|
}
|
||||||
|
app.baendeCacheMutex.RUnlock()
|
||||||
|
|
||||||
|
// Acquire write lock to populate cache
|
||||||
|
app.baendeCacheMutex.Lock()
|
||||||
|
defer app.baendeCacheMutex.Unlock()
|
||||||
|
|
||||||
|
// Double-check after acquiring write lock
|
||||||
|
if app.baendeCache != nil && time.Since(app.baendeCache.CachedAt) < time.Hour {
|
||||||
|
return app.baendeCache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all entries sorted by PreferredTitle
|
||||||
|
entries := []*dbmodels.Entry{}
|
||||||
|
if err := app.PB.RecordQuery(dbmodels.ENTRIES_TABLE).
|
||||||
|
OrderBy(dbmodels.PREFERRED_TITLE_FIELD).
|
||||||
|
All(&entries); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect entry IDs
|
||||||
|
entryIDs := make([]any, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
entryIDs = append(entryIDs, entry.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load series and relations
|
||||||
|
seriesMap := map[string]*dbmodels.Series{}
|
||||||
|
entrySeriesMap := map[string][]*dbmodels.REntriesSeries{}
|
||||||
|
if len(entries) > 0 {
|
||||||
|
relations, err := dbmodels.REntriesSeries_Entries(app.PB.App, dbmodels.Ids(entries))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
seriesIDs := []any{}
|
||||||
|
for _, r := range relations {
|
||||||
|
seriesIDs = append(seriesIDs, r.Series())
|
||||||
|
entrySeriesMap[r.Entry()] = append(entrySeriesMap[r.Entry()], r)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(seriesIDs) > 0 {
|
||||||
|
series, err := dbmodels.Series_IDs(app.PB.App, seriesIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, s := range series {
|
||||||
|
seriesMap[s.Id] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load agents and relations
|
||||||
|
agentsMap := map[string]*dbmodels.Agent{}
|
||||||
|
entryAgentsMap := map[string][]*dbmodels.REntriesAgents{}
|
||||||
|
if len(entryIDs) > 0 {
|
||||||
|
arelations, err := dbmodels.REntriesAgents_Entries(app.PB.App, entryIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
agentIDs := []any{}
|
||||||
|
for _, r := range arelations {
|
||||||
|
agentIDs = append(agentIDs, r.Agent())
|
||||||
|
entryAgentsMap[r.Entry()] = append(entryAgentsMap[r.Entry()], r)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(agentIDs) > 0 {
|
||||||
|
agents, err := dbmodels.Agents_IDs(app.PB.App, agentIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, a := range agents {
|
||||||
|
agentsMap[a.Id] = a
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load places
|
||||||
|
placesMap := map[string]*dbmodels.Place{}
|
||||||
|
placesIDs := []any{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
for _, placeID := range entry.Places() {
|
||||||
|
placesIDs = append(placesIDs, placeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(placesIDs) > 0 {
|
||||||
|
places, err := dbmodels.Places_IDs(app.PB.App, placesIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, place := range places {
|
||||||
|
placesMap[place.Id] = place
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load items
|
||||||
|
itemsMap := map[string][]*dbmodels.Item{}
|
||||||
|
if len(entryIDs) > 0 {
|
||||||
|
allItems, err := dbmodels.Items_Entries(app.PB.App, entryIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
interestedEntries := make(map[string]struct{})
|
||||||
|
for _, id := range entryIDs {
|
||||||
|
interestedEntries[id.(string)] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range allItems {
|
||||||
|
for _, entryID := range item.Entries() {
|
||||||
|
if _, ok := interestedEntries[entryID]; ok {
|
||||||
|
itemsMap[entryID] = append(itemsMap[entryID], item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.baendeCache = &BaendeCache{
|
||||||
|
Entries: entries,
|
||||||
|
Series: seriesMap,
|
||||||
|
EntriesSeries: entrySeriesMap,
|
||||||
|
Places: placesMap,
|
||||||
|
Agents: agentsMap,
|
||||||
|
EntriesAgents: entryAgentsMap,
|
||||||
|
Items: itemsMap,
|
||||||
|
CachedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.baendeCache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *App) GetBaendeCache() (pagemodels.BaendeCacheInterface, error) {
|
||||||
|
return app.EnsureBaendeCache()
|
||||||
|
}
|
||||||
|
|
||||||
func (app *App) setWatchers(engine *templating.Engine) {
|
func (app *App) setWatchers(engine *templating.Engine) {
|
||||||
// INFO: hot reloading for poor people
|
// INFO: hot reloading for poor people
|
||||||
watcher, err := EngineWatcher(engine)
|
watcher, err := EngineWatcher(engine)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (p *AlmanachEditPage) Setup(router *router.Router[*core.RequestEvent], ia p
|
|||||||
rg := router.Group(URL_ALMANACH)
|
rg := router.Group(URL_ALMANACH)
|
||||||
rg.BindFunc(middleware.IsAdminOrEditor())
|
rg.BindFunc(middleware.IsAdminOrEditor())
|
||||||
rg.GET(URL_ALMANACH_EDIT, p.GET(engine, app))
|
rg.GET(URL_ALMANACH_EDIT, p.GET(engine, app))
|
||||||
rg.POST(URL_ALMANACH_EDIT+"save", p.POSTSave(engine, app))
|
rg.POST(URL_ALMANACH_EDIT+"save", p.POSTSave(engine, app, ia))
|
||||||
rg.POST(URL_ALMANACH_EDIT+"delete", p.POSTDelete(engine, app))
|
rg.POST(URL_ALMANACH_EDIT+"delete", p.POSTDelete(engine, app))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ func NewAlmanachEditResult(app core.App, id string, filters BeitraegeFilterParam
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AlmanachEditPage) POSTSave(engine *templating.Engine, app core.App) HandleFunc {
|
func (p *AlmanachEditPage) POSTSave(engine *templating.Engine, app core.App, ma pagemodels.IApp) HandleFunc {
|
||||||
return func(e *core.RequestEvent) error {
|
return func(e *core.RequestEvent) error {
|
||||||
id := e.Request.PathValue("id")
|
id := e.Request.PathValue("id")
|
||||||
req := templating.NewRequest(e)
|
req := templating.NewRequest(e)
|
||||||
@@ -194,6 +194,9 @@ func (p *AlmanachEditPage) POSTSave(engine *templating.Engine, app core.App) Han
|
|||||||
// Invalidate sorted entries cache since entry was modified
|
// Invalidate sorted entries cache since entry was modified
|
||||||
InvalidateSortedEntriesCache()
|
InvalidateSortedEntriesCache()
|
||||||
|
|
||||||
|
// Invalidate Bände cache since entry was modified
|
||||||
|
ma.ResetBaendeCache()
|
||||||
|
|
||||||
// Check if fields that affect contents changed
|
// Check if fields that affect contents changed
|
||||||
contentsNeedUpdate := entry.PreferredTitle() != oldPreferredTitle ||
|
contentsNeedUpdate := entry.PreferredTitle() != oldPreferredTitle ||
|
||||||
entry.Year() != oldYear ||
|
entry.Year() != oldYear ||
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@@ -20,8 +23,10 @@ import (
|
|||||||
const (
|
const (
|
||||||
URL_BAENDE = "/baende/"
|
URL_BAENDE = "/baende/"
|
||||||
URL_BAENDE_RESULTS = "/baende/results/"
|
URL_BAENDE_RESULTS = "/baende/results/"
|
||||||
|
URL_BAENDE_MORE = "/baende/more/"
|
||||||
TEMPLATE_BAENDE = "/baende/"
|
TEMPLATE_BAENDE = "/baende/"
|
||||||
URL_BAENDE_DETAILS = "/baende/details/{id}"
|
URL_BAENDE_DETAILS = "/baende/details/{id}"
|
||||||
|
BAENDE_PAGE_SIZE = 150
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -51,29 +56,30 @@ type BaendeResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BaendeDetailsResult struct {
|
type BaendeDetailsResult struct {
|
||||||
Entry *dbmodels.Entry
|
Entry *dbmodels.Entry
|
||||||
Series []*dbmodels.Series
|
Series []*dbmodels.Series
|
||||||
Places []*dbmodels.Place
|
Places []*dbmodels.Place
|
||||||
Agents []*dbmodels.Agent
|
Agents []*dbmodels.Agent
|
||||||
Items []*dbmodels.Item
|
Items []*dbmodels.Item
|
||||||
SeriesRels []*dbmodels.REntriesSeries
|
SeriesRels []*dbmodels.REntriesSeries
|
||||||
AgentRels []*dbmodels.REntriesAgents
|
AgentRels []*dbmodels.REntriesAgents
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
CSRFToken string
|
CSRFToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *BaendePage) Setup(router *router.Router[*core.RequestEvent], ia pagemodels.IApp, engine *templating.Engine) error {
|
func (p *BaendePage) Setup(router *router.Router[*core.RequestEvent], ia pagemodels.IApp, engine *templating.Engine) error {
|
||||||
app := ia.Core()
|
app := ia.Core()
|
||||||
rg := router.Group(URL_BAENDE)
|
rg := router.Group(URL_BAENDE)
|
||||||
rg.BindFunc(middleware.Authenticated(app))
|
rg.BindFunc(middleware.Authenticated(app))
|
||||||
rg.GET("", p.handlePage(engine, app))
|
rg.GET("", p.handlePage(engine, app, ia))
|
||||||
rg.GET("results/", p.handleResults(engine, app))
|
rg.GET("results/", p.handleResults(engine, app, ia))
|
||||||
|
rg.GET("more/", p.handleMore(engine, app, ia))
|
||||||
rg.GET("details/{id}", p.handleDetails(engine, app))
|
rg.GET("details/{id}", p.handleDetails(engine, app))
|
||||||
rg.GET("row/{id}", p.handleRow(engine, app))
|
rg.GET("row/{id}", p.handleRow(engine, app))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *BaendePage) handlePage(engine *templating.Engine, app core.App) HandleFunc {
|
func (p *BaendePage) handlePage(engine *templating.Engine, app core.App, ma pagemodels.IApp) HandleFunc {
|
||||||
return func(e *core.RequestEvent) error {
|
return func(e *core.RequestEvent) error {
|
||||||
req := templating.NewRequest(e)
|
req := templating.NewRequest(e)
|
||||||
if req.User() == nil {
|
if req.User() == nil {
|
||||||
@@ -81,7 +87,7 @@ func (p *BaendePage) handlePage(engine *templating.Engine, app core.App) HandleF
|
|||||||
return e.Redirect(303, "/login/?redirectTo="+redirectTo)
|
return e.Redirect(303, "/login/?redirectTo="+redirectTo)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := p.buildResultData(app, e, req)
|
data, err := p.buildResultData(app, ma, e, req, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return engine.Response404(e, err, data)
|
return engine.Response404(e, err, data)
|
||||||
}
|
}
|
||||||
@@ -89,7 +95,7 @@ func (p *BaendePage) handlePage(engine *templating.Engine, app core.App) HandleF
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *BaendePage) handleResults(engine *templating.Engine, app core.App) HandleFunc {
|
func (p *BaendePage) handleResults(engine *templating.Engine, app core.App, ma pagemodels.IApp) HandleFunc {
|
||||||
return func(e *core.RequestEvent) error {
|
return func(e *core.RequestEvent) error {
|
||||||
req := templating.NewRequest(e)
|
req := templating.NewRequest(e)
|
||||||
if req.User() == nil {
|
if req.User() == nil {
|
||||||
@@ -97,7 +103,7 @@ func (p *BaendePage) handleResults(engine *templating.Engine, app core.App) Hand
|
|||||||
return e.Redirect(303, "/login/?redirectTo="+redirectTo)
|
return e.Redirect(303, "/login/?redirectTo="+redirectTo)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := p.buildResultData(app, e, req)
|
data, err := p.buildResultData(app, ma, e, req, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return engine.Response404(e, err, data)
|
return engine.Response404(e, err, data)
|
||||||
}
|
}
|
||||||
@@ -203,126 +209,171 @@ func (p *BaendePage) handleDetails(engine *templating.Engine, app core.App) Hand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *BaendePage) buildResultData(app core.App, e *core.RequestEvent, req *templating.Request) (map[string]any, error) {
|
func (p *BaendePage) buildResultData(app core.App, ma pagemodels.IApp, e *core.RequestEvent, req *templating.Request, showAggregated bool) (map[string]any, error) {
|
||||||
data := map[string]any{}
|
data := map[string]any{}
|
||||||
|
|
||||||
|
// Get offset from query params (default 0)
|
||||||
|
offset := 0
|
||||||
|
if offsetStr := e.Request.URL.Query().Get("offset"); offsetStr != "" {
|
||||||
|
if val, err := strconv.Atoi(offsetStr); err == nil && val >= 0 {
|
||||||
|
offset = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get filters from query params
|
||||||
search := strings.TrimSpace(e.Request.URL.Query().Get("search"))
|
search := strings.TrimSpace(e.Request.URL.Query().Get("search"))
|
||||||
if search != "" {
|
|
||||||
data["search"] = search
|
|
||||||
}
|
|
||||||
|
|
||||||
letter := strings.ToUpper(strings.TrimSpace(e.Request.URL.Query().Get("letter")))
|
letter := strings.ToUpper(strings.TrimSpace(e.Request.URL.Query().Get("letter")))
|
||||||
if letter == "" {
|
|
||||||
letter = "A"
|
// Validate letter
|
||||||
}
|
if letter != "" {
|
||||||
if len(letter) > 1 {
|
if len(letter) > 1 {
|
||||||
letter = letter[:1]
|
letter = letter[:1]
|
||||||
}
|
}
|
||||||
if letter < "A" || letter > "Z" {
|
if letter < "A" || letter > "Z" {
|
||||||
letter = "A"
|
letter = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entries := []*dbmodels.Entry{}
|
// Get sort parameters
|
||||||
|
sort := strings.ToLower(strings.TrimSpace(e.Request.URL.Query().Get("sort")))
|
||||||
|
order := strings.ToLower(strings.TrimSpace(e.Request.URL.Query().Get("order")))
|
||||||
|
|
||||||
|
// Validate sort field - whitelist approach for security
|
||||||
|
validSorts := map[string]bool{
|
||||||
|
"title": true,
|
||||||
|
"alm": true,
|
||||||
|
"year": true,
|
||||||
|
"signatur": true,
|
||||||
|
}
|
||||||
|
if !validSorts[sort] {
|
||||||
|
sort = "title" // default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate order
|
||||||
|
if order != "asc" && order != "desc" {
|
||||||
|
order = "asc" // default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load from cache
|
||||||
|
cacheInterface, err := ma.GetBaendeCache()
|
||||||
|
if err != nil {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract data from cache using interface methods
|
||||||
|
allEntries, ok := cacheInterface.GetEntries().([]*dbmodels.Entry)
|
||||||
|
if !ok {
|
||||||
|
return data, fmt.Errorf("failed to get entries from cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsMap, ok := cacheInterface.GetItems().(map[string][]*dbmodels.Item)
|
||||||
|
if !ok {
|
||||||
|
return data, fmt.Errorf("failed to get items from cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
seriesMap, ok := cacheInterface.GetSeries().(map[string]*dbmodels.Series)
|
||||||
|
if !ok {
|
||||||
|
return data, fmt.Errorf("failed to get series from cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
entrySeriesMap, ok := cacheInterface.GetEntriesSeries().(map[string][]*dbmodels.REntriesSeries)
|
||||||
|
if !ok {
|
||||||
|
return data, fmt.Errorf("failed to get entries series from cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
placesMap, ok := cacheInterface.GetPlaces().(map[string]*dbmodels.Place)
|
||||||
|
if !ok {
|
||||||
|
return data, fmt.Errorf("failed to get places from cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
agentsMap, ok := cacheInterface.GetAgents().(map[string]*dbmodels.Agent)
|
||||||
|
if !ok {
|
||||||
|
return data, fmt.Errorf("failed to get agents from cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
entryAgentsMap, ok := cacheInterface.GetEntriesAgents().(map[string][]*dbmodels.REntriesAgents)
|
||||||
|
if !ok {
|
||||||
|
return data, fmt.Errorf("failed to get entries agents from cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search or letter filter
|
||||||
|
var filteredEntries []*dbmodels.Entry
|
||||||
if search != "" {
|
if search != "" {
|
||||||
var err error
|
trimmedSearch := strings.TrimSpace(search)
|
||||||
entries, err = searchBaendeEntries(app, search)
|
if utf8.RuneCountInString(trimmedSearch) >= 3 {
|
||||||
if err != nil {
|
entries, err := searchBaendeEntries(app, trimmedSearch)
|
||||||
return data, err
|
if err != nil {
|
||||||
}
|
return data, err
|
||||||
} else {
|
|
||||||
if err := app.RecordQuery(dbmodels.ENTRIES_TABLE).
|
|
||||||
Where(dbx.Like(dbmodels.PREFERRED_TITLE_FIELD, letter).Match(false, true)).
|
|
||||||
OrderBy(dbmodels.PREFERRED_TITLE_FIELD).
|
|
||||||
All(&entries); err != nil {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entryIDs := []any{}
|
|
||||||
for _, entry := range entries {
|
|
||||||
entryIDs = append(entryIDs, entry.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
seriesMap := map[string]*dbmodels.Series{}
|
|
||||||
entrySeriesMap := map[string][]*dbmodels.REntriesSeries{}
|
|
||||||
if len(entries) > 0 {
|
|
||||||
series, relations, err := Series_Entries(app, entries)
|
|
||||||
if err != nil {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
for _, s := range series {
|
|
||||||
seriesMap[s.Id] = s
|
|
||||||
}
|
|
||||||
for _, r := range relations {
|
|
||||||
entrySeriesMap[r.Entry()] = append(entrySeriesMap[r.Entry()], r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
agentsMap := map[string]*dbmodels.Agent{}
|
|
||||||
entryAgentsMap := map[string][]*dbmodels.REntriesAgents{}
|
|
||||||
if len(entryIDs) > 0 {
|
|
||||||
agents, arelations, err := Agents_Entries_IDs(app, entryIDs)
|
|
||||||
if err != nil {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
for _, a := range agents {
|
|
||||||
agentsMap[a.Id] = a
|
|
||||||
}
|
|
||||||
for _, r := range arelations {
|
|
||||||
entryAgentsMap[r.Entry()] = append(entryAgentsMap[r.Entry()], r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
placesMap := map[string]*dbmodels.Place{}
|
|
||||||
placesIDs := []any{}
|
|
||||||
for _, entry := range entries {
|
|
||||||
for _, placeID := range entry.Places() {
|
|
||||||
placesIDs = append(placesIDs, placeID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(placesIDs) > 0 {
|
|
||||||
places, err := dbmodels.Places_IDs(app, placesIDs)
|
|
||||||
if err != nil {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
for _, place := range places {
|
|
||||||
placesMap[place.Id] = place
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
itemsMap := map[string][]*dbmodels.Item{}
|
|
||||||
if len(entryIDs) > 0 {
|
|
||||||
// 1. Fetch all items related to any of the entry IDs in a single query.
|
|
||||||
allItems, err := dbmodels.Items_Entries(app, entryIDs)
|
|
||||||
if err != nil {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Create a lookup map for the entries we are interested in.
|
|
||||||
interestedEntries := make(map[string]struct{})
|
|
||||||
for _, id := range entryIDs {
|
|
||||||
interestedEntries[id.(string)] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Group the fetched items by their associated entry ID.
|
|
||||||
for _, item := range allItems {
|
|
||||||
// An item can be related to multiple entries. We need to check which of its entries are in our current list.
|
|
||||||
for _, entryID := range item.Entries() {
|
|
||||||
// If the item's entry ID is in our list of interested entries, add the item to the map.
|
|
||||||
if _, ok := interestedEntries[entryID]; ok {
|
|
||||||
itemsMap[entryID] = append(itemsMap[entryID], item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
filteredEntries = entries
|
||||||
|
} else {
|
||||||
|
filteredEntries = filterEntriesBySearch(allEntries, itemsMap, trimmedSearch)
|
||||||
}
|
}
|
||||||
|
data["search"] = trimmedSearch
|
||||||
|
} else if letter != "" {
|
||||||
|
// Apply letter filter
|
||||||
|
filteredEntries = filterEntriesByLetter(allEntries, letter)
|
||||||
|
} else {
|
||||||
|
filteredEntries = allEntries
|
||||||
}
|
}
|
||||||
|
|
||||||
letters := []string{
|
// Apply sorting based on sort parameter
|
||||||
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
|
switch sort {
|
||||||
"N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
|
case "alm":
|
||||||
|
dbmodels.Sort_Entries_MusenalmID(filteredEntries)
|
||||||
|
case "year":
|
||||||
|
dbmodels.Sort_Entries_Year_Title(filteredEntries)
|
||||||
|
case "signatur":
|
||||||
|
dbmodels.Sort_Entries_Signatur(filteredEntries, itemsMap)
|
||||||
|
default: // "title"
|
||||||
|
dbmodels.Sort_Entries_Title_Year(filteredEntries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reverse for descending order
|
||||||
|
if order == "desc" {
|
||||||
|
slices.Reverse(filteredEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate pagination
|
||||||
|
totalCount := len(filteredEntries)
|
||||||
|
var pageEntries []*dbmodels.Entry
|
||||||
|
nextOffset := offset
|
||||||
|
hasMore := false
|
||||||
|
currentCount := 0
|
||||||
|
|
||||||
|
if showAggregated {
|
||||||
|
displayLimit := offset + BAENDE_PAGE_SIZE
|
||||||
|
if displayLimit > totalCount {
|
||||||
|
displayLimit = totalCount
|
||||||
|
}
|
||||||
|
if displayLimit < 0 {
|
||||||
|
displayLimit = 0
|
||||||
|
}
|
||||||
|
pageEntries = filteredEntries[:displayLimit]
|
||||||
|
nextOffset = displayLimit
|
||||||
|
currentCount = len(pageEntries)
|
||||||
|
hasMore = displayLimit < totalCount
|
||||||
|
} else {
|
||||||
|
start := offset
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
if start > totalCount {
|
||||||
|
start = totalCount
|
||||||
|
}
|
||||||
|
endIndex := start + BAENDE_PAGE_SIZE
|
||||||
|
if endIndex > totalCount {
|
||||||
|
endIndex = totalCount
|
||||||
|
}
|
||||||
|
pageEntries = filteredEntries[start:endIndex]
|
||||||
|
nextOffset = endIndex
|
||||||
|
currentCount = start + len(pageEntries)
|
||||||
|
hasMore = endIndex < totalCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build result with cached associated data
|
||||||
data["result"] = &BaendeResult{
|
data["result"] = &BaendeResult{
|
||||||
Entries: entries,
|
Entries: pageEntries,
|
||||||
Series: seriesMap,
|
Series: seriesMap,
|
||||||
EntriesSeries: entrySeriesMap,
|
EntriesSeries: entrySeriesMap,
|
||||||
Places: placesMap,
|
Places: placesMap,
|
||||||
@@ -330,13 +381,110 @@ func (p *BaendePage) buildResultData(app core.App, e *core.RequestEvent, req *te
|
|||||||
EntriesAgents: entryAgentsMap,
|
EntriesAgents: entryAgentsMap,
|
||||||
Items: itemsMap,
|
Items: itemsMap,
|
||||||
}
|
}
|
||||||
|
data["offset"] = offset
|
||||||
|
data["total_count"] = totalCount
|
||||||
|
data["current_count"] = currentCount
|
||||||
|
data["has_more"] = hasMore
|
||||||
|
data["next_offset"] = nextOffset
|
||||||
data["letter"] = letter
|
data["letter"] = letter
|
||||||
data["letters"] = letters
|
data["sort_field"] = sort
|
||||||
|
data["sort_order"] = order
|
||||||
data["csrf_token"] = req.Session().Token
|
data["csrf_token"] = req.Session().Token
|
||||||
|
|
||||||
|
// Keep letters array for navigation
|
||||||
|
letters := []string{
|
||||||
|
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
|
||||||
|
"N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
|
||||||
|
}
|
||||||
|
data["letters"] = letters
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *BaendePage) handleMore(engine *templating.Engine, app core.App, ma pagemodels.IApp) HandleFunc {
|
||||||
|
return func(e *core.RequestEvent) error {
|
||||||
|
req := templating.NewRequest(e)
|
||||||
|
if req.User() == nil {
|
||||||
|
return e.Redirect(303, "/login/")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := p.buildResultData(app, ma, e, req, false)
|
||||||
|
if err != nil {
|
||||||
|
return engine.Response404(e, err, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add header to indicate if more results exist
|
||||||
|
hasMore := "false"
|
||||||
|
if hasMoreVal, ok := data["has_more"].(bool); ok && hasMoreVal {
|
||||||
|
hasMore = "true"
|
||||||
|
}
|
||||||
|
e.Response.Header().Set("X-Has-More", hasMore)
|
||||||
|
if nextOffsetVal, ok := data["next_offset"].(int); ok {
|
||||||
|
e.Response.Header().Set("X-Next-Offset", strconv.Itoa(nextOffsetVal))
|
||||||
|
} else {
|
||||||
|
e.Response.Header().Set("X-Next-Offset", "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
return engine.Response200(e, URL_BAENDE_MORE, data, "fragment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterEntriesBySearch performs in-memory filtering of entries by search term
|
||||||
|
func filterEntriesBySearch(entries []*dbmodels.Entry, itemsMap map[string][]*dbmodels.Item, search string) []*dbmodels.Entry {
|
||||||
|
query := strings.ToLower(strings.TrimSpace(search))
|
||||||
|
if query == "" {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []*dbmodels.Entry
|
||||||
|
for _, entry := range entries {
|
||||||
|
if matchesShortSearch(entry, itemsMap, query) {
|
||||||
|
results = append(results, entry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesShortSearch(entry *dbmodels.Entry, itemsMap map[string][]*dbmodels.Item, query string) bool {
|
||||||
|
if strings.Contains(strings.ToLower(entry.PreferredTitle()), query) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(entry.TitleStmt()), query) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(entry.PlaceStmt()), query) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(entry.ResponsibilityStmt()), query) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(strconv.Itoa(entry.MusenalmID()), query) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if items, ok := itemsMap[entry.Id]; ok {
|
||||||
|
for _, item := range items {
|
||||||
|
if strings.Contains(strings.ToLower(item.Identifier()), query) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterEntriesByLetter performs in-memory filtering of entries by first letter
|
||||||
|
func filterEntriesByLetter(entries []*dbmodels.Entry, letter string) []*dbmodels.Entry {
|
||||||
|
var results []*dbmodels.Entry
|
||||||
|
for _, entry := range entries {
|
||||||
|
preferredTitle := entry.PreferredTitle()
|
||||||
|
if len(preferredTitle) > 0 && strings.HasPrefix(strings.ToUpper(preferredTitle), letter) {
|
||||||
|
results = append(results, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
func searchBaendeEntries(app core.App, search string) ([]*dbmodels.Entry, error) {
|
func searchBaendeEntries(app core.App, search string) ([]*dbmodels.Entry, error) {
|
||||||
query := strings.TrimSpace(search)
|
query := strings.TrimSpace(search)
|
||||||
if query == "" {
|
if query == "" {
|
||||||
@@ -412,8 +560,6 @@ func searchBaendeEntriesFTS(app core.App, query string) ([]*dbmodels.Entry, erro
|
|||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func searchBaendeEntriesQuick(app core.App, query string) ([]*dbmodels.Entry, error) {
|
func searchBaendeEntriesQuick(app core.App, query string) ([]*dbmodels.Entry, error) {
|
||||||
trimmed := strings.TrimSpace(query)
|
trimmed := strings.TrimSpace(query)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
@@ -425,6 +571,7 @@ func searchBaendeEntriesQuick(app core.App, query string) ([]*dbmodels.Entry, er
|
|||||||
|
|
||||||
entryFields := []string{
|
entryFields := []string{
|
||||||
dbmodels.PREFERRED_TITLE_FIELD,
|
dbmodels.PREFERRED_TITLE_FIELD,
|
||||||
|
dbmodels.MUSENALMID_FIELD,
|
||||||
}
|
}
|
||||||
|
|
||||||
entryConditions := make([]dbx.Expression, 0, len(entryFields))
|
entryConditions := make([]dbx.Expression, 0, len(entryFields))
|
||||||
|
|||||||
@@ -64,3 +64,52 @@ func Sort_Places_Name(places []*Place) {
|
|||||||
return collator.CompareString(i.Name(), j.Name())
|
return collator.CompareString(i.Name(), j.Name())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort_Entries_MusenalmID sorts entries by MusenalmID (Alm number) in ascending order
|
||||||
|
func Sort_Entries_MusenalmID(entries []*Entry) {
|
||||||
|
slices.SortFunc(entries, func(i, j *Entry) int {
|
||||||
|
return i.MusenalmID() - j.MusenalmID()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort_Entries_Signatur sorts entries by their lowest signature (identifier) alphabetically
|
||||||
|
// Entries with no items sort last, entries with items but empty identifiers also sort last
|
||||||
|
func Sort_Entries_Signatur(entries []*Entry, itemsMap map[string][]*Item) {
|
||||||
|
collator := collate.New(language.German)
|
||||||
|
slices.SortFunc(entries, func(i, j *Entry) int {
|
||||||
|
iItems := itemsMap[i.Id]
|
||||||
|
jItems := itemsMap[j.Id]
|
||||||
|
|
||||||
|
// Find lowest signature for entry i
|
||||||
|
var iLowestSig string
|
||||||
|
for _, item := range iItems {
|
||||||
|
sig := item.Identifier()
|
||||||
|
if sig != "" && (iLowestSig == "" || collator.CompareString(sig, iLowestSig) < 0) {
|
||||||
|
iLowestSig = sig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find lowest signature for entry j
|
||||||
|
var jLowestSig string
|
||||||
|
for _, item := range jItems {
|
||||||
|
sig := item.Identifier()
|
||||||
|
if sig != "" && (jLowestSig == "" || collator.CompareString(sig, jLowestSig) < 0) {
|
||||||
|
jLowestSig = sig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entries without signatures sort last
|
||||||
|
if iLowestSig == "" && jLowestSig == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if iLowestSig == "" {
|
||||||
|
return 1 // i goes after j
|
||||||
|
}
|
||||||
|
if jLowestSig == "" {
|
||||||
|
return -1 // i goes before j
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare using German collation for natural sorting
|
||||||
|
return collator.CompareString(iLowestSig, jLowestSig)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,25 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BaendeCacheInterface defines the interface for Bände cache operations
|
||||||
|
// We use an interface with accessor methods to avoid circular dependencies
|
||||||
|
type BaendeCacheInterface interface {
|
||||||
|
GetEntries() interface{} // Returns []*dbmodels.Entry
|
||||||
|
GetSeries() interface{} // Returns map[string]*dbmodels.Series
|
||||||
|
GetEntriesSeries() interface{} // Returns map[string][]*dbmodels.REntriesSeries
|
||||||
|
GetPlaces() interface{} // Returns map[string]*dbmodels.Place
|
||||||
|
GetAgents() interface{} // Returns map[string]*dbmodels.Agent
|
||||||
|
GetEntriesAgents() interface{} // Returns map[string][]*dbmodels.REntriesAgents
|
||||||
|
GetItems() interface{} // Returns map[string][]*dbmodels.Item
|
||||||
|
}
|
||||||
|
|
||||||
type IApp interface {
|
type IApp interface {
|
||||||
Core() core.App
|
Core() core.App
|
||||||
ResetDataCache()
|
ResetDataCache()
|
||||||
ResetHtmlCache()
|
ResetHtmlCache()
|
||||||
ResetPagesCache()
|
ResetPagesCache()
|
||||||
|
ResetBaendeCache()
|
||||||
|
GetBaendeCache() (BaendeCacheInterface, error)
|
||||||
Logger() *slog.Logger
|
Logger() *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,97 @@
|
|||||||
{{ $model := . }}
|
{{ $model := . }}
|
||||||
|
|
||||||
<div x-data="{ search : '{{ $model.search }}' }" class="container-normal font-sans mt-10">
|
<div x-data="{
|
||||||
|
search: '{{ $model.search }}',
|
||||||
|
offset: {{ if $model.next_offset }}{{ $model.next_offset }}{{ else }}0{{ end }},
|
||||||
|
hasMore: {{ if $model.has_more }}true{{ else }}false{{ end }},
|
||||||
|
loading: false,
|
||||||
|
alphabetOpen: false,
|
||||||
|
selectedLetter: '{{ $model.letter }}',
|
||||||
|
sortField: '{{ if $model.sort_field }}{{ $model.sort_field }}{{ else }}title{{ end }}',
|
||||||
|
sortOrder: '{{ if $model.sort_order }}{{ $model.sort_order }}{{ else }}asc{{ end }}',
|
||||||
|
changeSort(field) {
|
||||||
|
if (this.sortField === field) {
|
||||||
|
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
this.sortField = field;
|
||||||
|
this.sortOrder = 'asc';
|
||||||
|
}
|
||||||
|
this.offset = 0;
|
||||||
|
this.hasMore = true;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('sort', this.sortField);
|
||||||
|
params.set('order', this.sortOrder);
|
||||||
|
params.set('offset', 0);
|
||||||
|
if (this.search) {
|
||||||
|
params.set('search', this.search);
|
||||||
|
}
|
||||||
|
if (this.selectedLetter) {
|
||||||
|
params.set('letter', this.selectedLetter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
htmx.ajax('GET', `/baende/results/?${queryString}`, {
|
||||||
|
target: '#baenderesults',
|
||||||
|
swap: 'outerHTML',
|
||||||
|
indicator: 'body'
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateUrl() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('offset', this.offset);
|
||||||
|
params.set('sort', this.sortField);
|
||||||
|
params.set('order', this.sortOrder);
|
||||||
|
if (this.search) {
|
||||||
|
params.set('search', this.search);
|
||||||
|
}
|
||||||
|
if (this.selectedLetter) {
|
||||||
|
params.set('letter', this.selectedLetter);
|
||||||
|
}
|
||||||
|
const query = params.toString();
|
||||||
|
const newUrl = query ? `/baende/?${query}` : '/baende/';
|
||||||
|
window.history.replaceState(null, '', newUrl);
|
||||||
|
},
|
||||||
|
loadMoreUrl() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('offset', this.offset);
|
||||||
|
params.set('sort', this.sortField);
|
||||||
|
params.set('order', this.sortOrder);
|
||||||
|
if (this.search) {
|
||||||
|
params.set('search', this.search);
|
||||||
|
}
|
||||||
|
if (this.selectedLetter) {
|
||||||
|
params.set('letter', this.selectedLetter);
|
||||||
|
}
|
||||||
|
return `/baende/more/?${params.toString()}`;
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
@htmx:after-swap.window="
|
||||||
|
if ($event.detail.target && $event.detail.target.id === 'baenderesults') {
|
||||||
|
const responseUrl = $event.detail.xhr?.responseURL || window.location.href;
|
||||||
|
const params = new URL(responseUrl).searchParams;
|
||||||
|
selectedLetter = params.get('letter') || '';
|
||||||
|
sortField = params.get('sort') || sortField;
|
||||||
|
sortOrder = params.get('order') || sortOrder;
|
||||||
|
const nextOffsetAttr = $event.detail.target.dataset.nextOffset;
|
||||||
|
if (nextOffsetAttr) {
|
||||||
|
const parsed = Number(nextOffsetAttr);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
offset = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateUrl();
|
||||||
|
}
|
||||||
|
"
|
||||||
|
class="container-normal font-sans mt-10">
|
||||||
<div id="pageheading" class="headingcontainer">
|
<div id="pageheading" class="headingcontainer">
|
||||||
<h1 class="heading">Bände A–Z</h1>
|
<h1 class="heading">Bände A–Z</h1>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div class="flex flex-wrap flex-row border-b px-3 border-zinc-300 items-end justify-between min-h-14 gap-y-4">
|
<div class="flex flex-wrap flex-row border-b px-3 border-zinc-300 items-end justify-between min-h-14 gap-y-4">
|
||||||
<!-- Left side group: Count and Alphabet -->
|
<!-- Left side group: Search and Alphabet -->
|
||||||
<div class="flex items-end gap-x-6">
|
|
||||||
<div id="alphabet" class="alphabet flex flex-row items-end text-xl">
|
|
||||||
{{- range $_, $ch := $model.letters -}}
|
|
||||||
<a
|
|
||||||
href="/baende/?letter={{ $ch }}"
|
|
||||||
:class="search ? 'inactive pointer-events-none' : ''"
|
|
||||||
class="odd:bg-stone-100 even:bg-zinc-100 mr-1 border-zinc-300 border-x border-t [&>a[aria-current='page']]:font-bold px-2 py-0.5 no-underline transition-all duration-75"
|
|
||||||
{{ if and (not $model.search) (eq $model.letter $ch) }}aria-current="page"{{ end }}>
|
|
||||||
{{ $ch }}
|
|
||||||
</a>
|
|
||||||
{{- end -}}
|
|
||||||
<i class="ml-2 ri-hourglass-2-fill request-indicator spinning"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right side group: Search and Spalten menu -->
|
|
||||||
<div class="flex items-end gap-4">
|
<div class="flex items-end gap-4">
|
||||||
|
<!-- Search box -->
|
||||||
<div class="min-w-[22.5rem] max-w-96 flex flex-row bg-stone-50 relative font-sans text-lg items-center">
|
<div class="min-w-[22.5rem] max-w-96 flex flex-row bg-stone-50 relative font-sans text-lg items-center">
|
||||||
<div>
|
<div>
|
||||||
<i class="ri-search-line"></i><i class="-ml-0.5 inline-block ri-arrow-right-s-line"></i>
|
<i class="ri-search-line"></i><i class="-ml-0.5 inline-block ri-arrow-right-s-line"></i>
|
||||||
@@ -38,10 +106,10 @@
|
|||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-target="#baenderesults"
|
hx-target="#baenderesults"
|
||||||
role="search"
|
role="search"
|
||||||
|
@submit="offset = 0; hasMore = true"
|
||||||
aria-label="Bändesuche">
|
aria-label="Bändesuche">
|
||||||
{{- if $model.letter -}}
|
<input type="hidden" name="sort" :value="sortField" />
|
||||||
<input type="hidden" name="letter" value="{{- $model.letter -}}" />
|
<input type="hidden" name="order" :value="sortOrder" />
|
||||||
{{- end -}}
|
|
||||||
<input
|
<input
|
||||||
class="px-2 py-0.5 font-sans placeholder:italic w-full text-lg"
|
class="px-2 py-0.5 font-sans placeholder:italic w-full text-lg"
|
||||||
type="search"
|
type="search"
|
||||||
@@ -56,45 +124,114 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Alphabet navigation toggle -->
|
||||||
|
<div class="relative">
|
||||||
|
<details class="font-sans text-base list-none" data-role="alphabet-toggle" @toggle="alphabetOpen = $el.open">
|
||||||
|
<summary class="cursor-pointer text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2">
|
||||||
|
Alphabet
|
||||||
|
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': alphabetOpen }"></i>
|
||||||
|
</summary>
|
||||||
|
<div class="absolute left-0 mt-2 w-12 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
||||||
|
<div class="p-2 flex flex-col gap-1 text-sm text-gray-700">
|
||||||
|
{{- range $_, $ch := $model.letters -}}
|
||||||
|
<a href="/baende/?letter={{ $ch }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-get="/baende/results/?letter={{ $ch }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-target="#baenderesults"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-indicator="body"
|
||||||
|
hx-push-url="/baende/?letter={{ $ch }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
@click="offset = 0; hasMore = true; alphabetOpen = false; selectedLetter = '{{ $ch }}'"
|
||||||
|
:class="selectedLetter === '{{ $ch }}' ? 'bg-stone-200 font-bold' : ''"
|
||||||
|
class="text-center py-1 px-2 rounded hover:bg-gray-100 no-underline transition-colors">
|
||||||
|
{{ $ch }}
|
||||||
|
</a>
|
||||||
|
{{- end -}}
|
||||||
|
<!-- Clear filter option -->
|
||||||
|
<a href="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-get="/baende/results/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-target="#baenderesults"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-indicator="body"
|
||||||
|
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
@click="offset = 0; hasMore = true; alphabetOpen = false; selectedLetter = ''"
|
||||||
|
class="text-center py-1 px-2 rounded hover:bg-gray-100 no-underline transition-colors border-t mt-1">
|
||||||
|
Alle
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right side group: Spalten menu and count/button -->
|
||||||
|
<div class="flex items-end gap-4">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Spalten toggle -->
|
||||||
|
<div class="relative" x-data="{ open: false }">
|
||||||
|
<details class="font-sans text-base list-none" data-role="baende-column-toggle" @toggle="open = $el.open">
|
||||||
|
<summary class="cursor-pointer text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2">
|
||||||
|
Spalten
|
||||||
|
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
||||||
|
</summary>
|
||||||
|
<div class="absolute left-0 mt-2 w-56 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
||||||
|
<div class="p-4 flex flex-col gap-2 text-sm text-gray-700">
|
||||||
|
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="appearance" checked /> Erscheinung</label>
|
||||||
|
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="year" /> Jahr</label>
|
||||||
|
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="language" /> Sprachen</label>
|
||||||
|
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="extent" checked /> Umfang / Maße</label>
|
||||||
|
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="signatures" checked /> Signaturen</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Count and New button -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div id="baende-count" class="text-lg font-semibold font-sans text-gray-600 whitespace-nowrap">
|
||||||
|
{{ if $model.current_count }}{{ $model.current_count }} / {{ end }}{{ if $model.total_count }}{{ $model.total_count }}{{ else }}{{ len $model.result.Entries }}{{ end }} Bände
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="content-action-button"
|
||||||
|
onclick="window.location.assign('/almanach/new')">
|
||||||
|
<i class="ri-add-line"></i>
|
||||||
|
<span>Neuer Band</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="baenderesults" class="mt-2" data-next-offset="{{ $model.next_offset }}">
|
||||||
|
|
||||||
<div class="flex justify-between mt-10">
|
|
||||||
<div class="relative" x-data="{ open: false }">
|
|
||||||
<details class="font-sans text-base list-none" data-role="baende-column-toggle" @toggle="open = $el.open">
|
|
||||||
<summary class="cursor-pointer text-gray-700 hover:text-slate-900 bg-gray-100 px-3 py-1.5 rounded-md flex items-center gap-2">
|
|
||||||
Spalten
|
|
||||||
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
|
||||||
</summary>
|
|
||||||
<div class="absolute left-0 mt-2 w-56 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
|
||||||
<div class="p-4 flex flex-col gap-2 text-sm text-gray-700">
|
|
||||||
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="appearance" checked /> Erscheinung</label>
|
|
||||||
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="year" /> Jahr</label>
|
|
||||||
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="language" /> Sprachen</label>
|
|
||||||
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="extent" checked /> Umfang / Maße</label>
|
|
||||||
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="signatures" checked /> Signaturen</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div id="baende-count" class="text-lg font-semibold font-sans text-gray-600 whitespace-nowrap">
|
|
||||||
{{ len $model.result.Entries }} Bände
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="content-action-button"
|
|
||||||
onclick="window.location.assign('/almanach/new')">
|
|
||||||
<i class="ri-add-line"></i>
|
|
||||||
<span>Neuer Band</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="baenderesults" class="mt-2">
|
|
||||||
{{ template "_baende_table" $model }}
|
{{ template "_baende_table" $model }}
|
||||||
|
|
||||||
|
<!-- Load More Button -->
|
||||||
|
<div class="mt-6 flex justify-center" x-show="hasMore">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="content-action-button"
|
||||||
|
:hx-get="loadMoreUrl()"
|
||||||
|
hx-target="#baende-tbody"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
hx-indicator="this"
|
||||||
|
@htmx:before-request="loading = true"
|
||||||
|
@htmx:after-request="
|
||||||
|
loading = false;
|
||||||
|
hasMore = $event.detail.xhr.getResponseHeader('X-Has-More') === 'true';
|
||||||
|
const nextOffsetHeader = Number($event.detail.xhr.getResponseHeader('X-Next-Offset'));
|
||||||
|
if (!Number.isNaN(nextOffsetHeader)) {
|
||||||
|
offset = nextOffsetHeader;
|
||||||
|
}
|
||||||
|
updateUrl();
|
||||||
|
"
|
||||||
|
:disabled="loading">
|
||||||
|
<i class="ri-arrow-down-line" :class="{ 'spinning': loading }"></i>
|
||||||
|
<span x-text="loading ? 'Lädt...' : 'Weitere 150 laden'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,16 +4,72 @@
|
|||||||
<table class="min-w-full text-sm font-sans baende-text">
|
<table class="min-w-full text-sm font-sans baende-text">
|
||||||
<thead class="text-left text-gray-600 border-b">
|
<thead class="text-left text-gray-600 border-b">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="py-2 pr-4 pl-2 whitespace-nowrap w-[10rem]"></th>
|
<th class="py-2 pr-4 pl-2 whitespace-nowrap w-[10rem]"
|
||||||
<th class="py-2 pr-4 whitespace-nowrap w-[44rem]">Titel</th>
|
:aria-sort="sortField === 'alm' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'">
|
||||||
|
<button type="button"
|
||||||
|
class="flex w-full items-center justify-between gap-1 text-left text-sm"
|
||||||
|
@click="changeSort('alm')">
|
||||||
|
<span class="font-semibold tracking-wide">Alm</span>
|
||||||
|
<i class="text-xs opacity-70 transition-colors"
|
||||||
|
:class="{
|
||||||
|
'ri-arrow-up-line text-blue-600': sortField === 'alm' && sortOrder === 'asc',
|
||||||
|
'ri-arrow-down-line text-blue-600': sortField === 'alm' && sortOrder === 'desc',
|
||||||
|
'ri-arrow-up-down-line text-gray-400': sortField !== 'alm'
|
||||||
|
}"
|
||||||
|
aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th class="py-2 pr-4 whitespace-nowrap w-[44rem]"
|
||||||
|
:aria-sort="sortField === 'title' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'">
|
||||||
|
<button type="button"
|
||||||
|
class="flex w-full items-center justify-between gap-1 text-left text-sm"
|
||||||
|
@click="changeSort('title')">
|
||||||
|
<span class="font-semibold tracking-wide">Titel</span>
|
||||||
|
<i class="text-xs opacity-70 transition-colors"
|
||||||
|
:class="{
|
||||||
|
'ri-arrow-up-line text-blue-600': sortField === 'title' && sortOrder === 'asc',
|
||||||
|
'ri-arrow-down-line text-blue-600': sortField === 'title' && sortOrder === 'desc',
|
||||||
|
'ri-arrow-up-down-line text-gray-400': sortField !== 'title'
|
||||||
|
}"
|
||||||
|
aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
<th class="py-2 pr-4 whitespace-nowrap col-appearance w-[18rem]">Erscheinung</th>
|
<th class="py-2 pr-4 whitespace-nowrap col-appearance w-[18rem]">Erscheinung</th>
|
||||||
<th class="py-2 pr-4 whitespace-nowrap col-year hidden">Jahr</th>
|
<th class="py-2 pr-4 whitespace-nowrap col-year hidden"
|
||||||
|
:aria-sort="sortField === 'year' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'">
|
||||||
|
<button type="button"
|
||||||
|
class="flex w-full items-center justify-between gap-1 text-left text-sm"
|
||||||
|
@click="changeSort('year')">
|
||||||
|
<span class="font-semibold tracking-wide">Jahr</span>
|
||||||
|
<i class="text-xs opacity-70 transition-colors"
|
||||||
|
:class="{
|
||||||
|
'ri-arrow-up-line text-blue-600': sortField === 'year' && sortOrder === 'asc',
|
||||||
|
'ri-arrow-down-line text-blue-600': sortField === 'year' && sortOrder === 'desc',
|
||||||
|
'ri-arrow-up-down-line text-gray-400': sortField !== 'year'
|
||||||
|
}"
|
||||||
|
aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
<th class="py-2 pr-4 whitespace-nowrap col-language hidden">Sprachen</th>
|
<th class="py-2 pr-4 whitespace-nowrap col-language hidden">Sprachen</th>
|
||||||
<th class="py-2 pr-4 whitespace-nowrap col-extent w-[18rem]">Umfang / Maße</th>
|
<th class="py-2 pr-4 whitespace-nowrap col-extent w-[18rem]">Umfang / Maße</th>
|
||||||
<th class="py-2 pr-4 whitespace-nowrap col-signatures">Signaturen</th>
|
<th class="py-2 pr-4 whitespace-nowrap col-signatures"
|
||||||
|
:aria-sort="sortField === 'signatur' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'">
|
||||||
|
<button type="button"
|
||||||
|
class="flex w-full items-center justify-between gap-1 text-left text-sm"
|
||||||
|
@click="changeSort('signatur')">
|
||||||
|
<span class="font-semibold tracking-wide">Signaturen</span>
|
||||||
|
<i class="text-xs opacity-70 transition-colors"
|
||||||
|
:class="{
|
||||||
|
'ri-arrow-up-line text-blue-600': sortField === 'signatur' && sortOrder === 'asc',
|
||||||
|
'ri-arrow-down-line text-blue-600': sortField === 'signatur' && sortOrder === 'desc',
|
||||||
|
'ri-arrow-up-down-line text-gray-400': sortField !== 'signatur'
|
||||||
|
}"
|
||||||
|
aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="baende-tbody">
|
||||||
{{- range $_, $entry := $model.result.Entries -}}
|
{{- range $_, $entry := $model.result.Entries -}}
|
||||||
<tr class="border-b align-top cursor-pointer transition-colors odd:bg-white even:bg-stone-50/60 hover:bg-stone-100"
|
<tr class="border-b align-top cursor-pointer transition-colors odd:bg-white even:bg-stone-50/60 hover:bg-stone-100"
|
||||||
data-role="baende-row"
|
data-role="baende-row"
|
||||||
|
|||||||
166
views/routes/baende/more/body.gohtml
Normal file
166
views/routes/baende/more/body.gohtml
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
{{ $model := . }}
|
||||||
|
|
||||||
|
{{- range $_, $entry := $model.result.Entries -}}
|
||||||
|
<tr class="border-b align-top cursor-pointer transition-colors odd:bg-white even:bg-stone-50/60 hover:bg-stone-100"
|
||||||
|
data-role="baende-row"
|
||||||
|
data-entry-id="{{ $entry.MusenalmID }}"
|
||||||
|
hx-get="/baende/details/{{ $entry.MusenalmID }}"
|
||||||
|
hx-target="this"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<td class="py-2 pr-4 pl-2 whitespace-nowrap w-[10rem]">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="inline-flex items-center rounded-xs bg-stone-100 px-2.5 py-0.5 text-xs font-semibold text-slate-700">Alm {{ $entry.MusenalmID }}</span>
|
||||||
|
{{- if $entry.References -}}
|
||||||
|
<span class="inline-flex items-center rounded-xs bg-stone-100 px-2.5 py-0.5 text-xs text-slate-700 max-w-[10rem] whitespace-normal break-words" title="{{ $entry.References }}">{{ $entry.References }}</span>
|
||||||
|
{{- end -}}
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5 pt-1">
|
||||||
|
<tool-tip position="top" class="inline">
|
||||||
|
<a href="/almanach/{{ $entry.MusenalmID }}" class="no-underline inline-flex items-center gap-1 rounded-xs bg-stone-100 px-2 py-1 text-xs font-semibold text-slate-700 hover:bg-stone-200 hover:text-slate-900">
|
||||||
|
<i class="ri-eye-line"></i>
|
||||||
|
</a>
|
||||||
|
<div class="data-tip">Ansehen</div>
|
||||||
|
</tool-tip>
|
||||||
|
{{- if (IsAdminOrEditor $model.request.user) -}}
|
||||||
|
<tool-tip position="top" class="inline">
|
||||||
|
<a href="/almanach/{{ $entry.MusenalmID }}/edit" class="no-underline inline-flex items-center gap-1 rounded-xs bg-stone-100 px-2 py-1 text-xs font-semibold text-slate-700 hover:bg-stone-200 hover:text-slate-900">
|
||||||
|
<i class="ri-edit-line"></i>
|
||||||
|
</a>
|
||||||
|
<div class="data-tip">Bearbeiten</div>
|
||||||
|
</tool-tip>
|
||||||
|
<form method="POST" action="/almanach/{{ $entry.MusenalmID }}/edit/delete" class="inline" onsubmit="return confirm('Band wirklich löschen?');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ $model.csrf_token }}" />
|
||||||
|
<tool-tip position="top" class="inline">
|
||||||
|
<button type="submit" class="inline-flex items-center gap-1 rounded-xs bg-red-50 px-2 py-1 text-xs font-semibold text-red-700 hover:bg-red-100 hover:text-red-900">
|
||||||
|
<i class="ri-delete-bin-line"></i>
|
||||||
|
</button>
|
||||||
|
<div class="data-tip">Löschen</div>
|
||||||
|
</tool-tip>
|
||||||
|
</form>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4">
|
||||||
|
<div class="font-semibold text-slate-900 text-base leading-snug">
|
||||||
|
{{- if $entry.PreferredTitle -}}
|
||||||
|
{{ $entry.PreferredTitle }}
|
||||||
|
{{- else if ne $entry.Year 0 -}}
|
||||||
|
{{ $entry.Year }}
|
||||||
|
{{- else -}}
|
||||||
|
[o.J.]
|
||||||
|
{{- end -}}
|
||||||
|
<tool-tip position="top" class="inline">
|
||||||
|
<i class="status-icon ml-1 align-middle {{- if eq $entry.EditState "Edited" }} ri-checkbox-circle-line{{- else if eq $entry.EditState "Seen" }} ri-information-line{{- else if eq $entry.EditState "Review" }} ri-search-line{{- else if eq $entry.EditState "ToDo" }} ri-list-check{{- else }} ri-forbid-2-line{{- end }}" data-status="{{ $entry.EditState }}"></i>
|
||||||
|
<div class="data-tip">
|
||||||
|
{{- if eq $entry.EditState "Unknown" -}}
|
||||||
|
Gesucht
|
||||||
|
{{- else if eq $entry.EditState "ToDo" -}}
|
||||||
|
Zu erledigen
|
||||||
|
{{- else if eq $entry.EditState "Review" -}}
|
||||||
|
Überprüfen
|
||||||
|
{{- else if eq $entry.EditState "Seen" -}}
|
||||||
|
Autopsiert
|
||||||
|
{{- else if eq $entry.EditState "Edited" -}}
|
||||||
|
Vollständig Erfasst
|
||||||
|
{{- else -}}
|
||||||
|
{{ $entry.EditState }}
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
</tool-tip>
|
||||||
|
</div>
|
||||||
|
{{- if $entry.TitleStmt -}}
|
||||||
|
<div class="text-gray-700 text-base mt-1 leading-snug">
|
||||||
|
{{ $entry.TitleStmt }}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- if or $entry.SubtitleStmt $entry.VariantTitle $entry.ParallelTitle $entry.IncipitStmt -}}
|
||||||
|
<div class="flex flex-col gap-1 text-sm text-gray-700 mt-1">
|
||||||
|
{{- if $entry.SubtitleStmt -}}
|
||||||
|
<div><span class="font-semibold text-gray-500">Untertitel:</span> {{ $entry.SubtitleStmt }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- if $entry.VariantTitle -}}
|
||||||
|
<div><span class="font-semibold text-gray-500">Varianten:</span> {{ $entry.VariantTitle }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- if $entry.ParallelTitle -}}
|
||||||
|
<div><span class="font-semibold text-gray-500">Parallel:</span> {{ $entry.ParallelTitle }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- if $entry.IncipitStmt -}}
|
||||||
|
<div><span class="font-semibold text-gray-500">Incipit:</span> {{ $entry.IncipitStmt }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 col-appearance">
|
||||||
|
{{- if or $entry.ResponsibilityStmt $entry.PublicationStmt $entry.PlaceStmt -}}
|
||||||
|
<div class="flex flex-col gap-2 text-sm text-gray-700">
|
||||||
|
{{- if and $entry.ResponsibilityStmt (not (eq $entry.ResponsibilityStmt "unbezeichnet")) -}}
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold text-gray-500">Herausgaberangabe</div>
|
||||||
|
<div>{{ $entry.ResponsibilityStmt }}</div>
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- if $entry.PublicationStmt -}}
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold text-gray-500">Publikationsangabe</div>
|
||||||
|
<div>{{ $entry.PublicationStmt }}</div>
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- if $entry.PlaceStmt -}}
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold text-gray-500">Ortsangabe</div>
|
||||||
|
<div>{{ $entry.PlaceStmt }}</div>
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 whitespace-nowrap col-year hidden">
|
||||||
|
{{- if ne $entry.Year 0 -}}
|
||||||
|
{{ $entry.Year }}
|
||||||
|
{{- end -}}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 col-language hidden">
|
||||||
|
{{- if $entry.Language -}}
|
||||||
|
{{- range $i, $lang := $entry.Language -}}
|
||||||
|
{{- if $i }}, {{ end -}}{{ LanguageNameGerman $lang }}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 col-extent">
|
||||||
|
{{- if or $entry.Extent $entry.Dimensions -}}
|
||||||
|
<div class="flex flex-col gap-1 text-sm text-gray-700">
|
||||||
|
{{- if $entry.Extent -}}
|
||||||
|
<div><span class="font-semibold text-gray-500 block">Struktur</span>{{ $entry.Extent }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- if $entry.Dimensions -}}
|
||||||
|
<div><span class="font-semibold text-gray-500">Maße:</span> {{ $entry.Dimensions }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 col-signatures">
|
||||||
|
{{- $items := index $model.result.Items $entry.Id -}}
|
||||||
|
{{- if $items -}}
|
||||||
|
<div class="flex flex-col gap-2 text-sm text-gray-700">
|
||||||
|
{{- range $_, $item := $items -}}
|
||||||
|
<div class="inline-flex flex-col items-center justify-center rounded-xs border border-slate-200 bg-white text-xs">
|
||||||
|
{{- if $item.Identifier -}}
|
||||||
|
<div class="px-2 py-1 font-semibold text-slate-900 text-center">{{ $item.Identifier }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- if $item.Media -}}
|
||||||
|
<div class="w-full border-t border-slate-200 px-2 py-1 text-gray-600 text-center leading-snug">
|
||||||
|
{{- range $i, $media := $item.Media -}}{{- if $i }}, {{ end -}}{{ $media }}{{- end -}}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
<!-- Update count display with OOB swap -->
|
||||||
|
<div id="baende-count" class="text-lg font-semibold font-sans text-gray-600 whitespace-nowrap" hx-swap-oob="true">
|
||||||
|
{{ if $model.current_count }}{{ $model.current_count }} / {{ end }}{{ if $model.total_count }}{{ $model.total_count }}{{ else }}{{ len $model.result.Entries }}{{ end }} Bände
|
||||||
|
</div>
|
||||||
@@ -1,11 +1,32 @@
|
|||||||
{{ $model := . }}
|
{{ $model := . }}
|
||||||
|
|
||||||
<div id="baenderesults">
|
<div id="baenderesults"
|
||||||
|
x-data="{ hasMore: {{ if $model.has_more }}true{{ else }}false{{ end }}, offset: {{ if $model.next_offset }}{{ $model.next_offset }}{{ else }}0{{ end }}, loading: false }">
|
||||||
|
|
||||||
<div id="baende-count" class="text-lg font-semibold font-sans text-gray-600 whitespace-nowrap" hx-swap-oob="true">
|
<div id="baende-count" class="text-lg font-semibold font-sans text-gray-600 whitespace-nowrap" hx-swap-oob="true">
|
||||||
{{ len $model.result.Entries }} Bände
|
{{ if $model.current_count }}{{ $model.current_count }} / {{ end }}{{ if $model.total_count }}{{ $model.total_count }}{{ else }}{{ len $model.result.Entries }}{{ end }} Bände
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ template "_baende_table" $model }}
|
{{ template "_baende_table" $model }}
|
||||||
|
|
||||||
|
<!-- Load More Button -->
|
||||||
|
<div class="mt-6 flex justify-center" x-show="hasMore">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="content-action-button"
|
||||||
|
hx-get="/baende/more/?offset={{ if $model.next_offset }}{{ $model.next_offset }}{{ else }}0{{ end }}{{ if $model.search }}&search={{ $model.search }}{{ end }}{{ if $model.letter }}&letter={{ $model.letter }}{{ end }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-push-url="/baende/?offset={{ if $model.next_offset }}{{ $model.next_offset }}{{ else }}0{{ end }}{{ if $model.search }}&search={{ $model.search }}{{ end }}{{ if $model.letter }}&letter={{ $model.letter }}{{ end }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-target="#baende-tbody"
|
||||||
|
hx-swap="beforeend"
|
||||||
|
hx-indicator="this"
|
||||||
|
@htmx:before-request="loading = true"
|
||||||
|
@htmx:after-request="loading = false; hasMore = $event.detail.xhr.getResponseHeader('X-Has-More') === 'true'; offset = {{ if $model.next_offset }}{{ $model.next_offset }}{{ else }}0{{ end }}"
|
||||||
|
:disabled="loading">
|
||||||
|
<i class="ri-arrow-down-line" :class="{ 'spinning': loading }"></i>
|
||||||
|
<span x-text="loading ? 'Lädt...' : 'Weitere 150 laden'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{ if $model.search }}
|
{{ if $model.search }}
|
||||||
<script type="module">
|
<script type="module">
|
||||||
let elements = document.querySelectorAll('.baende-text');
|
let elements = document.querySelectorAll('.baende-text');
|
||||||
|
|||||||
Reference in New Issue
Block a user