From b66b3bcaeddc9fe2766344d0c5b60c55227f8099 Mon Sep 17 00:00:00 2001 From: Simon Martens Date: Tue, 27 Jan 2026 13:52:33 +0100 Subject: [PATCH] +Bilder, Files endpoint --- controllers/files_admin.go | 144 +++++ controllers/images_admin.go | 237 ++++++++ views/routes/components/_file_uploader.gohtml | 489 ++++++++++++++++ .../components/_file_uploader_list.gohtml | 63 +++ .../routes/components/_image_uploader.gohtml | 525 ++++++++++++++++++ .../components/_image_uploader_list.gohtml | 67 +++ .../components/_image_uploader_single.gohtml | 447 +++++++++++++++ .../_image_uploader_single_view.gohtml | 45 ++ .../components/file_uploader_list/body.gohtml | 1 + .../image_uploader_list/body.gohtml | 1 + .../image_uploader_single_view/body.gohtml | 1 + 11 files changed, 2020 insertions(+) create mode 100644 controllers/files_admin.go create mode 100644 controllers/images_admin.go create mode 100644 views/routes/components/_file_uploader.gohtml create mode 100644 views/routes/components/_file_uploader_list.gohtml create mode 100644 views/routes/components/_image_uploader.gohtml create mode 100644 views/routes/components/_image_uploader_list.gohtml create mode 100644 views/routes/components/_image_uploader_single.gohtml create mode 100644 views/routes/components/_image_uploader_single_view.gohtml create mode 100644 views/routes/components/file_uploader_list/body.gohtml create mode 100644 views/routes/components/image_uploader_list/body.gohtml create mode 100644 views/routes/components/image_uploader_single_view/body.gohtml diff --git a/controllers/files_admin.go b/controllers/files_admin.go new file mode 100644 index 0000000..8314d05 --- /dev/null +++ b/controllers/files_admin.go @@ -0,0 +1,144 @@ +package controllers + +import ( + "net/http" + "path/filepath" + "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/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/router" +) + +const ( + URL_FILES_ADMIN = "/redaktion/files/" + URL_FILES_LIST = "list/" + URL_FILES_UPLOAD = "upload/" + URL_FILES_DELETE = "delete/" + TEMPLATE_FILES_LIST = "/components/file_uploader_list/" + LAYOUT_FILES_FRAGMENT = "fragment" +) + +func init() { + app.Register(&FilesAdmin{}) +} + +type FilesAdmin struct{} + +func (p *FilesAdmin) Up(ia pagemodels.IApp, engine *templating.Engine) error { + return nil +} + +func (p *FilesAdmin) Down(ia pagemodels.IApp, engine *templating.Engine) error { + return nil +} + +func (p *FilesAdmin) Setup(router *router.Router[*core.RequestEvent], ia pagemodels.IApp, engine *templating.Engine) error { + app := ia.Core() + rg := router.Group(URL_FILES_ADMIN) + rg.BindFunc(middleware.Authenticated(app)) + rg.BindFunc(middleware.IsAdminOrEditor()) + rg.GET(URL_FILES_LIST, p.listHandler(engine, app)) + rg.POST(URL_FILES_UPLOAD, p.uploadHandler(engine, app)).Bind(apis.BodyLimit(100 << 20)) + rg.POST(URL_FILES_DELETE+"{id}", p.deleteHandler(engine, app)) + return nil +} + +func (p *FilesAdmin) listHandler(engine *templating.Engine, app core.App) HandleFunc { + return func(e *core.RequestEvent) error { + files, err := dbmodels.Files_All(app) + if err != nil { + return engine.Response500(e, err, nil) + } + + req := templating.NewRequest(e) + csrf := "" + if req.Session() != nil { + csrf = req.Session().Token + } + + data := map[string]any{ + "files": files, + "csrf_token": csrf, + } + + return engine.Response200(e, TEMPLATE_FILES_LIST, data, LAYOUT_FILES_FRAGMENT) + } +} + +func (p *FilesAdmin) uploadHandler(engine *templating.Engine, app core.App) HandleFunc { + return func(e *core.RequestEvent) error { + req := templating.NewRequest(e) + + if err := e.Request.ParseMultipartForm(32 << 20); err != nil { + return e.JSON(http.StatusBadRequest, map[string]any{"error": "Formulardaten ungültig."}) + } + + if err := req.CheckCSRF(e.Request.FormValue("csrf_token")); err != nil { + return e.JSON(http.StatusUnauthorized, map[string]any{"error": err.Error()}) + } + + files, err := e.FindUploadedFiles(dbmodels.FILE_FIELD) + if err != nil || len(files) == 0 { + return e.JSON(http.StatusBadRequest, map[string]any{"error": "Keine Datei ausgewählt."}) + } + + title := strings.TrimSpace(e.Request.FormValue("title")) + description := strings.TrimSpace(e.Request.FormValue("description")) + if title == "" { + base := strings.TrimSpace(files[0].OriginalName) + if base != "" { + title = strings.TrimSuffix(base, filepath.Ext(base)) + } + if title == "" { + title = files[0].OriginalName + } + } + + collection, err := app.FindCollectionByNameOrId(dbmodels.FILES_TABLE) + if err != nil { + return e.JSON(http.StatusInternalServerError, map[string]any{"error": "Dateiablage konnte nicht geladen werden."}) + } + + record := core.NewRecord(collection) + record.Set(dbmodels.TITLE_FIELD, title) + record.Set(dbmodels.DESCRIPTION_FIELD, description) + record.Set(dbmodels.FILE_FIELD, files[0]) + + if err := app.Save(record); err != nil { + return e.JSON(http.StatusInternalServerError, map[string]any{"error": err.Error()}) + } + + return e.JSON(http.StatusOK, map[string]any{"success": true, "message": "Datei hochgeladen.", "id": record.Id}) + } +} + +func (p *FilesAdmin) deleteHandler(engine *templating.Engine, app core.App) HandleFunc { + return func(e *core.RequestEvent) error { + req := templating.NewRequest(e) + if err := req.CheckCSRF(e.Request.FormValue("csrf_token")); err != nil { + return e.JSON(http.StatusUnauthorized, map[string]any{"error": err.Error()}) + } + + id := strings.TrimSpace(e.Request.PathValue("id")) + if id == "" { + return e.JSON(http.StatusBadRequest, map[string]any{"error": "Ungültige Datei-ID."}) + } + + record, err := app.FindRecordById(dbmodels.FILES_TABLE, id) + if err != nil || record == nil { + return e.JSON(http.StatusNotFound, map[string]any{"error": "Datei nicht gefunden."}) + } + + if err := app.Delete(record); err != nil { + return e.JSON(http.StatusInternalServerError, map[string]any{"error": err.Error()}) + } + + return e.JSON(http.StatusOK, map[string]any{"success": true, "message": "Datei gelöscht."}) + } +} diff --git a/controllers/images_admin.go b/controllers/images_admin.go new file mode 100644 index 0000000..4b32508 --- /dev/null +++ b/controllers/images_admin.go @@ -0,0 +1,237 @@ +package controllers + +import ( + "net/http" + "path/filepath" + "strings" + "unicode" + + "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/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/router" +) + +const ( + URL_IMAGES_ADMIN = "/redaktion/images/" + URL_IMAGES_LIST = "list/" + URL_IMAGES_UPLOAD = "upload/" + URL_IMAGES_DELETE = "delete/" + TEMPLATE_IMAGES_LIST = "/components/image_uploader_list/" +) + +func init() { + app.Register(&ImagesAdmin{}) +} + +type ImagesAdmin struct{} + +func (p *ImagesAdmin) Up(ia pagemodels.IApp, engine *templating.Engine) error { + return nil +} + +func (p *ImagesAdmin) Down(ia pagemodels.IApp, engine *templating.Engine) error { + return nil +} + +func (p *ImagesAdmin) Setup(router *router.Router[*core.RequestEvent], ia pagemodels.IApp, engine *templating.Engine) error { + app := ia.Core() + rg := router.Group(URL_IMAGES_ADMIN) + rg.BindFunc(middleware.Authenticated(app)) + rg.BindFunc(middleware.IsAdminOrEditor()) + rg.GET(URL_IMAGES_LIST, p.listHandler(engine, app)) + rg.POST(URL_IMAGES_UPLOAD, p.uploadHandler(engine, app)).Bind(apis.BodyLimit(100 << 20)) + rg.POST(URL_IMAGES_DELETE+"{id}", p.deleteHandler(engine, app)) + return nil +} + +func (p *ImagesAdmin) listHandler(engine *templating.Engine, app core.App) HandleFunc { + return func(e *core.RequestEvent) error { + key := strings.TrimSpace(e.Request.URL.Query().Get("key")) + if key != "" { + collection, err := app.FindCollectionByNameOrId(dbmodels.IMAGES_TABLE) + if err != nil { + return engine.Response500(e, err, nil) + } + record, err := app.FindFirstRecordByData(collection.Id, dbmodels.KEY_FIELD, key) + if err != nil { + return engine.Response500(e, err, nil) + } + var image *dbmodels.Image + if record != nil { + image = &dbmodels.Image{} + image.SetProxyRecord(record) + } + data := map[string]any{ + "image": image, + } + return engine.Response200(e, "/components/image_uploader_single_view/", data, LAYOUT_FRAGMENT) + } + + prefix := strings.TrimSpace(e.Request.URL.Query().Get("prefix")) + if prefix == "" { + return e.JSON(http.StatusBadRequest, map[string]any{"error": "Prefix fehlt."}) + } + + keyPrefix := imageKeyPrefix(prefix) + images, err := dbmodels.Images_KeyPrefix(app, keyPrefix) + if err != nil { + return engine.Response500(e, err, nil) + } + + data := map[string]any{ + "images": images, + } + + return engine.Response200(e, TEMPLATE_IMAGES_LIST, data, LAYOUT_FRAGMENT) + } +} + +func (p *ImagesAdmin) uploadHandler(engine *templating.Engine, app core.App) HandleFunc { + return func(e *core.RequestEvent) error { + req := templating.NewRequest(e) + + if err := e.Request.ParseMultipartForm(32 << 20); err != nil { + return e.JSON(http.StatusBadRequest, map[string]any{"error": "Formulardaten ungültig."}) + } + + if err := req.CheckCSRF(e.Request.FormValue("csrf_token")); err != nil { + return e.JSON(http.StatusUnauthorized, map[string]any{"error": err.Error()}) + } + + key := strings.TrimSpace(e.Request.FormValue("key")) + prefix := strings.TrimSpace(e.Request.FormValue("prefix")) + if key == "" && prefix == "" { + return e.JSON(http.StatusBadRequest, map[string]any{"error": "Prefix fehlt."}) + } + if key != "" && strings.ContainsAny(key, " \t") { + return e.JSON(http.StatusBadRequest, map[string]any{"error": "Schlüssel darf keine Leerzeichen enthalten."}) + } + + imageFiles, err := e.FindUploadedFiles(dbmodels.IMAGE_FIELD) + if err != nil || len(imageFiles) == 0 { + return e.JSON(http.StatusBadRequest, map[string]any{"error": "Bitte ein Bild auswählen."}) + } + previewFiles, _ := e.FindUploadedFiles(dbmodels.PREVIEW_FIELD) + + title := strings.TrimSpace(e.Request.FormValue("title")) + description := strings.TrimSpace(e.Request.FormValue("description")) + if title == "" { + base := strings.TrimSpace(imageFiles[0].OriginalName) + if base != "" { + title = strings.TrimSuffix(base, filepath.Ext(base)) + } + } + if title == "" { + title = "Bild" + } + + if key == "" { + keyPrefix := imageKeyPrefix(prefix) + key = keyPrefix + slugify(title) + } + + collection, err := app.FindCollectionByNameOrId(dbmodels.IMAGES_TABLE) + if err != nil { + return e.JSON(http.StatusInternalServerError, map[string]any{"error": "Bildsammlung konnte nicht geladen werden."}) + } + + record, _ := app.FindFirstRecordByData(collection.Id, dbmodels.KEY_FIELD, key) + if record == nil { + record = core.NewRecord(collection) + record.Set(dbmodels.KEY_FIELD, key) + } + record.Set(dbmodels.TITLE_FIELD, title) + record.Set(dbmodels.DESCRIPTION_FIELD, description) + record.Set(dbmodels.IMAGE_FIELD, imageFiles[0]) + if len(previewFiles) > 0 { + record.Set(dbmodels.PREVIEW_FIELD, previewFiles[0]) + } + + if err := app.Save(record); err != nil { + return e.JSON(http.StatusInternalServerError, map[string]any{"error": err.Error()}) + } + + return e.JSON(http.StatusOK, map[string]any{"success": true, "message": "Bild gespeichert.", "id": record.Id}) + } +} + +func (p *ImagesAdmin) deleteHandler(engine *templating.Engine, app core.App) HandleFunc { + return func(e *core.RequestEvent) error { + req := templating.NewRequest(e) + if err := req.CheckCSRF(e.Request.FormValue("csrf_token")); err != nil { + return e.JSON(http.StatusUnauthorized, map[string]any{"error": err.Error()}) + } + + id := strings.TrimSpace(e.Request.PathValue("id")) + if id == "" { + return e.JSON(http.StatusBadRequest, map[string]any{"error": "Ungültige Bild-ID."}) + } + + record, err := app.FindRecordById(dbmodels.IMAGES_TABLE, id) + if err != nil || record == nil { + return e.JSON(http.StatusNotFound, map[string]any{"error": "Bild nicht gefunden."}) + } + + if err := app.Delete(record); err != nil { + return e.JSON(http.StatusInternalServerError, map[string]any{"error": err.Error()}) + } + + return e.JSON(http.StatusOK, map[string]any{"success": true, "message": "Bild gelöscht."}) + } +} + +func imageKeyPrefix(prefix string) string { + p := strings.TrimSpace(prefix) + p = strings.TrimSuffix(p, ".") + if strings.HasSuffix(p, ".image") { + p += "." + } + if !strings.HasSuffix(p, ".image.") { + p += ".image." + } + return p +} + +func slugify(input string) string { + replace := map[rune]string{ + 'ä': "ae", + 'ö': "oe", + 'ü': "ue", + 'ß': "ss", + 'Ä': "ae", + 'Ö': "oe", + 'Ü': "ue", + } + var b strings.Builder + lastDash := false + for _, r := range input { + if rep, ok := replace[r]; ok { + if b.Len() > 0 && !lastDash { + b.WriteByte('-') + lastDash = true + } + b.WriteString(rep) + lastDash = false + continue + } + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(unicode.ToLower(r)) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + return "bild" + } + return out +} diff --git a/views/routes/components/_file_uploader.gohtml b/views/routes/components/_file_uploader.gohtml new file mode 100644 index 0000000..d631bde --- /dev/null +++ b/views/routes/components/_file_uploader.gohtml @@ -0,0 +1,489 @@ +{{ $model := . }} + + + + + + + + diff --git a/views/routes/components/_file_uploader_list.gohtml b/views/routes/components/_file_uploader_list.gohtml new file mode 100644 index 0000000..898c97f --- /dev/null +++ b/views/routes/components/_file_uploader_list.gohtml @@ -0,0 +1,63 @@ +{{ $model := . }} + +{{- if not $model.files -}} +
Noch keine Dateien hochgeladen.
+{{- else -}} + + + + + + + + + + + + + {{- range $_, $file := $model.files -}} + {{- $url := $file.PublicURL -}} + + + + + + + + + {{- end -}} + +
+ + DateiTyp/Größe + + LinkAktion
+
{{ $file.Title }}
+ {{- if $file.Description -}} +
{{ $file.Description }}
+ {{- end -}} +
{{ $file.FileField }} + {{- if $url -}} + + lädt… + + {{- end -}} + {{ GermanDate $file.Created }} {{ GermanTime $file.Created }} + {{- if $url -}} + Link + + {{- end -}} + + +
+{{- end -}} diff --git a/views/routes/components/_image_uploader.gohtml b/views/routes/components/_image_uploader.gohtml new file mode 100644 index 0000000..4eacebd --- /dev/null +++ b/views/routes/components/_image_uploader.gohtml @@ -0,0 +1,525 @@ +{{ $model := . }} + + + + + + + + diff --git a/views/routes/components/_image_uploader_list.gohtml b/views/routes/components/_image_uploader_list.gohtml new file mode 100644 index 0000000..e13076e --- /dev/null +++ b/views/routes/components/_image_uploader_list.gohtml @@ -0,0 +1,67 @@ +{{ $model := . }} + +{{- if not $model.images -}} +
Noch keine Bilder vorhanden.
+{{- else -}} + + + + + + + + + + + + {{- range $_, $img := $model.images -}} + {{- $preview := $img.PreviewPath -}} + {{- $image := $img.ImagePath -}} + + + + + + + + {{- end -}} + +
+ + VorschauBild + + Aktion
+
{{ $img.Title }}
+ {{- if $img.Description -}} +
{{ Safe $img.Description }}
+ {{- end -}} +
{{ $img.Key }}
+
+ {{- if $preview -}} + + + + {{- end -}} + + {{- if $image -}} + + + + {{- end -}} + {{ GermanDate $img.Created }} {{ GermanTime $img.Created }} + {{- if $image -}} + + {{- end -}} + +
+{{- end -}} diff --git a/views/routes/components/_image_uploader_single.gohtml b/views/routes/components/_image_uploader_single.gohtml new file mode 100644 index 0000000..7016271 --- /dev/null +++ b/views/routes/components/_image_uploader_single.gohtml @@ -0,0 +1,447 @@ +{{ $model := . }} + + + + + + + + diff --git a/views/routes/components/_image_uploader_single_view.gohtml b/views/routes/components/_image_uploader_single_view.gohtml new file mode 100644 index 0000000..7ff88cb --- /dev/null +++ b/views/routes/components/_image_uploader_single_view.gohtml @@ -0,0 +1,45 @@ +{{ $model := . }} + +{{- if not $model.image -}} +
Kein Bild vorhanden.
+{{- else -}} + {{- $preview := $model.image.PreviewPath -}} + {{- $image := $model.image.ImagePath -}} +
+
{{ $model.image.Title }}
+ {{- if $model.image.Description -}} +
{{ Safe $model.image.Description }}
+ {{- end -}} +
{{ $model.image.Key }}
+
+
+
+
Vorschau
+ {{- if $preview -}} + + + + {{- else -}} + + {{- end -}} +
+
+
Bild
+ {{- if $image -}} + + + + {{- else -}} + + {{- end -}} +
+
+
+ {{ GermanDate $model.image.Created }} {{ GermanTime $model.image.Created }} + {{- if $image -}} + + {{- end -}} +
+{{- end -}} diff --git a/views/routes/components/file_uploader_list/body.gohtml b/views/routes/components/file_uploader_list/body.gohtml new file mode 100644 index 0000000..6b51787 --- /dev/null +++ b/views/routes/components/file_uploader_list/body.gohtml @@ -0,0 +1 @@ +{{ template "_file_uploader_list" . }} diff --git a/views/routes/components/image_uploader_list/body.gohtml b/views/routes/components/image_uploader_list/body.gohtml new file mode 100644 index 0000000..8cd1366 --- /dev/null +++ b/views/routes/components/image_uploader_list/body.gohtml @@ -0,0 +1 @@ +{{ template "_image_uploader_list" . }} diff --git a/views/routes/components/image_uploader_single_view/body.gohtml b/views/routes/components/image_uploader_single_view/body.gohtml new file mode 100644 index 0000000..3c4d920 --- /dev/null +++ b/views/routes/components/image_uploader_single_view/body.gohtml @@ -0,0 +1 @@ +{{ template "_image_uploader_single_view" . }}