FIX: pages load with indexed map[string]any, page edit page

This commit is contained in:
Simon Martens
2026-01-14 17:56:28 +01:00
parent 941ecbecaf
commit 3732b128db
12 changed files with 564 additions and 3 deletions

342
controllers/seiten_edit.go Normal file
View File

@@ -0,0 +1,342 @@
package controllers
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"github.com/Theodor-Springmann-Stiftung/musenalm/app"
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
"github.com/Theodor-Springmann-Stiftung/musenalm/middleware"
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/types"
)
const (
URL_SEITEN_EDITOR = "/redaktion/seiten/"
URL_SEITEN_EDITOR_FORM = "form/"
URL_SEITEN_EDITOR_SAVE = "save/"
TEMPLATE_SEITEN_EDITOR = "/redaktion/seiten/"
TEMPLATE_SEITEN_FORM = "/redaktion/seiten/form/"
LAYOUT_FRAGMENT = "fragment"
pageHTMLPrefixSeparator = "."
)
func init() {
p := &SeitenEditPage{
StaticPage: pagemodels.StaticPage{
Name: pagemodels.P_SEITEN_EDIT_NAME,
URL: URL_SEITEN_EDITOR,
Template: TEMPLATE_SEITEN_EDITOR,
Layout: pagemodels.LAYOUT_LOGIN_PAGES,
},
}
app.Register(p)
}
type SeitenEditPage struct {
pagemodels.StaticPage
}
type SeitenEditorPageItem struct {
Key string
Title string
}
type SeitenEditorSection struct {
Key string
Label string
HTML string
}
type SeitenEditorSelected struct {
Key string
Title string
Description string
Keywords string
Sections []SeitenEditorSection
}
func (p *SeitenEditPage) Setup(router *router.Router[*core.RequestEvent], ia pagemodels.IApp, engine *templating.Engine) error {
app := ia.Core()
rg := router.Group(URL_SEITEN_EDITOR)
rg.BindFunc(middleware.IsAdminOrEditor())
rg.GET("", p.GET(engine, app))
rg.GET(URL_SEITEN_EDITOR_FORM, p.GETForm(engine, app))
rg.POST(URL_SEITEN_EDITOR_SAVE, p.POSTSave(engine, ia))
return nil
}
func (p *SeitenEditPage) GET(engine *templating.Engine, app core.App) HandleFunc {
return func(e *core.RequestEvent) error {
data, err := seitenEditorData(e, app)
if err != nil {
return engine.Response500(e, err, nil)
}
return engine.Response200(e, p.Template, data, p.Layout)
}
}
func (p *SeitenEditPage) GETForm(engine *templating.Engine, app core.App) HandleFunc {
return func(e *core.RequestEvent) error {
data, err := seitenEditorData(e, app)
if err != nil {
return engine.Response500(e, err, nil)
}
return engine.Response200(e, TEMPLATE_SEITEN_FORM, data, LAYOUT_FRAGMENT)
}
}
func (p *SeitenEditPage) POSTSave(engine *templating.Engine, ia pagemodels.IApp) HandleFunc {
return func(e *core.RequestEvent) error {
app := ia.Core()
req := templating.NewRequest(e)
if err := e.Request.ParseForm(); err != nil {
return p.redirectError(e, "Formulardaten ungültig.")
}
csrfToken := e.Request.FormValue("csrf_token")
if err := req.CheckCSRF(csrfToken); err != nil {
return p.redirectError(e, err.Error())
}
pageKey := strings.TrimSpace(e.Request.FormValue("page_key"))
if pageKey == "" {
return p.redirectError(e, "Bitte eine Seite auswählen.")
}
title := strings.TrimSpace(e.Request.FormValue("title"))
description := strings.TrimSpace(e.Request.FormValue("description"))
keywords := strings.TrimSpace(e.Request.FormValue("keywords"))
htmlUpdates := make(map[string]string)
for key, values := range e.Request.Form {
if !strings.HasPrefix(key, "html[") || !strings.HasSuffix(key, "]") {
continue
}
htmlKey := strings.TrimSuffix(strings.TrimPrefix(key, "html["), "]")
if htmlKey == "" || len(values) == 0 {
continue
}
htmlUpdates[htmlKey] = values[0]
}
pagesCollection, err := app.FindCollectionByNameOrId(dbmodels.PAGES_TABLE)
if err != nil {
app.Logger().Error("Failed to load pages collection", "error", err)
return p.redirectError(e, "Speichern fehlgeschlagen.")
}
pageRecord, err := app.FindFirstRecordByData(pagesCollection.Id, dbmodels.KEY_FIELD, pageKey)
if err != nil || pageRecord == nil {
return p.redirectError(e, "Seite nicht gefunden.")
}
data := map[string]any{}
if existing := pageRecord.Get(dbmodels.DATA_FIELD); existing != nil {
switch value := existing.(type) {
case map[string]any:
data = value
case types.JSONRaw:
if err := json.Unmarshal(value, &data); err != nil {
app.Logger().Error("Failed to unmarshal page data", "error", err)
}
}
}
data["description"] = description
data["keywords"] = keywords
pageRecord.Set(dbmodels.TITLE_FIELD, title)
pageRecord.Set(dbmodels.DATA_FIELD, data)
htmlCollection, err := app.FindCollectionByNameOrId(dbmodels.HTML_TABLE)
if err != nil {
app.Logger().Error("Failed to load html collection", "error", err)
return p.redirectError(e, "Speichern fehlgeschlagen.")
}
if err := app.RunInTransaction(func(tx core.App) error {
if err := tx.Save(pageRecord); err != nil {
return err
}
for key, value := range htmlUpdates {
record, _ := tx.FindFirstRecordByData(htmlCollection.Id, dbmodels.KEY_FIELD, key)
if record == nil {
record = core.NewRecord(htmlCollection)
record.Set(dbmodels.KEY_FIELD, key)
}
record.Set(dbmodels.HTML_FIELD, value)
if err := tx.Save(record); err != nil {
return err
}
}
return nil
}); err != nil {
app.Logger().Error("Failed to save page data", "page", pageKey, "error", err)
return p.redirectError(e, "Speichern fehlgeschlagen.")
}
go ia.ResetPagesCache()
go ia.ResetHtmlCache()
redirect := fmt.Sprintf("%s?key=%s&success=%s", URL_SEITEN_EDITOR, url.QueryEscape(pageKey), url.QueryEscape("Änderungen gespeichert."))
return e.Redirect(http.StatusSeeOther, redirect)
}
}
func (p *SeitenEditPage) redirectError(e *core.RequestEvent, message string) error {
redirect := fmt.Sprintf("%s?error=%s", URL_SEITEN_EDITOR, url.QueryEscape(message))
return e.Redirect(http.StatusSeeOther, redirect)
}
func seitenEditorData(e *core.RequestEvent, app core.App) (map[string]any, error) {
pages, err := seitenEditorPages(app)
if err != nil {
return nil, err
}
selectedKey := strings.TrimSpace(e.Request.URL.Query().Get("key"))
if selectedKey == "" && len(pages) > 0 {
selectedKey = pages[0].Key
}
var selected *SeitenEditorSelected
if selectedKey != "" {
selected, err = seitenEditorSelected(app, selectedKey)
if err != nil {
return nil, err
}
}
data := map[string]any{
"pages": pages,
"selected": selected,
"selected_key": selectedKey,
}
req := templating.NewRequest(e)
if req.Session() != nil {
data["csrf_token"] = req.Session().Token
} else {
data["csrf_token"] = ""
}
if msg := e.Request.URL.Query().Get("success"); msg != "" {
data["success"] = msg
}
if msg := e.Request.URL.Query().Get("error"); msg != "" {
data["error"] = msg
}
return data, nil
}
func seitenEditorPages(app core.App) ([]SeitenEditorPageItem, error) {
pages, err := dbmodels.Pages_All(app)
if err != nil {
return nil, err
}
items := make([]SeitenEditorPageItem, 0, len(pages))
for _, page := range pages {
title := strings.TrimSpace(page.Title())
if title == "" {
title = page.Key()
}
items = append(items, SeitenEditorPageItem{
Key: page.Key(),
Title: title,
})
}
slices.SortFunc(items, func(a, b SeitenEditorPageItem) int {
return strings.Compare(strings.ToLower(a.Title), strings.ToLower(b.Title))
})
return items, nil
}
func seitenEditorSelected(app core.App, key string) (*SeitenEditorSelected, error) {
page, err := dbmodels.TableByField[dbmodels.Page](app, dbmodels.PAGES_TABLE, dbmodels.KEY_FIELD, key)
if err != nil {
return nil, err
}
description := ""
keywords := ""
if data := (&page).Data(); data != nil {
if value, ok := data["description"]; ok && value != nil {
description = fmt.Sprint(value)
}
if value, ok := data["keywords"]; ok && value != nil {
keywords = fmt.Sprint(value)
}
}
sections, err := seitenEditorSections(app, key)
if err != nil {
return nil, err
}
return &SeitenEditorSelected{
Key: key,
Title: page.Title(),
Description: description,
Keywords: keywords,
Sections: sections,
}, nil
}
func seitenEditorSections(app core.App, key string) ([]SeitenEditorSection, error) {
prefix := "page." + key
records := make([]*dbmodels.HTML, 0)
err := app.RecordQuery(dbmodels.HTML_TABLE).
Where(dbx.NewExp(dbmodels.KEY_FIELD+" LIKE {:prefix}", dbx.Params{"prefix": prefix + "%"})).
All(&records)
if err != nil {
return nil, err
}
sections := make([]SeitenEditorSection, 0, len(records))
for _, record := range records {
section := strings.TrimPrefix(record.Key(), prefix)
section = strings.TrimPrefix(section, pageHTMLPrefixSeparator)
label := "Inhalt"
if section != "" {
label = strings.ReplaceAll(section, "_", " ")
}
sections = append(sections, SeitenEditorSection{
Key: record.Key(),
Label: label,
HTML: seitenEditorHTMLValue(record.HTML()),
})
}
slices.SortFunc(sections, func(a, b SeitenEditorSection) int {
return strings.Compare(a.Key, b.Key)
})
return sections, nil
}
func seitenEditorHTMLValue(value any) string {
switch v := value.(type) {
case string:
return v
case []byte:
return string(v)
case types.JSONRaw:
return string(v)
default:
return fmt.Sprint(value)
}
}

