mirror of
https://github.com/Theodor-Springmann-Stiftung/musenalm.git
synced 2026-02-04 02:25:30 +00:00
+Benutzer filter, u. Spalte
This commit is contained in:
29
app/pb.go
29
app/pb.go
@@ -55,6 +55,7 @@ type BaendeCache struct {
|
|||||||
Agents map[string]*dbmodels.Agent
|
Agents map[string]*dbmodels.Agent
|
||||||
EntriesAgents map[string][]*dbmodels.REntriesAgents
|
EntriesAgents map[string][]*dbmodels.REntriesAgents
|
||||||
Items map[string][]*dbmodels.Item
|
Items map[string][]*dbmodels.Item
|
||||||
|
Users map[string]*dbmodels.User
|
||||||
CachedAt time.Time
|
CachedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +88,9 @@ func (bc *BaendeCache) GetItems() interface{} {
|
|||||||
return bc.Items
|
return bc.Items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (bc *BaendeCache) GetUsers() interface{} {
|
||||||
|
return bc.Users
|
||||||
|
}
|
||||||
const (
|
const (
|
||||||
TEST_SUPERUSER_MAIL = "demo@example.com"
|
TEST_SUPERUSER_MAIL = "demo@example.com"
|
||||||
TEST_SUPERUSER_PASS = "password"
|
TEST_SUPERUSER_PASS = "password"
|
||||||
@@ -661,6 +665,30 @@ func (app *App) EnsureBaendeCache() (*BaendeCache, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load users (editors)
|
||||||
|
usersMap := map[string]*dbmodels.User{}
|
||||||
|
editorIDs := map[string]struct{}{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if editorID := entry.Editor(); editorID != "" {
|
||||||
|
editorIDs[editorID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(editorIDs) > 0 {
|
||||||
|
userIDs := make([]any, 0, len(editorIDs))
|
||||||
|
for editorID := range editorIDs {
|
||||||
|
userIDs = append(userIDs, editorID)
|
||||||
|
}
|
||||||
|
users, err := dbmodels.TableByIDs[*dbmodels.User](app.PB.App, dbmodels.USERS_TABLE, userIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
if user != nil {
|
||||||
|
usersMap[user.Id] = user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.baendeCache = &BaendeCache{
|
app.baendeCache = &BaendeCache{
|
||||||
Entries: entries,
|
Entries: entries,
|
||||||
Series: seriesMap,
|
Series: seriesMap,
|
||||||
@@ -669,6 +697,7 @@ func (app *App) EnsureBaendeCache() (*BaendeCache, error) {
|
|||||||
Agents: agentsMap,
|
Agents: agentsMap,
|
||||||
EntriesAgents: entryAgentsMap,
|
EntriesAgents: entryAgentsMap,
|
||||||
Items: itemsMap,
|
Items: itemsMap,
|
||||||
|
Users: usersMap,
|
||||||
CachedAt: time.Now(),
|
CachedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ type BaendeResult struct {
|
|||||||
Agents map[string]*dbmodels.Agent
|
Agents map[string]*dbmodels.Agent
|
||||||
EntriesAgents map[string][]*dbmodels.REntriesAgents
|
EntriesAgents map[string][]*dbmodels.REntriesAgents
|
||||||
Items map[string][]*dbmodels.Item
|
Items map[string][]*dbmodels.Item
|
||||||
|
Users map[string]*dbmodels.User
|
||||||
}
|
}
|
||||||
|
|
||||||
type BaendeDetailsResult struct {
|
type BaendeDetailsResult struct {
|
||||||
@@ -133,11 +134,22 @@ func (p *BaendePage) handleRow(engine *templating.Engine, app core.App) HandleFu
|
|||||||
app.Logger().Error("Failed to get items for entry", "error", err)
|
app.Logger().Error("Failed to get items for entry", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var editorUser *dbmodels.User
|
||||||
|
if editorID := entry.Editor(); editorID != "" {
|
||||||
|
user, err := dbmodels.Users_ID(app, editorID)
|
||||||
|
if err != nil {
|
||||||
|
app.Logger().Error("Failed to get editor user for entry", "error", err)
|
||||||
|
} else {
|
||||||
|
editorUser = user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"entry": entry,
|
"entry": entry,
|
||||||
"items": items,
|
"items": items,
|
||||||
"is_admin": req.IsAdmin(),
|
"editor_user": editorUser,
|
||||||
"csrf_token": req.Session().Token,
|
"is_admin": req.IsAdmin(),
|
||||||
|
"csrf_token": req.Session().Token,
|
||||||
}
|
}
|
||||||
|
|
||||||
return engine.Response200(e, "/baende/row/", data, "fragment")
|
return engine.Response200(e, "/baende/row/", data, "fragment")
|
||||||
@@ -225,6 +237,7 @@ func (p *BaendePage) buildResultData(app core.App, ma pagemodels.IApp, e *core.R
|
|||||||
letter := strings.ToUpper(strings.TrimSpace(e.Request.URL.Query().Get("letter")))
|
letter := strings.ToUpper(strings.TrimSpace(e.Request.URL.Query().Get("letter")))
|
||||||
status := strings.TrimSpace(e.Request.URL.Query().Get("status"))
|
status := strings.TrimSpace(e.Request.URL.Query().Get("status"))
|
||||||
person := strings.TrimSpace(e.Request.URL.Query().Get("person"))
|
person := strings.TrimSpace(e.Request.URL.Query().Get("person"))
|
||||||
|
user := strings.TrimSpace(e.Request.URL.Query().Get("user"))
|
||||||
yearStr := strings.TrimSpace(e.Request.URL.Query().Get("year"))
|
yearStr := strings.TrimSpace(e.Request.URL.Query().Get("year"))
|
||||||
place := strings.TrimSpace(e.Request.URL.Query().Get("place"))
|
place := strings.TrimSpace(e.Request.URL.Query().Get("place"))
|
||||||
|
|
||||||
@@ -250,6 +263,7 @@ func (p *BaendePage) buildResultData(app core.App, ma pagemodels.IApp, e *core.R
|
|||||||
"signatur": true,
|
"signatur": true,
|
||||||
"responsibility": true,
|
"responsibility": true,
|
||||||
"place": true,
|
"place": true,
|
||||||
|
"updated": true,
|
||||||
}
|
}
|
||||||
if !validSorts[sort] {
|
if !validSorts[sort] {
|
||||||
sort = "title" // default
|
sort = "title" // default
|
||||||
@@ -302,6 +316,11 @@ func (p *BaendePage) buildResultData(app core.App, ma pagemodels.IApp, e *core.R
|
|||||||
return data, fmt.Errorf("failed to get entries agents from cache")
|
return data, fmt.Errorf("failed to get entries agents from cache")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usersMap, ok := cacheInterface.GetUsers().(map[string]*dbmodels.User)
|
||||||
|
if !ok {
|
||||||
|
return data, fmt.Errorf("failed to get users from cache")
|
||||||
|
}
|
||||||
|
|
||||||
// Determine active filter (only one at a time)
|
// Determine active filter (only one at a time)
|
||||||
activeFilterType := ""
|
activeFilterType := ""
|
||||||
activeFilterValue := ""
|
activeFilterValue := ""
|
||||||
@@ -310,11 +329,18 @@ func (p *BaendePage) buildResultData(app core.App, ma pagemodels.IApp, e *core.R
|
|||||||
activeFilterType = "status"
|
activeFilterType = "status"
|
||||||
activeFilterValue = status
|
activeFilterValue = status
|
||||||
person = ""
|
person = ""
|
||||||
|
user = ""
|
||||||
yearStr = ""
|
yearStr = ""
|
||||||
place = ""
|
place = ""
|
||||||
case person != "":
|
case person != "":
|
||||||
activeFilterType = "person"
|
activeFilterType = "person"
|
||||||
activeFilterValue = person
|
activeFilterValue = person
|
||||||
|
user = ""
|
||||||
|
yearStr = ""
|
||||||
|
place = ""
|
||||||
|
case user != "":
|
||||||
|
activeFilterType = "user"
|
||||||
|
activeFilterValue = user
|
||||||
yearStr = ""
|
yearStr = ""
|
||||||
place = ""
|
place = ""
|
||||||
case yearStr != "":
|
case yearStr != "":
|
||||||
@@ -338,6 +364,8 @@ func (p *BaendePage) buildResultData(app core.App, ma pagemodels.IApp, e *core.R
|
|||||||
filteredEntries = filterEntriesByStatus(allEntries, status)
|
filteredEntries = filterEntriesByStatus(allEntries, status)
|
||||||
case "person":
|
case "person":
|
||||||
filteredEntries = filterEntriesByAgent(allEntries, entryAgentsMap, person)
|
filteredEntries = filterEntriesByAgent(allEntries, entryAgentsMap, person)
|
||||||
|
case "user":
|
||||||
|
filteredEntries = filterEntriesByEditor(allEntries, user)
|
||||||
case "year":
|
case "year":
|
||||||
yearVal, err := strconv.Atoi(yearStr)
|
yearVal, err := strconv.Atoi(yearStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -379,6 +407,8 @@ func (p *BaendePage) buildResultData(app core.App, ma pagemodels.IApp, e *core.R
|
|||||||
dbmodels.Sort_Entries_Responsibility_Title(filteredEntries)
|
dbmodels.Sort_Entries_Responsibility_Title(filteredEntries)
|
||||||
case "place":
|
case "place":
|
||||||
dbmodels.Sort_Entries_Place_Title(filteredEntries)
|
dbmodels.Sort_Entries_Place_Title(filteredEntries)
|
||||||
|
case "updated":
|
||||||
|
dbmodels.Sort_Entries_Updated(filteredEntries)
|
||||||
default: // "title"
|
default: // "title"
|
||||||
dbmodels.Sort_Entries_Title_Year(filteredEntries)
|
dbmodels.Sort_Entries_Title_Year(filteredEntries)
|
||||||
}
|
}
|
||||||
@@ -434,6 +464,7 @@ func (p *BaendePage) buildResultData(app core.App, ma pagemodels.IApp, e *core.R
|
|||||||
Agents: agentsMap,
|
Agents: agentsMap,
|
||||||
EntriesAgents: entryAgentsMap,
|
EntriesAgents: entryAgentsMap,
|
||||||
Items: itemsMap,
|
Items: itemsMap,
|
||||||
|
Users: usersMap,
|
||||||
}
|
}
|
||||||
data["offset"] = offset
|
data["offset"] = offset
|
||||||
data["total_count"] = totalCount
|
data["total_count"] = totalCount
|
||||||
@@ -459,6 +490,8 @@ func (p *BaendePage) buildResultData(app core.App, ma pagemodels.IApp, e *core.R
|
|||||||
data["filter_status_labels"] = buildStatusLabelMap()
|
data["filter_status_labels"] = buildStatusLabelMap()
|
||||||
data["filter_agents"] = buildAgentFilters(agentsMap)
|
data["filter_agents"] = buildAgentFilters(agentsMap)
|
||||||
data["filter_agent_labels"] = buildAgentLabelMap(agentsMap)
|
data["filter_agent_labels"] = buildAgentLabelMap(agentsMap)
|
||||||
|
data["filter_users"] = buildUserFilters(usersMap)
|
||||||
|
data["filter_user_labels"] = buildUserLabelMap(usersMap)
|
||||||
data["filter_places"] = buildPlaceFilters(placesMap)
|
data["filter_places"] = buildPlaceFilters(placesMap)
|
||||||
data["filter_place_labels"] = buildPlaceLabelMap(placesMap)
|
data["filter_place_labels"] = buildPlaceLabelMap(placesMap)
|
||||||
data["filter_years"] = buildYearFilters(allEntries)
|
data["filter_years"] = buildYearFilters(allEntries)
|
||||||
@@ -749,6 +782,19 @@ func filterEntriesByPlace(entries []*dbmodels.Entry, placeID string) []*dbmodels
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterEntriesByEditor(entries []*dbmodels.Entry, userID string) []*dbmodels.Entry {
|
||||||
|
if userID == "" {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
results := make([]*dbmodels.Entry, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Editor() == userID {
|
||||||
|
results = append(results, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
func buildStatusFilters() []map[string]string {
|
func buildStatusFilters() []map[string]string {
|
||||||
labels := buildStatusLabelMap()
|
labels := buildStatusLabelMap()
|
||||||
allowed := []string{"Unknown", "ToDo", "Review", "Seen", "Edited"}
|
allowed := []string{"Unknown", "ToDo", "Review", "Seen", "Edited"}
|
||||||
@@ -795,6 +841,25 @@ func buildAgentLabelMap(agentsMap map[string]*dbmodels.Agent) map[string]string
|
|||||||
return labels
|
return labels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildUserFilters(usersMap map[string]*dbmodels.User) []*dbmodels.User {
|
||||||
|
users := make([]*dbmodels.User, 0, len(usersMap))
|
||||||
|
for _, user := range usersMap {
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
dbmodels.Sort_Users_Name(users)
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildUserLabelMap(usersMap map[string]*dbmodels.User) map[string]string {
|
||||||
|
labels := make(map[string]string, len(usersMap))
|
||||||
|
for id, user := range usersMap {
|
||||||
|
if user != nil {
|
||||||
|
labels[id] = user.Name()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
func buildPlaceFilters(placesMap map[string]*dbmodels.Place) []*dbmodels.Place {
|
func buildPlaceFilters(placesMap map[string]*dbmodels.Place) []*dbmodels.Place {
|
||||||
places := make([]*dbmodels.Place, 0, len(placesMap))
|
places := make([]*dbmodels.Place, 0, len(placesMap))
|
||||||
for _, place := range placesMap {
|
for _, place := range placesMap {
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ func Sort_Agents_Name(agents []*Agent) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Sort_Users_Name(users []*User) {
|
||||||
|
collator := collate.New(language.German)
|
||||||
|
slices.SortFunc(users, func(i, j *User) int {
|
||||||
|
return collator.CompareString(i.Name(), j.Name())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func Sort_Entries_Title_Year(entries []*Entry) {
|
func Sort_Entries_Title_Year(entries []*Entry) {
|
||||||
collator := collate.New(language.German)
|
collator := collate.New(language.German)
|
||||||
slices.SortFunc(entries, func(i, j *Entry) int {
|
slices.SortFunc(entries, func(i, j *Entry) int {
|
||||||
@@ -161,3 +168,19 @@ func Sort_Entries_Place_Title(entries []*Entry) {
|
|||||||
return collator.CompareString(iPlace, jPlace)
|
return collator.CompareString(iPlace, jPlace)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort_Entries_Updated sorts entries by updated timestamp, then preferred title.
|
||||||
|
func Sort_Entries_Updated(entries []*Entry) {
|
||||||
|
collator := collate.New(language.German)
|
||||||
|
slices.SortFunc(entries, func(i, j *Entry) int {
|
||||||
|
iUpdated := i.Updated().Time()
|
||||||
|
jUpdated := j.Updated().Time()
|
||||||
|
if iUpdated.Equal(jUpdated) {
|
||||||
|
return collator.CompareString(i.PreferredTitle(), j.PreferredTitle())
|
||||||
|
}
|
||||||
|
if iUpdated.Before(jUpdated) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,7 +55,15 @@ func CleanupExpired(app core.App) {
|
|||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
filename := record.GetString(dbmodels.EXPORT_FILENAME_FIELD)
|
filename := record.GetString(dbmodels.EXPORT_FILENAME_FIELD)
|
||||||
if filename == "" {
|
if filename == "" {
|
||||||
filename = record.Id + ".xml"
|
exportType := record.GetString(dbmodels.EXPORT_TYPE_FIELD)
|
||||||
|
if exportType == "" {
|
||||||
|
exportType = dbmodels.EXPORT_TYPE_DATA
|
||||||
|
}
|
||||||
|
kind := "data"
|
||||||
|
if exportType == dbmodels.EXPORT_TYPE_FILES {
|
||||||
|
kind = "files"
|
||||||
|
}
|
||||||
|
filename = buildExportFilename(kind, record.Id)
|
||||||
}
|
}
|
||||||
filename = filepath.Base(filename)
|
filename = filepath.Base(filename)
|
||||||
_ = os.Remove(filepath.Join(exportDir, filename))
|
_ = os.Remove(filepath.Join(exportDir, filename))
|
||||||
|
|||||||
@@ -91,3 +91,13 @@ func GermanTime(t types.DateTime) string {
|
|||||||
time := t.Time().In(location)
|
time := t.Time().In(location)
|
||||||
return fmt.Sprintf("%02d:%02d", time.Hour(), time.Minute())
|
return fmt.Sprintf("%02d:%02d", time.Hour(), time.Minute())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GermanShortDateTime(t types.DateTime) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return "N/A"
|
||||||
|
}
|
||||||
|
|
||||||
|
location, _ := time.LoadLocation("Europe/Berlin")
|
||||||
|
ts := t.Time().In(location)
|
||||||
|
return ts.Format("02.01.06 15:04")
|
||||||
|
}
|
||||||
|
|||||||
88
helpers/imports/common.go
Normal file
88
helpers/imports/common.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package imports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const importDirName = "import"
|
||||||
|
|
||||||
|
var importNamePattern = regexp.MustCompile(`^(\d{4})-(\d{2})(?:-(\d{2}))?-MUSENALM-(DATA|FILES)`)
|
||||||
|
|
||||||
|
type ImportCandidate struct {
|
||||||
|
Path string
|
||||||
|
IsZip bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindLatestImport(kind string) (*ImportCandidate, error) {
|
||||||
|
dirEntries, err := os.ReadDir(importDirName)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "MUSENALM-" + strings.ToUpper(kind)
|
||||||
|
type candidate struct {
|
||||||
|
name string
|
||||||
|
ts time.Time
|
||||||
|
}
|
||||||
|
candidates := []candidate{}
|
||||||
|
for _, entry := range dirEntries {
|
||||||
|
name := entry.Name()
|
||||||
|
matches := importNamePattern.FindStringSubmatch(name)
|
||||||
|
if matches == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToUpper(name), want) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
year := matches[1]
|
||||||
|
month := matches[2]
|
||||||
|
day := matches[3]
|
||||||
|
if day == "" {
|
||||||
|
day = "01"
|
||||||
|
}
|
||||||
|
ts, err := time.Parse("2006-01-02", year+"-"+month+"-"+day)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidates = append(candidates, candidate{name: name, ts: ts})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(candidates, func(i, j int) bool {
|
||||||
|
if candidates[i].ts.Equal(candidates[j].ts) {
|
||||||
|
return candidates[i].name < candidates[j].name
|
||||||
|
}
|
||||||
|
return candidates[i].ts.Before(candidates[j].ts)
|
||||||
|
})
|
||||||
|
latest := candidates[len(candidates)-1].name
|
||||||
|
path := filepath.Join(importDirName, latest)
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return &ImportCandidate{Path: path, IsZip: false}, nil
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(strings.ToLower(latest), ".zip") {
|
||||||
|
return &ImportCandidate{Path: path, IsZip: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ImportCandidate{Path: path, IsZip: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HasImport(kind string) bool {
|
||||||
|
candidate, err := FindLatestImport(kind)
|
||||||
|
return err == nil && candidate != nil
|
||||||
|
}
|
||||||
252
helpers/imports/data.go
Normal file
252
helpers/imports/data.go
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
package imports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tableFile struct {
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
Zip *zip.File
|
||||||
|
}
|
||||||
|
|
||||||
|
type columnValue struct {
|
||||||
|
Value string
|
||||||
|
IsNull bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func ImportData(app core.App, candidate *ImportCandidate) error {
|
||||||
|
if candidate == nil {
|
||||||
|
return fmt.Errorf("no data import candidate found")
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := listDataFiles(candidate)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
return fmt.Errorf("no XML files found in data import")
|
||||||
|
}
|
||||||
|
|
||||||
|
tableOrder := make([]string, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
tableOrder = append(tableOrder, file.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.RunInTransaction(func(txApp core.App) error {
|
||||||
|
for i := len(tableOrder) - 1; i >= 0; i-- {
|
||||||
|
if shouldSkipImportTable(tableOrder[i]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := txApp.DB().NewQuery("DELETE FROM " + quoteTableName(tableOrder[i])).Execute(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if shouldSkipImportTable(file.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, closeFn, err := openTableReader(file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := importTableXML(txApp, reader); err != nil {
|
||||||
|
closeFn()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
closeFn()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func listDataFiles(candidate *ImportCandidate) ([]tableFile, error) {
|
||||||
|
files := []tableFile{}
|
||||||
|
if candidate.IsZip {
|
||||||
|
reader, err := zip.OpenReader(candidate.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
for _, file := range reader.File {
|
||||||
|
if file.FileInfo().IsDir() || !strings.HasSuffix(strings.ToLower(file.Name), ".xml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, tableFile{
|
||||||
|
Name: strings.TrimSuffix(filepath.Base(file.Name), ".xml"),
|
||||||
|
Zip: file,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entries, err := os.ReadDir(candidate.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasSuffix(strings.ToLower(name), ".xml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, tableFile{
|
||||||
|
Name: strings.TrimSuffix(name, ".xml"),
|
||||||
|
Path: filepath.Join(candidate.Path, name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
return files[i].Name < files[j].Name
|
||||||
|
})
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openTableReader(file tableFile) (io.ReadCloser, func(), error) {
|
||||||
|
if file.Zip != nil {
|
||||||
|
reader, err := file.Zip.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, func() {}, err
|
||||||
|
}
|
||||||
|
return reader, func() { _ = reader.Close() }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := os.Open(file.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, func() {}, err
|
||||||
|
}
|
||||||
|
return reader, func() { _ = reader.Close() }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func importTableXML(app core.App, reader io.Reader) error {
|
||||||
|
decoder := xml.NewDecoder(reader)
|
||||||
|
var tableName string
|
||||||
|
inRow := false
|
||||||
|
rowValues := map[string]columnValue{}
|
||||||
|
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch t := token.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
if t.Name.Local == "table" {
|
||||||
|
for _, attr := range t.Attr {
|
||||||
|
if attr.Name.Local == "name" {
|
||||||
|
tableName = strings.TrimSpace(attr.Value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tableName == "" {
|
||||||
|
return fmt.Errorf("missing table name in XML")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.Name.Local == "row" {
|
||||||
|
inRow = true
|
||||||
|
rowValues = map[string]columnValue{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if inRow {
|
||||||
|
colName := t.Name.Local
|
||||||
|
isNull := hasNullAttr(t.Attr)
|
||||||
|
var text string
|
||||||
|
if err := decoder.DecodeElement(&text, &t); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rowValues[colName] = columnValue{
|
||||||
|
Value: text,
|
||||||
|
IsNull: isNull,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case xml.EndElement:
|
||||||
|
if t.Name.Local == "row" && inRow {
|
||||||
|
if err := insertRow(app, tableName, rowValues); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
inRow = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertRow(app core.App, tableName string, row map[string]columnValue) error {
|
||||||
|
if len(row) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cols := make([]string, 0, len(row))
|
||||||
|
placeholders := make([]string, 0, len(row))
|
||||||
|
params := dbx.Params{}
|
||||||
|
|
||||||
|
idx := 0
|
||||||
|
for col, value := range row {
|
||||||
|
cols = append(cols, quoteIdentifier(col))
|
||||||
|
paramName := fmt.Sprintf("p%d", idx)
|
||||||
|
placeholders = append(placeholders, "{:"+paramName+"}")
|
||||||
|
if value.IsNull {
|
||||||
|
params[paramName] = nil
|
||||||
|
} else {
|
||||||
|
params[paramName] = value.Value
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "INSERT INTO " + quoteTableName(tableName) +
|
||||||
|
" (" + strings.Join(cols, ", ") + ") VALUES (" + strings.Join(placeholders, ", ") + ")"
|
||||||
|
_, err := app.DB().NewQuery(query).Bind(params).Execute()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasNullAttr(attrs []xml.Attr) bool {
|
||||||
|
for _, attr := range attrs {
|
||||||
|
if attr.Name.Local == "null" && strings.EqualFold(attr.Value, "true") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSkipImportTable(name string) bool {
|
||||||
|
if name == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, "_") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case "schema_migrations":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteTableName(name string) string {
|
||||||
|
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteIdentifier(name string) string {
|
||||||
|
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
|
||||||
|
}
|
||||||
104
helpers/imports/files.go
Normal file
104
helpers/imports/files.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package imports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ImportFiles(app core.App, candidate *ImportCandidate) error {
|
||||||
|
if candidate == nil {
|
||||||
|
return fmt.Errorf("no file import candidate found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if candidate.IsZip {
|
||||||
|
return importFilesFromZip(app, candidate.Path)
|
||||||
|
}
|
||||||
|
return importFilesFromDir(app, candidate.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func importFilesFromZip(app core.App, zipPath string) error {
|
||||||
|
reader, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
for _, file := range reader.File {
|
||||||
|
if file.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reader, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := importFileEntry(app, file.Name, reader); err != nil {
|
||||||
|
_ = reader.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = reader.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func importFilesFromDir(app core.App, dirPath string) error {
|
||||||
|
return filepath.WalkDir(dirPath, func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
relPath, err := filepath.Rel(dirPath, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reader, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
return importFileEntry(app, relPath, reader)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func importFileEntry(app core.App, relPath string, reader io.Reader) error {
|
||||||
|
parts := strings.Split(filepath.ToSlash(relPath), "/")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionName := parts[0]
|
||||||
|
recordId := parts[1]
|
||||||
|
filename := parts[len(parts)-1]
|
||||||
|
if collectionName == "" || recordId == "" || filename == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
collection, err := app.FindCollectionByNameOrId(collectionName)
|
||||||
|
if err != nil || collection == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
destDir := filepath.Join(app.DataDir(), "storage", collection.Id, recordId)
|
||||||
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
destPath := filepath.Join(destDir, filename)
|
||||||
|
|
||||||
|
dest, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dest.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(dest, reader)
|
||||||
|
return err
|
||||||
|
}
|
||||||
138
helpers/imports/fts.go
Normal file
138
helpers/imports/fts.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package imports
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RebuildFTS(app core.App) error {
|
||||||
|
if err := dbmodels.DeleteFTS5Data(app); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
places := []*dbmodels.Place{}
|
||||||
|
if err := app.RecordQuery(dbmodels.PLACES_TABLE).All(&places); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
agents := []*dbmodels.Agent{}
|
||||||
|
if err := app.RecordQuery(dbmodels.AGENTS_TABLE).All(&agents); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
series := []*dbmodels.Series{}
|
||||||
|
if err := app.RecordQuery(dbmodels.SERIES_TABLE).All(&series); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items := []*dbmodels.Item{}
|
||||||
|
if err := app.RecordQuery(dbmodels.ITEMS_TABLE).All(&items); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries := []*dbmodels.Entry{}
|
||||||
|
if err := app.RecordQuery(dbmodels.ENTRIES_TABLE).All(&entries); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
contents := []*dbmodels.Content{}
|
||||||
|
if err := app.RecordQuery(dbmodels.CONTENTS_TABLE).All(&contents); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
entriesSeries := []*dbmodels.REntriesSeries{}
|
||||||
|
if err := app.RecordQuery(dbmodels.RelationTableName(dbmodels.ENTRIES_TABLE, dbmodels.SERIES_TABLE)).All(&entriesSeries); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entriesAgents := []*dbmodels.REntriesAgents{}
|
||||||
|
if err := app.RecordQuery(dbmodels.RelationTableName(dbmodels.ENTRIES_TABLE, dbmodels.AGENTS_TABLE)).All(&entriesAgents); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
contentsAgents := []*dbmodels.RContentsAgents{}
|
||||||
|
if err := app.RecordQuery(dbmodels.RelationTableName(dbmodels.CONTENTS_TABLE, dbmodels.AGENTS_TABLE)).All(&contentsAgents); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
placesById := map[string]*dbmodels.Place{}
|
||||||
|
for _, place := range places {
|
||||||
|
placesById[place.Id] = place
|
||||||
|
}
|
||||||
|
agentsById := map[string]*dbmodels.Agent{}
|
||||||
|
for _, agent := range agents {
|
||||||
|
agentsById[agent.Id] = agent
|
||||||
|
}
|
||||||
|
seriesById := map[string]*dbmodels.Series{}
|
||||||
|
for _, s := range series {
|
||||||
|
seriesById[s.Id] = s
|
||||||
|
}
|
||||||
|
entriesById := map[string]*dbmodels.Entry{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
entriesById[entry.Id] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
entriesSeriesMap := map[string][]*dbmodels.Series{}
|
||||||
|
for _, rel := range entriesSeries {
|
||||||
|
if series := seriesById[rel.Series()]; series != nil {
|
||||||
|
entriesSeriesMap[rel.Entry()] = append(entriesSeriesMap[rel.Entry()], series)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entriesAgentsMap := map[string][]*dbmodels.Agent{}
|
||||||
|
for _, rel := range entriesAgents {
|
||||||
|
if agent := agentsById[rel.Agent()]; agent != nil {
|
||||||
|
entriesAgentsMap[rel.Entry()] = append(entriesAgentsMap[rel.Entry()], agent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentsAgentsMap := map[string][]*dbmodels.Agent{}
|
||||||
|
for _, rel := range contentsAgents {
|
||||||
|
if agent := agentsById[rel.Agent()]; agent != nil {
|
||||||
|
contentsAgentsMap[rel.Content()] = append(contentsAgentsMap[rel.Content()], agent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
qp := dbmodels.FTS5InsertQuery(app, dbmodels.PLACES_TABLE, dbmodels.PLACES_FTS5_FIELDS)
|
||||||
|
qa := dbmodels.FTS5InsertQuery(app, dbmodels.AGENTS_TABLE, dbmodels.AGENTS_FTS5_FIELDS)
|
||||||
|
qs := dbmodels.FTS5InsertQuery(app, dbmodels.SERIES_TABLE, dbmodels.SERIES_FTS5_FIELDS)
|
||||||
|
qi := dbmodels.FTS5InsertQuery(app, dbmodels.ITEMS_TABLE, dbmodels.ITEMS_FTS5_FIELDS)
|
||||||
|
qe := dbmodels.FTS5InsertQuery(app, dbmodels.ENTRIES_TABLE, dbmodels.ENTRIES_FTS5_FIELDS)
|
||||||
|
qc := dbmodels.FTS5InsertQuery(app, dbmodels.CONTENTS_TABLE, dbmodels.CONTENTS_FTS5_FIELDS)
|
||||||
|
|
||||||
|
for _, place := range places {
|
||||||
|
if err := dbmodels.BulkInsertFTS5Place(qp, place); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, agent := range agents {
|
||||||
|
if err := dbmodels.BulkInsertFTS5Agent(qa, agent); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range series {
|
||||||
|
if err := dbmodels.BulkInsertFTS5Series(qs, s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if err := dbmodels.BulkInsertFTS5Item(qi, item); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
entryPlaces := []*dbmodels.Place{}
|
||||||
|
for _, placeId := range entry.Places() {
|
||||||
|
if place := placesById[placeId]; place != nil {
|
||||||
|
entryPlaces = append(entryPlaces, place)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entryAgents := entriesAgentsMap[entry.Id]
|
||||||
|
entrySeries := entriesSeriesMap[entry.Id]
|
||||||
|
if err := dbmodels.BulkInsertFTS5Entry(qe, entry, entryPlaces, entryAgents, entrySeries); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, content := range contents {
|
||||||
|
entry := entriesById[content.Entry()]
|
||||||
|
contentAgents := contentsAgentsMap[content.Id]
|
||||||
|
if err := dbmodels.BulkInsertFTS5Content(qc, content, entry, contentAgents); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/datatypes"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/datatypes"
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/imports"
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/migrations/seed"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/migrations/seed"
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/xmlmodels"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/xmlmodels"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -14,6 +15,16 @@ import (
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
m.Register(func(app core.App) error {
|
m.Register(func(app core.App) error {
|
||||||
|
if candidate, err := imports.FindLatestImport("data"); err != nil {
|
||||||
|
return err
|
||||||
|
} else if candidate != nil {
|
||||||
|
app.Logger().Info("Importing Musenalm data from export", "path", candidate.Path)
|
||||||
|
if err := imports.ImportData(app, candidate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return imports.RebuildFTS(app)
|
||||||
|
}
|
||||||
|
|
||||||
adb, err := xmlmodels.ReadAccessDB(xmlmodels.DATA_PATH, app.Logger())
|
adb, err := xmlmodels.ReadAccessDB(xmlmodels.DATA_PATH, app.Logger())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||||
|
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/imports"
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
|
||||||
"github.com/Theodor-Springmann-Stiftung/musenalm/xmlmodels"
|
"github.com/Theodor-Springmann-Stiftung/musenalm/xmlmodels"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
@@ -653,8 +654,8 @@ const (
|
|||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
KABINETT_TITLE = "Lesekabinett"
|
KABINETT_TITLE = "Lesekabinett"
|
||||||
KABINETT_DESCRIPTION = "Musenalm: Verzeichnis deutschsprachiger Almanache des 18. und 19. Jahrhunderts. Historische Texte zum Almanachwesen."
|
KABINETT_DESCRIPTION = "Musenalm: Verzeichnis deutschsprachiger Almanache des 18. und 19. Jahrhunderts. Historische Texte zum Almanachwesen."
|
||||||
LESEKABINETT_FILES_PATH = "./views/public/Lesekabinett"
|
LESEKABINETT_FILES_PATH = "./views/public/Lesekabinett"
|
||||||
|
|
||||||
ABKUERZUNGEN_PATH = "./import/data/abkuerzungen.txt"
|
ABKUERZUNGEN_PATH = "./import/data/abkuerzungen.txt"
|
||||||
@@ -806,6 +807,19 @@ func pageHTMLSeed(kabinetText string) map[string]string {
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
m.Register(func(app core.App) error {
|
m.Register(func(app core.App) error {
|
||||||
|
if candidate, err := imports.FindLatestImport("data"); err != nil {
|
||||||
|
return err
|
||||||
|
} else if candidate != nil {
|
||||||
|
if filesCandidate, err := imports.FindLatestImport("files"); err != nil {
|
||||||
|
return err
|
||||||
|
} else if filesCandidate != nil {
|
||||||
|
app.Logger().Info("Importing Musenalm files from export", "path", filesCandidate.Path)
|
||||||
|
return imports.ImportFiles(app, filesCandidate)
|
||||||
|
}
|
||||||
|
app.Logger().Info("Skipping page seed because data import exists", "path", candidate.Path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
kabinetUrls, err := seedLesekabinettFiles(app)
|
kabinetUrls, err := seedLesekabinettFiles(app)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type BaendeCacheInterface interface {
|
|||||||
GetAgents() interface{} // Returns map[string]*dbmodels.Agent
|
GetAgents() interface{} // Returns map[string]*dbmodels.Agent
|
||||||
GetEntriesAgents() interface{} // Returns map[string][]*dbmodels.REntriesAgents
|
GetEntriesAgents() interface{} // Returns map[string][]*dbmodels.REntriesAgents
|
||||||
GetItems() interface{} // Returns map[string][]*dbmodels.Item
|
GetItems() interface{} // Returns map[string][]*dbmodels.Item
|
||||||
|
GetUsers() interface{} // Returns map[string]*dbmodels.User
|
||||||
}
|
}
|
||||||
|
|
||||||
type IApp interface {
|
type IApp interface {
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ func (e *Engine) funcs() error {
|
|||||||
e.AddFunc("Len", functions.Length)
|
e.AddFunc("Len", functions.Length)
|
||||||
e.AddFunc("GermanDate", functions.GermanDate)
|
e.AddFunc("GermanDate", functions.GermanDate)
|
||||||
e.AddFunc("GermanTime", functions.GermanTime)
|
e.AddFunc("GermanTime", functions.GermanTime)
|
||||||
|
e.AddFunc("GermanShortDateTime", functions.GermanShortDateTime)
|
||||||
|
|
||||||
// String Functions
|
// String Functions
|
||||||
e.AddFunc("Lower", functions.Lower)
|
e.AddFunc("Lower", functions.Lower)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -21,6 +21,11 @@
|
|||||||
{{ printf "%q" $k }}: {{ printf "%q" $v }},
|
{{ printf "%q" $k }}: {{ printf "%q" $v }},
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
},
|
},
|
||||||
|
userLabels: {
|
||||||
|
{{- range $k, $v := $model.filter_user_labels -}}
|
||||||
|
{{ printf "%q" $k }}: {{ printf "%q" $v }},
|
||||||
|
{{- end -}}
|
||||||
|
},
|
||||||
placeLabels: {
|
placeLabels: {
|
||||||
{{- range $k, $v := $model.filter_place_labels -}}
|
{{- range $k, $v := $model.filter_place_labels -}}
|
||||||
{{ printf "%q" $k }}: {{ printf "%q" $v }},
|
{{ printf "%q" $k }}: {{ printf "%q" $v }},
|
||||||
@@ -117,7 +122,7 @@
|
|||||||
const params = new URL(responseUrl).searchParams;
|
const params = new URL(responseUrl).searchParams;
|
||||||
sortField = params.get('sort') || sortField;
|
sortField = params.get('sort') || sortField;
|
||||||
sortOrder = params.get('order') || sortOrder;
|
sortOrder = params.get('order') || sortOrder;
|
||||||
const filterKeys = ['status', 'person', 'year', 'place'];
|
const filterKeys = ['status', 'person', 'user', 'year', 'place'];
|
||||||
activeFilterType = '';
|
activeFilterType = '';
|
||||||
activeFilterValue = '';
|
activeFilterValue = '';
|
||||||
filterKeys.some((key) => {
|
filterKeys.some((key) => {
|
||||||
@@ -144,9 +149,9 @@ class="container-normal font-sans mt-10">
|
|||||||
<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="border-b px-3 border-zinc-300">
|
||||||
<!-- Left side group: Search and Alphabet -->
|
<!-- Row 1: Search and Filters -->
|
||||||
<div class="flex items-end gap-4">
|
<div class="flex flex-wrap items-end justify-start min-h-14 gap-x-2 gap-y-3 pb-3">
|
||||||
<!-- Search box -->
|
<!-- 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>
|
||||||
@@ -157,7 +162,7 @@ class="container-normal font-sans mt-10">
|
|||||||
method="GET"
|
method="GET"
|
||||||
action="/baende/"
|
action="/baende/"
|
||||||
hx-get="/baende/results/"
|
hx-get="/baende/results/"
|
||||||
hx-indicator="body"
|
hx-indicator="#baende-search-spinner"
|
||||||
hx-push-url="false"
|
hx-push-url="false"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-target="#baenderesults"
|
hx-target="#baenderesults"
|
||||||
@@ -166,16 +171,21 @@ class="container-normal font-sans mt-10">
|
|||||||
aria-label="Bändesuche">
|
aria-label="Bändesuche">
|
||||||
<input type="hidden" name="sort" :value="sortField" />
|
<input type="hidden" name="sort" :value="sortField" />
|
||||||
<input type="hidden" name="order" :value="sortOrder" />
|
<input type="hidden" name="order" :value="sortOrder" />
|
||||||
<input
|
<div class="relative">
|
||||||
class="px-2 py-0.5 font-sans placeholder:italic w-full text-lg"
|
<input
|
||||||
type="search"
|
class="px-2 py-0.5 pr-7 font-sans placeholder:italic w-full text-lg"
|
||||||
name="search"
|
type="search"
|
||||||
value="{{ $model.search }}"
|
name="search"
|
||||||
placeholder="Signatur oder Suchbegriff"
|
value="{{ $model.search }}"
|
||||||
x-model="search"
|
placeholder="Signatur oder Suchbegriff"
|
||||||
@input.debounce.500="selectedLetter = ''; clearFilters(); ((search.trim().length >= 3) || /^[0-9]+$/.test(search.trim()) || search === '') && $el.form.requestSubmit()"
|
x-model="search"
|
||||||
@search.debounce.500="selectedLetter = ''; clearFilters(); ((search.trim().length >= 3) || /^[0-9]+$/.test(search.trim()) || search === '') && $el.form.requestSubmit()"
|
@input.debounce.500="selectedLetter = ''; clearFilters(); ((search.trim().length >= 3) || /^[0-9]+$/.test(search.trim()) || search === '') && $el.form.requestSubmit()"
|
||||||
autocomplete="off" />
|
@search.debounce.500="selectedLetter = ''; clearFilters(); ((search.trim().length >= 3) || /^[0-9]+$/.test(search.trim()) || search === '') && $el.form.requestSubmit()"
|
||||||
|
autocomplete="off" />
|
||||||
|
<span id="baende-search-spinner" class="htmx-indicator absolute right-1 top-1/2 -translate-y-1/2 text-slate-900">
|
||||||
|
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<button x-show="false">Suchen</button>
|
<button x-show="false">Suchen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,32 +198,39 @@ class="container-normal font-sans mt-10">
|
|||||||
:class="selectedLetter ? 'font-semibold text-slate-900 ring-1 ring-slate-300' : ''">
|
:class="selectedLetter ? 'font-semibold text-slate-900 ring-1 ring-slate-300' : ''">
|
||||||
<span x-text="selectedLetter ? `Alphabet: ${selectedLetter}` : 'Alphabet'"></span>
|
<span x-text="selectedLetter ? `Alphabet: ${selectedLetter}` : 'Alphabet'"></span>
|
||||||
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': alphabetOpen }"></i>
|
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': alphabetOpen }"></i>
|
||||||
|
<span id="baende-alphabet-spinner" class="htmx-indicator text-slate-900">
|
||||||
|
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="absolute left-0 mt-2 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
<div class="absolute left-0 mt-2 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
||||||
<div class="p-2 grid grid-cols-13 gap-1 text-sm text-gray-700 w-[26rem]">
|
<div class="p-2 w-[26rem]">
|
||||||
{{- 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 }}'; search = ''; clearFilters()"
|
|
||||||
: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 -}}
|
|
||||||
<a href="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
<a href="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
hx-get="/baende/results/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
hx-get="/baende/results/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-indicator="#baende-alphabet-spinner"
|
||||||
hx-target="#baenderesults"
|
hx-target="#baenderesults"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-indicator="body"
|
|
||||||
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
@click="offset = 0; hasMore = true; alphabetOpen = false; selectedLetter = ''; search = ''; clearFilters()"
|
@click="offset = 0; hasMore = true; alphabetOpen = false; selectedLetter = ''; search = ''; clearFilters()"
|
||||||
class="text-center py-1 px-2 rounded hover:bg-gray-100 no-underline transition-colors col-span-13 border-t mt-1">
|
x-show="selectedLetter"
|
||||||
Alle
|
class="mb-2 inline-flex w-full items-center justify-center gap-2 rounded bg-orange-100 px-2 py-1 text-sm font-semibold text-orange-800 hover:bg-orange-200 no-underline transition-colors">
|
||||||
|
<i class="ri-filter-off-line text-base"></i>
|
||||||
|
<span>Alle</span>
|
||||||
</a>
|
</a>
|
||||||
|
<div class="grid grid-cols-13 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-indicator="#baende-alphabet-spinner"
|
||||||
|
hx-target="#baenderesults"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="/baende/?letter={{ $ch }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
@click="offset = 0; hasMore = true; alphabetOpen = false; selectedLetter = '{{ $ch }}'; search = ''; clearFilters()"
|
||||||
|
: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 -}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
@@ -226,16 +243,19 @@ class="container-normal font-sans mt-10">
|
|||||||
:class="activeFilterType === 'status' ? 'font-semibold text-slate-900 ring-1 ring-slate-300' : ''">
|
:class="activeFilterType === 'status' ? 'font-semibold text-slate-900 ring-1 ring-slate-300' : ''">
|
||||||
<span x-text="activeFilterType === 'status' ? `Status: ${statusLabels[activeFilterValue] || activeFilterValue}` : 'Status'"></span>
|
<span x-text="activeFilterType === 'status' ? `Status: ${statusLabels[activeFilterValue] || activeFilterValue}` : 'Status'"></span>
|
||||||
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
||||||
|
<span id="baende-status-spinner" class="htmx-indicator text-slate-900">
|
||||||
|
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="absolute left-0 mt-2 w-64 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
<div class="absolute left-0 mt-2 w-72 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="max-h-64 overflow-auto flex flex-col gap-1 text-sm text-gray-700" data-role="filter-list">
|
<div class="max-h-64 overflow-auto flex flex-col gap-1 text-sm text-gray-700 border border-stone-100 rounded-sm" data-role="filter-list">
|
||||||
<a data-role="filter-item" data-label="Alle" href="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
<a data-role="filter-item" data-label="Alle" href="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
hx-get="/baende/results/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
hx-get="/baende/results/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-indicator="#baende-status-spinner"
|
||||||
hx-target="#baenderesults"
|
hx-target="#baenderesults"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-indicator="body"
|
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
|
||||||
@click="offset = 0; hasMore = true; open = false; clearFilters(); search = ''; selectedLetter = ''"
|
@click="offset = 0; hasMore = true; open = false; clearFilters(); search = ''; selectedLetter = ''"
|
||||||
x-show="activeFilterType === 'status'"
|
x-show="activeFilterType === 'status'"
|
||||||
class="mb-2 inline-flex w-full items-center justify-center gap-2 rounded bg-orange-100 px-2 py-1 text-sm font-semibold text-orange-800 hover:bg-orange-200 no-underline transition-colors">
|
class="mb-2 inline-flex w-full items-center justify-center gap-2 rounded bg-orange-100 px-2 py-1 text-sm font-semibold text-orange-800 hover:bg-orange-200 no-underline transition-colors">
|
||||||
@@ -245,13 +265,13 @@ class="container-normal font-sans mt-10">
|
|||||||
{{- range $_, $s := $model.filter_statuses -}}
|
{{- range $_, $s := $model.filter_statuses -}}
|
||||||
<a data-role="filter-item" data-label="{{ $s.label }}" href="/baende/?status={{ $s.value }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
<a data-role="filter-item" data-label="{{ $s.label }}" href="/baende/?status={{ $s.value }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
hx-get="/baende/results/?status={{ $s.value }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
hx-get="/baende/results/?status={{ $s.value }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-indicator="#baende-status-spinner"
|
||||||
hx-target="#baenderesults"
|
hx-target="#baenderesults"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-indicator="body"
|
|
||||||
hx-push-url="/baende/?status={{ $s.value }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
hx-push-url="/baende/?status={{ $s.value }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
@click="offset = 0; hasMore = true; open = false; activeFilterType = 'status'; activeFilterValue = '{{ $s.value }}'; search = ''; selectedLetter = ''"
|
@click="offset = 0; hasMore = true; open = false; activeFilterType = 'status'; activeFilterValue = '{{ $s.value }}'; search = ''; selectedLetter = ''"
|
||||||
:class="activeFilterType === 'status' && activeFilterValue === '{{ $s.value }}' ? 'bg-stone-100 font-semibold' : ''"
|
:class="activeFilterType === 'status' && activeFilterValue === '{{ $s.value }}' ? 'bg-stone-100 font-semibold' : ''"
|
||||||
class="px-2 py-1 rounded hover:bg-gray-100 no-underline transition-colors">
|
class="filter-list-row px-2 py-1 rounded-sm hover:bg-stone-100 no-underline transition-colors">
|
||||||
{{ $s.label }}
|
{{ $s.label }}
|
||||||
</a>
|
</a>
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
@@ -269,32 +289,37 @@ class="container-normal font-sans mt-10">
|
|||||||
<span x-text="activeFilterType === 'person' ? `Person: ${personLabels[activeFilterValue] || activeFilterValue}` : 'Person'"></span>
|
<span x-text="activeFilterType === 'person' ? `Person: ${personLabels[activeFilterValue] || activeFilterValue}` : 'Person'"></span>
|
||||||
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="absolute left-0 mt-2 w-72 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
<div class="absolute left-0 mt-2 w-80 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<a data-role="filter-item" data-label="Alle" href="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
<a data-role="filter-item" data-label="Alle" href="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
hx-get="/baende/results/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
hx-get="/baende/results/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-indicator="#baende-person-spinner"
|
||||||
hx-target="#baenderesults"
|
hx-target="#baenderesults"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-indicator="body"
|
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
|
||||||
@click="offset = 0; hasMore = true; open = false; clearFilters(); search = ''; selectedLetter = ''"
|
@click="offset = 0; hasMore = true; open = false; clearFilters(); search = ''; selectedLetter = ''"
|
||||||
x-show="activeFilterType === 'person'"
|
x-show="activeFilterType === 'person'"
|
||||||
class="mb-2 inline-flex w-full items-center justify-center gap-2 rounded bg-orange-100 px-2 py-1 text-sm font-semibold text-orange-800 hover:bg-orange-200 no-underline transition-colors">
|
class="mb-2 inline-flex w-full items-center justify-center gap-2 rounded bg-orange-100 px-2 py-1 text-sm font-semibold text-orange-800 hover:bg-orange-200 no-underline transition-colors">
|
||||||
<i class="ri-filter-off-line text-base"></i>
|
<i class="ri-filter-off-line text-base"></i>
|
||||||
<span>Alle</span>
|
<span>Alle</span>
|
||||||
</a>
|
</a>
|
||||||
<input data-role="filter-search" type="search" placeholder="Personen filtern..." class="w-full px-2 py-1 border border-stone-200 rounded text-sm" />
|
<div class="relative">
|
||||||
<div class="mt-2 max-h-80 overflow-auto flex flex-col gap-0.5 text-sm text-gray-700" data-role="filter-list">
|
<input data-role="filter-search" type="search" placeholder="Personen filtern..." class="w-full px-2 py-1 pr-7 border border-stone-200 rounded text-sm" />
|
||||||
|
<span id="baende-person-spinner" class="htmx-indicator absolute right-2 top-1/2 -translate-y-1/2 text-slate-900">
|
||||||
|
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 max-h-80 overflow-auto flex flex-col gap-0.5 text-sm text-gray-700 border border-stone-100 rounded-sm" data-role="filter-list">
|
||||||
{{- range $_, $a := $model.filter_agents -}}
|
{{- range $_, $a := $model.filter_agents -}}
|
||||||
<a data-role="filter-item" data-label="{{ $a.Name }}" href="/baende/?person={{ $a.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
<a data-role="filter-item" data-label="{{ $a.Name }}" href="/baende/?person={{ $a.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
hx-get="/baende/results/?person={{ $a.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
hx-get="/baende/results/?person={{ $a.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-indicator="#baende-person-spinner"
|
||||||
hx-target="#baenderesults"
|
hx-target="#baenderesults"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-indicator="body"
|
|
||||||
hx-push-url="/baende/?person={{ $a.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
hx-push-url="/baende/?person={{ $a.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
@click="offset = 0; hasMore = true; open = false; activeFilterType = 'person'; activeFilterValue = '{{ $a.Id }}'; search = ''; selectedLetter = ''"
|
@click="offset = 0; hasMore = true; open = false; activeFilterType = 'person'; activeFilterValue = '{{ $a.Id }}'; search = ''; selectedLetter = ''"
|
||||||
:class="activeFilterType === 'person' && activeFilterValue === '{{ $a.Id }}' ? 'bg-stone-100 font-semibold' : ''"
|
:class="activeFilterType === 'person' && activeFilterValue === '{{ $a.Id }}' ? 'bg-stone-100 font-semibold' : ''"
|
||||||
class="px-2 py-1 rounded hover:bg-gray-100 no-underline transition-colors">
|
class="filter-list-row px-2 py-1 rounded-sm hover:bg-stone-100 no-underline transition-colors">
|
||||||
<span class="filter-list-searchable mr-1">{{ $a.Name }}</span>
|
<span class="filter-list-searchable mr-1">{{ $a.Name }}</span>
|
||||||
{{- if $a.CorporateBody -}}
|
{{- if $a.CorporateBody -}}
|
||||||
<span class="inline-flex items-center rounded-xs bg-stone-100 px-1.5 py-0.5 text-[0.7rem] font-semibold text-slate-600">ORG</span>
|
<span class="inline-flex items-center rounded-xs bg-stone-100 px-1.5 py-0.5 text-[0.7rem] font-semibold text-slate-600">ORG</span>
|
||||||
@@ -309,6 +334,54 @@ class="container-normal font-sans mt-10">
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Benutzer filter -->
|
||||||
|
<div class="relative" x-data="{ open: false }" data-role="baende-filter">
|
||||||
|
<details class="font-sans text-base list-none" @toggle="open = $el.open; if ($el.open) { closeOtherDropdowns($el); }">
|
||||||
|
<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"
|
||||||
|
:class="activeFilterType === 'user' ? 'font-semibold text-slate-900 ring-1 ring-slate-300' : ''">
|
||||||
|
<span x-text="activeFilterType === 'user' ? `Benutzer: ${userLabels[activeFilterValue] || activeFilterValue}` : 'Benutzer'"></span>
|
||||||
|
<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-80 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
||||||
|
<div class="p-3">
|
||||||
|
<a data-role="filter-item" data-label="Alle" href="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-get="/baende/results/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-indicator="#baende-user-spinner"
|
||||||
|
hx-target="#baenderesults"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
@click="offset = 0; hasMore = true; open = false; clearFilters(); search = ''; selectedLetter = ''"
|
||||||
|
x-show="activeFilterType === 'user'"
|
||||||
|
class="mb-2 inline-flex w-full items-center justify-center gap-2 rounded bg-orange-100 px-2 py-1 text-sm font-semibold text-orange-800 hover:bg-orange-200 no-underline transition-colors">
|
||||||
|
<i class="ri-filter-off-line text-base"></i>
|
||||||
|
<span>Alle</span>
|
||||||
|
</a>
|
||||||
|
<div class="relative">
|
||||||
|
<input data-role="filter-search" type="search" placeholder="Benutzer filtern..." class="w-full px-2 py-1 pr-7 border border-stone-200 rounded text-sm" />
|
||||||
|
<span id="baende-user-spinner" class="htmx-indicator absolute right-2 top-1/2 -translate-y-1/2 text-slate-900">
|
||||||
|
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 max-h-80 overflow-auto flex flex-col gap-0.5 text-sm text-gray-700 border border-stone-100 rounded-sm" data-role="filter-list">
|
||||||
|
{{- range $_, $u := $model.filter_users -}}
|
||||||
|
<a data-role="filter-item" data-label="{{ $u.Name }}" href="/baende/?user={{ $u.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-get="/baende/results/?user={{ $u.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-indicator="#baende-user-spinner"
|
||||||
|
hx-target="#baenderesults"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="/baende/?user={{ $u.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
@click="offset = 0; hasMore = true; open = false; activeFilterType = 'user'; activeFilterValue = '{{ $u.Id }}'; search = ''; selectedLetter = ''"
|
||||||
|
:class="activeFilterType === 'user' && activeFilterValue === '{{ $u.Id }}' ? 'bg-stone-100 font-semibold' : ''"
|
||||||
|
class="filter-list-row px-2 py-1 rounded-sm hover:bg-stone-100 no-underline transition-colors">
|
||||||
|
<span class="filter-list-searchable mr-1">{{ $u.Name }}</span>
|
||||||
|
</a>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Jahr filter -->
|
<!-- Jahr filter -->
|
||||||
<div class="relative" x-data="{ open: false }" data-role="baende-filter">
|
<div class="relative" x-data="{ open: false }" data-role="baende-filter">
|
||||||
<details class="font-sans text-base list-none" @toggle="open = $el.open; if ($el.open) { closeOtherDropdowns($el); }">
|
<details class="font-sans text-base list-none" @toggle="open = $el.open; if ($el.open) { closeOtherDropdowns($el); }">
|
||||||
@@ -317,34 +390,39 @@ class="container-normal font-sans mt-10">
|
|||||||
<span x-text="activeFilterType === 'year' ? `Jahr: ${yearLabels[activeFilterValue] || activeFilterValue}` : 'Jahr'"></span>
|
<span x-text="activeFilterType === 'year' ? `Jahr: ${yearLabels[activeFilterValue] || activeFilterValue}` : 'Jahr'"></span>
|
||||||
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="absolute left-0 mt-2 w-56 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
<div class="absolute left-0 mt-2 w-72 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<a data-role="filter-item" data-label="Alle" href="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
<a data-role="filter-item" data-label="Alle" href="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
hx-get="/baende/results/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
hx-get="/baende/results/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-indicator="#baende-year-spinner"
|
||||||
hx-target="#baenderesults"
|
hx-target="#baenderesults"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-indicator="body"
|
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
|
||||||
@click="offset = 0; hasMore = true; open = false; clearFilters(); search = ''; selectedLetter = ''"
|
@click="offset = 0; hasMore = true; open = false; clearFilters(); search = ''; selectedLetter = ''"
|
||||||
x-show="activeFilterType === 'year'"
|
x-show="activeFilterType === 'year'"
|
||||||
class="mb-2 inline-flex w-full items-center justify-center gap-2 rounded bg-orange-100 px-2 py-1 text-sm font-semibold text-orange-800 hover:bg-orange-200 no-underline transition-colors">
|
class="mb-2 inline-flex w-full items-center justify-center gap-2 rounded bg-orange-100 px-2 py-1 text-sm font-semibold text-orange-800 hover:bg-orange-200 no-underline transition-colors">
|
||||||
<i class="ri-filter-off-line text-base"></i>
|
<i class="ri-filter-off-line text-base"></i>
|
||||||
<span>Alle</span>
|
<span>Alle</span>
|
||||||
</a>
|
</a>
|
||||||
<input data-role="filter-search" type="search" placeholder="Jahre filtern..." class="w-full px-2 py-1 border border-stone-200 rounded text-sm" />
|
<div class="relative">
|
||||||
<div class="mt-2 max-h-80 overflow-auto flex flex-col gap-0.5 text-sm text-gray-700" data-role="filter-list">
|
<input data-role="filter-search" type="search" placeholder="Jahre filtern..." class="w-full px-2 py-1 pr-7 border border-stone-200 rounded text-sm" />
|
||||||
|
<span id="baende-year-spinner" class="htmx-indicator absolute right-2 top-1/2 -translate-y-1/2 text-slate-900">
|
||||||
|
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 max-h-80 overflow-auto flex flex-col gap-0.5 text-sm text-gray-700 border border-stone-100 rounded-sm" data-role="filter-list">
|
||||||
{{- range $_, $y := $model.filter_years -}}
|
{{- range $_, $y := $model.filter_years -}}
|
||||||
{{- $label := $y -}}
|
{{- $label := $y -}}
|
||||||
{{- if eq $y 0 -}}{{- $label = "ohne Jahr" -}}{{- end -}}
|
{{- if eq $y 0 -}}{{- $label = "ohne Jahr" -}}{{- end -}}
|
||||||
<a data-role="filter-item" data-label="{{ $label }}" href="/baende/?year={{ $y }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
<a data-role="filter-item" data-label="{{ $label }}" href="/baende/?year={{ $y }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
hx-get="/baende/results/?year={{ $y }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
hx-get="/baende/results/?year={{ $y }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-indicator="#baende-year-spinner"
|
||||||
hx-target="#baenderesults"
|
hx-target="#baenderesults"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-indicator="body"
|
|
||||||
hx-push-url="/baende/?year={{ $y }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
hx-push-url="/baende/?year={{ $y }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
@click="offset = 0; hasMore = true; open = false; activeFilterType = 'year'; activeFilterValue = '{{ $y }}'; search = ''; selectedLetter = ''"
|
@click="offset = 0; hasMore = true; open = false; activeFilterType = 'year'; activeFilterValue = '{{ $y }}'; search = ''; selectedLetter = ''"
|
||||||
:class="activeFilterType === 'year' && activeFilterValue === '{{ $y }}' ? 'bg-stone-100 font-semibold' : ''"
|
:class="activeFilterType === 'year' && activeFilterValue === '{{ $y }}' ? 'bg-stone-100 font-semibold' : ''"
|
||||||
class="px-2 py-1 rounded hover:bg-gray-100 no-underline transition-colors">
|
class="filter-list-row px-2 py-1 rounded-sm hover:bg-stone-100 no-underline transition-colors">
|
||||||
{{ $label }}
|
{{ $label }}
|
||||||
</a>
|
</a>
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
@@ -362,32 +440,37 @@ class="container-normal font-sans mt-10">
|
|||||||
<span x-text="activeFilterType === 'place' ? `Ort: ${placeLabels[activeFilterValue] || activeFilterValue}` : 'Ort'"></span>
|
<span x-text="activeFilterType === 'place' ? `Ort: ${placeLabels[activeFilterValue] || activeFilterValue}` : 'Ort'"></span>
|
||||||
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="absolute left-0 mt-2 w-72 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
<div class="absolute left-0 mt-2 w-80 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<a data-role="filter-item" data-label="Alle" href="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
<a data-role="filter-item" data-label="Alle" href="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
hx-get="/baende/results/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
hx-get="/baende/results/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-indicator="#baende-place-spinner"
|
||||||
hx-target="#baenderesults"
|
hx-target="#baenderesults"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-indicator="body"
|
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
hx-push-url="/baende/?sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
|
||||||
@click="offset = 0; hasMore = true; open = false; clearFilters(); search = ''; selectedLetter = ''"
|
@click="offset = 0; hasMore = true; open = false; clearFilters(); search = ''; selectedLetter = ''"
|
||||||
x-show="activeFilterType === 'place'"
|
x-show="activeFilterType === 'place'"
|
||||||
class="mb-2 inline-flex w-full items-center justify-center gap-2 rounded bg-orange-100 px-2 py-1 text-sm font-semibold text-orange-800 hover:bg-orange-200 no-underline transition-colors">
|
class="mb-2 inline-flex w-full items-center justify-center gap-2 rounded bg-orange-100 px-2 py-1 text-sm font-semibold text-orange-800 hover:bg-orange-200 no-underline transition-colors">
|
||||||
<i class="ri-filter-off-line text-base"></i>
|
<i class="ri-filter-off-line text-base"></i>
|
||||||
<span>Alle</span>
|
<span>Alle</span>
|
||||||
</a>
|
</a>
|
||||||
<input data-role="filter-search" type="search" placeholder="Orte filtern..." class="w-full px-2 py-1 border border-stone-200 rounded text-sm" />
|
<div class="relative">
|
||||||
<div class="mt-2 max-h-80 overflow-auto flex flex-col gap-0.5 text-sm text-gray-700" data-role="filter-list">
|
<input data-role="filter-search" type="search" placeholder="Orte filtern..." class="w-full px-2 py-1 pr-7 border border-stone-200 rounded text-sm" />
|
||||||
|
<span id="baende-place-spinner" class="htmx-indicator absolute right-2 top-1/2 -translate-y-1/2 text-slate-900">
|
||||||
|
<i class="ri-loader-4-line spinning" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 max-h-80 overflow-auto flex flex-col gap-0.5 text-sm text-gray-700 border border-stone-100 rounded-sm" data-role="filter-list">
|
||||||
{{- range $_, $p := $model.filter_places -}}
|
{{- range $_, $p := $model.filter_places -}}
|
||||||
<a data-role="filter-item" data-label="{{ $p.Name }}" href="/baende/?place={{ $p.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
<a data-role="filter-item" data-label="{{ $p.Name }}" href="/baende/?place={{ $p.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
hx-get="/baende/results/?place={{ $p.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
hx-get="/baende/results/?place={{ $p.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
|
hx-indicator="#baende-place-spinner"
|
||||||
hx-target="#baenderesults"
|
hx-target="#baenderesults"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-indicator="body"
|
|
||||||
hx-push-url="/baende/?place={{ $p.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
hx-push-url="/baende/?place={{ $p.Id }}&sort={{ $model.sort_field }}&order={{ $model.sort_order }}"
|
||||||
@click="offset = 0; hasMore = true; open = false; activeFilterType = 'place'; activeFilterValue = '{{ $p.Id }}'; search = ''; selectedLetter = ''"
|
@click="offset = 0; hasMore = true; open = false; activeFilterType = 'place'; activeFilterValue = '{{ $p.Id }}'; search = ''; selectedLetter = ''"
|
||||||
:class="activeFilterType === 'place' && activeFilterValue === '{{ $p.Id }}' ? 'bg-stone-100 font-semibold' : ''"
|
:class="activeFilterType === 'place' && activeFilterValue === '{{ $p.Id }}' ? 'bg-stone-100 font-semibold' : ''"
|
||||||
class="px-2 py-1 rounded hover:bg-gray-100 no-underline transition-colors">
|
class="filter-list-row px-2 py-1 rounded-sm hover:bg-stone-100 no-underline transition-colors">
|
||||||
{{ $p.Name }}
|
{{ $p.Name }}
|
||||||
</a>
|
</a>
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
@@ -397,36 +480,35 @@ class="container-normal font-sans mt-10">
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Spalten toggle -->
|
</div>
|
||||||
<div class="relative" x-data="{ open: false }" data-role="baende-filter">
|
|
||||||
|
<!-- Row 2: Spalten + count -->
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-3 pb-0 mt-3">
|
||||||
|
<div class="relative" x-data="{ open: false }">
|
||||||
<details class="font-sans text-base list-none" data-role="baende-column-toggle" @toggle="open = $el.open; if ($el.open) { closeOtherDropdowns($el); }">
|
<details class="font-sans text-base list-none" data-role="baende-column-toggle" @toggle="open = $el.open; if ($el.open) { closeOtherDropdowns($el); }">
|
||||||
<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">
|
<summary class="cursor-pointer text-gray-600 hover:text-slate-900 px-2 py-1 rounded-xs flex items-center gap-2 text-lg font-semibold font-sans">
|
||||||
<i class="ri-eye-line"></i>
|
<i class="ri-eye-line"></i>
|
||||||
<span>Spalten</span>
|
<span>Spalten</span>
|
||||||
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
<i class="ri-arrow-down-s-line transform origin-center transition-transform" :class="{ 'rotate-180': open }"></i>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="absolute left-0 mt-2 w-56 z-10 bg-white rounded-md shadow-lg border border-gray-200">
|
<div class="absolute right-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">
|
<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="title" checked /> Titel</label>
|
||||||
<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="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="year" /> Jahr</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="extent" checked /> Umfang / Maße</label>
|
||||||
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="signatures" checked /> Signaturen</label>
|
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="signatures" checked /> Signaturen</label>
|
||||||
|
<label class="inline-flex items-center gap-2"><input type="checkbox" data-col="modified" /> Bearbeitet / Benutzer</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right side group: count/button -->
|
|
||||||
<div class="flex items-end gap-4 ml-auto">
|
|
||||||
|
|
||||||
<!-- Count and New button -->
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div id="baende-count" class="text-lg font-semibold font-sans text-gray-600 whitespace-nowrap">
|
<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
|
{{ 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,23 +6,26 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="py-2 pr-4 pl-2 whitespace-nowrap w-[10rem] align-bottom"
|
<th class="py-2 pr-4 pl-2 whitespace-nowrap w-[10rem] align-bottom"
|
||||||
:aria-sort="sortField === 'alm' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'">
|
:aria-sort="sortField === 'alm' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'">
|
||||||
<button type="button"
|
<div class="flex flex-col items-start gap-0.5 leading-tight">
|
||||||
class="flex w-full items-center justify-between gap-1 text-left text-sm"
|
<button type="button"
|
||||||
@click="changeSort('alm')">
|
class="baende-sort-button flex w-full items-center justify-between gap-1 text-left text-sm"
|
||||||
<span class="font-semibold tracking-wide">Alm</span>
|
@click="changeSort('alm')">
|
||||||
<i class="text-xs opacity-70 transition-colors"
|
<span class="font-semibold tracking-wide">Alm-Nr</span>
|
||||||
:class="{
|
<i class="text-xs opacity-70 transition-colors"
|
||||||
'ri-arrow-up-line text-blue-600': sortField === 'alm' && sortOrder === 'asc',
|
:class="{
|
||||||
'ri-arrow-down-line text-blue-600': sortField === 'alm' && sortOrder === 'desc',
|
'ri-arrow-up-line text-blue-600': sortField === 'alm' && sortOrder === 'asc',
|
||||||
'ri-arrow-up-down-line text-gray-400': sortField !== 'alm'
|
'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>
|
aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<span class="font-semibold tracking-wide">Nachweis</span>
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th class="py-2 pr-4 whitespace-nowrap w-[44rem] align-bottom"
|
<th class="py-2 pr-4 whitespace-nowrap w-[44rem] align-bottom col-title"
|
||||||
:aria-sort="sortField === 'title' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'">
|
:aria-sort="sortField === 'title' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="flex w-full items-center justify-between gap-1 text-left text-sm"
|
class="baende-sort-button flex w-full items-center justify-between gap-1 text-left text-sm"
|
||||||
@click="changeSort('title')">
|
@click="changeSort('title')">
|
||||||
<span class="font-semibold tracking-wide">Titel</span>
|
<span class="font-semibold tracking-wide">Titel</span>
|
||||||
<i class="text-xs opacity-70 transition-colors"
|
<i class="text-xs opacity-70 transition-colors"
|
||||||
@@ -37,7 +40,7 @@
|
|||||||
<th class="py-2 pr-4 whitespace-nowrap col-appearance w-[18rem] align-bottom">
|
<th class="py-2 pr-4 whitespace-nowrap col-appearance w-[18rem] align-bottom">
|
||||||
<div class="flex flex-col items-start gap-0.5 h-full justify-end">
|
<div class="flex flex-col items-start gap-0.5 h-full justify-end">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="flex w-full items-center justify-between gap-1 text-left text-sm leading-tight"
|
class="baende-sort-button flex w-full items-center justify-between gap-1 text-left text-sm leading-tight"
|
||||||
@click="changeSort('responsibility')">
|
@click="changeSort('responsibility')">
|
||||||
<span class="font-semibold tracking-wide">Herausgeber</span>
|
<span class="font-semibold tracking-wide">Herausgeber</span>
|
||||||
<i class="text-xs opacity-70 transition-colors"
|
<i class="text-xs opacity-70 transition-colors"
|
||||||
@@ -49,7 +52,7 @@
|
|||||||
aria-hidden="true"></i>
|
aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="flex w-full items-center justify-between gap-1 text-left text-sm leading-tight"
|
class="baende-sort-button flex w-full items-center justify-between gap-1 text-left text-sm leading-tight"
|
||||||
@click="changeSort('place')">
|
@click="changeSort('place')">
|
||||||
<span class="font-semibold tracking-wide">Ortsangabe</span>
|
<span class="font-semibold tracking-wide">Ortsangabe</span>
|
||||||
<i class="text-xs opacity-70 transition-colors"
|
<i class="text-xs opacity-70 transition-colors"
|
||||||
@@ -65,7 +68,7 @@
|
|||||||
<th class="py-2 pr-4 whitespace-nowrap col-year hidden align-bottom"
|
<th class="py-2 pr-4 whitespace-nowrap col-year hidden align-bottom"
|
||||||
:aria-sort="sortField === 'year' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'">
|
:aria-sort="sortField === 'year' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="flex w-full items-center justify-between gap-1 text-left text-sm"
|
class="baende-sort-button flex w-full items-center justify-between gap-1 text-left text-sm"
|
||||||
@click="changeSort('year')">
|
@click="changeSort('year')">
|
||||||
<span class="font-semibold tracking-wide">Jahr</span>
|
<span class="font-semibold tracking-wide">Jahr</span>
|
||||||
<i class="text-xs opacity-70 transition-colors"
|
<i class="text-xs opacity-70 transition-colors"
|
||||||
@@ -86,7 +89,7 @@
|
|||||||
<th class="py-2 pr-4 whitespace-nowrap col-signatures align-bottom"
|
<th class="py-2 pr-4 whitespace-nowrap col-signatures align-bottom"
|
||||||
:aria-sort="sortField === 'signatur' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'">
|
:aria-sort="sortField === 'signatur' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="flex w-full items-center justify-between gap-1 text-left text-sm"
|
class="baende-sort-button flex w-full items-center justify-between gap-1 text-left text-sm"
|
||||||
@click="changeSort('signatur')">
|
@click="changeSort('signatur')">
|
||||||
<span class="font-semibold tracking-wide">Signaturen</span>
|
<span class="font-semibold tracking-wide">Signaturen</span>
|
||||||
<i class="text-xs opacity-70 transition-colors"
|
<i class="text-xs opacity-70 transition-colors"
|
||||||
@@ -98,12 +101,30 @@
|
|||||||
aria-hidden="true"></i>
|
aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
|
<th class="py-2 pr-4 whitespace-nowrap col-modified hidden align-bottom w-[11.25rem]"
|
||||||
|
:aria-sort="sortField === 'updated' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'">
|
||||||
|
<div class="flex flex-col items-start gap-0.5 leading-tight">
|
||||||
|
<button type="button"
|
||||||
|
class="baende-sort-button flex w-full items-center justify-between gap-1 text-left text-sm"
|
||||||
|
@click="changeSort('updated')">
|
||||||
|
<span class="font-semibold tracking-wide">Bearbeitet am</span>
|
||||||
|
<i class="text-xs opacity-70 transition-colors"
|
||||||
|
:class="{
|
||||||
|
'ri-arrow-up-line text-blue-600': sortField === 'updated' && sortOrder === 'asc',
|
||||||
|
'ri-arrow-down-line text-blue-600': sortField === 'updated' && sortOrder === 'desc',
|
||||||
|
'ri-arrow-up-down-line text-gray-400': sortField !== 'updated'
|
||||||
|
}"
|
||||||
|
aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<span class="font-semibold tracking-wide">Benutzer</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="baende-tbody">
|
<tbody id="baende-tbody">
|
||||||
{{- if not (len $model.result.Entries) -}}
|
{{- if not (len $model.result.Entries) -}}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="py-6 text-center text-sm text-gray-500">
|
<td colspan="7" class="py-6 text-center text-sm text-gray-500">
|
||||||
Keine Bände gefunden.
|
Keine Bände gefunden.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -149,7 +170,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 pr-4">
|
<td class="py-2 pr-4 col-title">
|
||||||
<div class="font-semibold text-slate-900 text-base leading-snug">
|
<div class="font-semibold text-slate-900 text-base leading-snug">
|
||||||
{{- if $entry.PreferredTitle -}}
|
{{- if $entry.PreferredTitle -}}
|
||||||
<span class="inline">{{ $entry.PreferredTitle }}</span>
|
<span class="inline">{{ $entry.PreferredTitle }}</span>
|
||||||
@@ -269,6 +290,20 @@
|
|||||||
</div>
|
</div>
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="py-2 pr-4 col-modified hidden w-[11.25rem]">
|
||||||
|
<div class="flex flex-col gap-1 text-sm text-gray-700">
|
||||||
|
{{- if $entry.Updated -}}
|
||||||
|
<div class="tabular-nums">{{ GermanShortDateTime $entry.Updated }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- $editor := $entry.Editor -}}
|
||||||
|
{{- if $editor -}}
|
||||||
|
{{- $user := index $model.result.Users $editor -}}
|
||||||
|
{{- if $user -}}
|
||||||
|
<div class="font-semibold text-slate-900">{{ $user.Name }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -279,8 +314,19 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const toggleRoot = document.querySelector('[data-role="baende-column-toggle"]');
|
const toggleRoot = document.querySelector('[data-role="baende-column-toggle"]');
|
||||||
if (toggleRoot) {
|
if (toggleRoot) {
|
||||||
toggleRoot.querySelectorAll('input[type="checkbox"][data-col]').forEach((checkbox) => {
|
const storageKey = "baende-columns";
|
||||||
|
let saved = null;
|
||||||
|
try {
|
||||||
|
saved = JSON.parse(localStorage.getItem(storageKey) || "null");
|
||||||
|
} catch {
|
||||||
|
saved = null;
|
||||||
|
}
|
||||||
|
const checkboxes = toggleRoot.querySelectorAll('input[type="checkbox"][data-col]');
|
||||||
|
checkboxes.forEach((checkbox) => {
|
||||||
const col = checkbox.getAttribute("data-col");
|
const col = checkbox.getAttribute("data-col");
|
||||||
|
if (saved && typeof saved[col] === "boolean") {
|
||||||
|
checkbox.checked = saved[col];
|
||||||
|
}
|
||||||
const setColumn = (visible) => {
|
const setColumn = (visible) => {
|
||||||
document.querySelectorAll(`.col-${col}`).forEach((el) => {
|
document.querySelectorAll(`.col-${col}`).forEach((el) => {
|
||||||
el.classList.toggle("hidden", !visible);
|
el.classList.toggle("hidden", !visible);
|
||||||
@@ -289,6 +335,12 @@
|
|||||||
setColumn(checkbox.checked);
|
setColumn(checkbox.checked);
|
||||||
checkbox.addEventListener("change", (event) => {
|
checkbox.addEventListener("change", (event) => {
|
||||||
setColumn(event.target.checked);
|
setColumn(event.target.checked);
|
||||||
|
const nextState = {};
|
||||||
|
checkboxes.forEach((cb) => {
|
||||||
|
const key = cb.getAttribute("data-col");
|
||||||
|
nextState[key] = cb.checked;
|
||||||
|
});
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(nextState));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 pr-4">
|
<td class="py-2 pr-4 col-title">
|
||||||
<div class="font-semibold text-slate-900 text-base leading-snug">
|
<div class="font-semibold text-slate-900 text-base leading-snug">
|
||||||
{{- if $entry.PreferredTitle -}}
|
{{- if $entry.PreferredTitle -}}
|
||||||
<span class="inline">{{ $entry.PreferredTitle }}</span>
|
<span class="inline">{{ $entry.PreferredTitle }}</span>
|
||||||
@@ -160,5 +160,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="py-2 pr-4 col-modified hidden w-[11.25rem]">
|
||||||
|
<div class="flex flex-col gap-1 text-sm text-gray-700">
|
||||||
|
{{- if $entry.Updated -}}
|
||||||
|
<div class="tabular-nums">{{ GermanShortDateTime $entry.Updated }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- $editor := $entry.Editor -}}
|
||||||
|
{{- if $editor -}}
|
||||||
|
{{- $user := index $model.result.Users $editor -}}
|
||||||
|
{{- if $user -}}
|
||||||
|
<div class="font-semibold text-slate-900">{{ $user.Name }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 pr-4">
|
<td class="py-2 pr-4 col-title">
|
||||||
<div class="font-semibold text-slate-900 text-base leading-snug">
|
<div class="font-semibold text-slate-900 text-base leading-snug">
|
||||||
{{- if $entry.PreferredTitle -}}
|
{{- if $entry.PreferredTitle -}}
|
||||||
<span class="inline">{{ $entry.PreferredTitle }}</span>
|
<span class="inline">{{ $entry.PreferredTitle }}</span>
|
||||||
@@ -160,4 +160,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="py-2 pr-4 col-modified hidden w-[11.25rem]">
|
||||||
|
<div class="flex flex-col gap-1 text-sm text-gray-700">
|
||||||
|
{{- if $entry.Updated -}}
|
||||||
|
<div class="tabular-nums">{{ GermanShortDateTime $entry.Updated }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- if $model.editor_user -}}
|
||||||
|
<div class="font-semibold text-slate-900">{{ $model.editor_user.Name }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -195,6 +195,14 @@
|
|||||||
@apply border-l-4 border-zinc-300 font-bold;
|
@apply border-l-4 border-zinc-300 font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-list-row:nth-child(odd) {
|
||||||
|
@apply bg-stone-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-list-row:nth-child(even) {
|
||||||
|
@apply bg-white;
|
||||||
|
}
|
||||||
|
|
||||||
.legacy-toggle-icon {
|
.legacy-toggle-icon {
|
||||||
transition: transform 150ms ease;
|
transition: transform 150ms ease;
|
||||||
}
|
}
|
||||||
@@ -219,6 +227,18 @@
|
|||||||
@apply ml-4 bg-stone-100 py-0.5 px-2.5 rounded font-sans text-base text-center;
|
@apply ml-4 bg-stone-100 py-0.5 px-2.5 rounded font-sans text-base text-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.baende-sort-button {
|
||||||
|
@apply transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baende-sort-button:hover {
|
||||||
|
@apply text-slate-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.baende-sort-button:hover i {
|
||||||
|
@apply text-slate-900;
|
||||||
|
}
|
||||||
|
|
||||||
.container-normal {
|
.container-normal {
|
||||||
@apply w-full max-w-(--breakpoint-xl) mx-auto px-3 py-4 relative;
|
@apply w-full max-w-(--breakpoint-xl) mx-auto px-3 py-4 relative;
|
||||||
}
|
}
|
||||||
@@ -625,6 +645,15 @@
|
|||||||
animation: spin 1s ease-out infinite;
|
animation: spin 1s ease-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.htmx-indicator {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmx-request .htmx-indicator,
|
||||||
|
.htmx-indicator.htmx-request {
|
||||||
|
@apply inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
body.htmx-request #simplesearchform #sumbmitbutton {
|
body.htmx-request #simplesearchform #sumbmitbutton {
|
||||||
@apply cursor-wait pointer-events-none;
|
@apply cursor-wait pointer-events-none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user