View File

@@ -1,6 +1,10 @@
package dbmodels
import "github.com/pocketbase/pocketbase/core"
import (
"log/slog"
"github.com/pocketbase/pocketbase/core"
)
type Page struct {
core.BaseRecordProxy
@@ -59,7 +63,19 @@ func (p *Page) Data() map[string]interface{} {
if val == nil {
return nil
}
return val.(map[string]interface{})
if data, ok := val.(map[string]interface{}); ok {
return data
}
data := make(map[string]interface{})
if err := p.UnmarshalJSONField(DATA_FIELD, &data); err != nil {
slog.Error("Error unmarshalling page data", "error", err)
return nil
}
if len(data) == 0 {
return nil
}
return data
}
func (p *Page) SetData(data map[string]interface{}) {

View File

@@ -11,6 +11,7 @@ type IApp interface {
Core() core.App
ResetDataCache()
ResetHtmlCache()
ResetPagesCache()
Logger() *slog.Logger
}

View File

@@ -43,6 +43,8 @@ const (
P_USER_MGMT_NAME = "user_management"
P_SEITEN_EDIT_NAME = "seiten_edit"
P_ALMANACH_EDIT_NAME = "almanach_edit"
P_ALMANACH_NEW_NAME = "almanach_new"
P_REIHE_EDIT_NAME = "reihe_edit"

View File

@@ -8812,6 +8812,15 @@ class Qc extends HTMLElement {
<i class="ri-external-link-line text-base"></i>
</a>
</div>
<div class="grid grid-cols-[1fr_auto] group">
<a href="/redaktion/seiten/" class="flex items-center px-4 py-2 group-hover:bg-gray-100 transition-colors no-underline text-sm">
<i class="ri-pages-line text-base text-gray-700 mr-2.5"></i>
<span class="text-gray-900">Seiten</span>
</a>
<a href="/redaktion/seiten/" target="_blank" class="flex items-center justify-center px-3 py-2 group-hover:bg-gray-100 text-gray-700 hover:text-slate-900 transition-colors no-underline text-sm" title="In neuem Tab öffnen">
<i class="ri-external-link-line text-base"></i>
</a>
</div>
<div class="border-t border-gray-200 my-1"></div>
` : "", w = r ? `
<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
{{ block "body" . }}
{{ end }}

View File

@@ -0,0 +1,43 @@
{{ $model := . }}
<div class="flex container-normal bg-slate-100 mx-auto px-8">
<div class="flex flex-row w-full justify-between">
<div class="flex flex-col justify-end-safe flex-2/5">
<div class="mb-1">
<i class="ri-pages-line"></i> Seiten
</div>
<h1 class="text-2xl w-full font-bold text-slate-900 mb-1">
Seiteneditor
</h1>
</div>
<div class="flex flex-col justify-end gap-y-4 pr-4">
<div class="inputwrapper !mb-0">
<div class="inputlabelrow">
<label for="page-editor-select" class="inputlabel">Seite auswählen</label>
</div>
<select
id="page-editor-select"
name="key"
class="inputinput"
hx-get="/redaktion/seiten/form/"
hx-trigger="change"
hx-target="#page-editor-form"
hx-swap="outerHTML"
hx-indicator="body">
{{- if $model.pages -}}
{{- range $page := $model.pages -}}
<option value="{{ $page.Key }}" {{ if eq $page.Key $model.selected_key }}selected{{ end }}>{{ $page.Title }}</option>
{{- end -}}
{{- else -}}
<option value="">Keine Seiten gefunden</option>
{{- end -}}
</select>
</div>
</div>
</div>
</div>
<div class="container-normal mx-auto mt-4 !px-0">
{{ template "_page_form" $model }}
</div>

View File

@@ -0,0 +1,135 @@
{{ $model := . }}
<div id="page-editor-form">
{{ template "_usermessage" $model }}
{{- if not $model.selected -}}
<div class="text-gray-700 bg-slate-100 border border-slate-200 rounded-xs p-4">
Keine Seite ausgewählt.
</div>
{{- else -}}
<form
class="w-full dbform"
method="POST"
action="/redaktion/seiten/save/"
hx-boost="false">
<input type="hidden" name="csrf_token" value="{{ $model.csrf_token }}" />
<input type="hidden" name="page_key" value="{{ $model.selected.Key }}" />
<div class="flex flex-col gap-4">
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="page-title" class="inputlabel">Titel</label>
</div>
<input type="text" id="page-title" name="title" class="inputinput" value="{{ $model.selected.Title }}" autocomplete="off" />
</div>
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="page-description" class="inputlabel">Beschreibung</label>
</div>
<textarea id="page-description" name="description" class="inputinput" rows="3" autocomplete="off">{{ $model.selected.Description }}</textarea>
</div>
<div class="inputwrapper">
<div class="inputlabelrow">
<label for="page-keywords" class="inputlabel">Stichworte</label>
</div>
<input type="text" id="page-keywords" name="keywords" class="inputinput" value="{{ $model.selected.Keywords }}" autocomplete="off" />
</div>
<div class="flex items-center gap-2 text-lg font-bold text-gray-700 mt-4">
<i class="ri-file-edit-line"></i>
<span>Seiteninhalte</span>
</div>
{{- if not $model.selected.Sections -}}
<div class="text-gray-700 bg-slate-100 border border-slate-200 rounded-xs p-4">
Keine HTML-Bereiche gefunden.
</div>
{{- else -}}
{{- range $index, $section := $model.selected.Sections -}}
<div class="inputwrapper">
<div class="inputlabelrow">
<div class="flex flex-col">
<label for="page-html-{{ $index }}" class="inputlabel">{{ $section.Label }}</label>
<span class="text-xs text-gray-500">{{ $section.Key }}</span>
</div>
</div>
<trix-toolbar id="page-html-toolbar-{{ $index }}">
<div class="trix-toolbar-container">
<span class="trix-toolbar-group">
<button type="button" class="trix-toolbar-button" data-trix-attribute="bold" data-trix-key="b" title="Fett">
<i class="ri-bold"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-attribute="italic" data-trix-key="i" title="Kursiv">
<i class="ri-italic"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-attribute="strike" title="Gestrichen">
<i class="ri-strikethrough"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-attribute="href" data-trix-action="link" data-trix-key="k" title="Link">
<i class="ri-links-line"></i>
</button>
</span>
<span class="trix-toolbar-group">
<button type="button" class="trix-toolbar-button" data-trix-attribute="heading1" title="Überschrift">
<i class="ri-h-1"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-attribute="quote" title="Zitat">
<i class="ri-double-quotes-l"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-attribute="bullet" title="Liste">
<i class="ri-list-unordered"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-attribute="number" title="Aufzählung">
<i class="ri-list-ordered"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-action="decreaseNestingLevel" title="Einzug verkleinern">
<i class="ri-indent-decrease"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-action="increaseNestingLevel" title="Einzug vergrößern">
<i class="ri-indent-increase"></i>
</button>
</span>
<span class="trix-toolbar-group">
<button type="button" class="trix-toolbar-button" data-trix-action="undo" data-trix-key="z" title="Rückgängig">
<i class="ri-arrow-go-back-line"></i>
</button>
<button type="button" class="trix-toolbar-button" data-trix-action="redo" data-trix-key="shift+z" title="Wiederholen">
<i class="ri-arrow-go-forward-line"></i>
</button>
</span>
</div>
<div class="trix-dialogs" data-trix-dialogs>
<div class="trix-dialog trix-dialog--link" data-trix-dialog="href" data-trix-dialog-attribute="href">
<div class="trix-dialog__link-fields flex flex-row">
<input type="url" name="href" class="trix-input trix-input--dialog" placeholder="URL eingeben…" aria-label="URL" required data-trix-input>
<div class="trix-button-group flex-row">
<input type="button" class="trix-button trix-button--dialog" value="Link" data-trix-method="setAttribute">
<input type="button" class="trix-button trix-button--dialog" value="Unlink" data-trix-method="removeAttribute">
</div>
</div>
</div>
</div>
</trix-toolbar>
<textarea hidden id="page-html-{{ $index }}" name="html[{{ $section.Key }}]" autocomplete="off">{{- $section.HTML -}}</textarea>
<trix-editor input="page-html-{{ $index }}" toolbar="page-html-toolbar-{{ $index }}"></trix-editor>
</div>
{{- end -}}
{{- end -}}
<div class="flex justify-end mt-6">
<button type="submit" class="btn bg-slate-800 text-white px-4 py-2 rounded-xs hover:bg-slate-900">
<i class="ri-save-line"></i> Speichern
</button>
</div>
</div>
</form>
{{- end -}}
</div>

View File

@@ -0,0 +1 @@
{{ template "_page_form" . }}

View File

@@ -0,0 +1 @@
<title>Seiteneditor</title>

View File

@@ -188,6 +188,15 @@ export class FabMenu extends HTMLElement {
<i class="ri-external-link-line text-base"></i>
</a>
</div>
<div class="grid grid-cols-[1fr_auto] group">
<a href="/redaktion/seiten/" class="flex items-center px-4 py-2 group-hover:bg-gray-100 transition-colors no-underline text-sm">
<i class="ri-pages-line text-base text-gray-700 mr-2.5"></i>
<span class="text-gray-900">Seiten</span>
</a>
<a href="/redaktion/seiten/" target="_blank" class="flex items-center justify-center px-3 py-2 group-hover:bg-gray-100 text-gray-700 hover:text-slate-900 transition-colors no-underline text-sm" title="In neuem Tab öffnen">
<i class="ri-external-link-line text-base"></i>
</a>
</div>
<div class="border-t border-gray-200 my-1"></div>
`
: "";