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 := . }}
+
+
| + + | +Datei | +Typ/Größe | ++ + | +Link | +Aktion | +
|---|---|---|---|---|---|
|
+ {{ $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 -}} + | ++ + | +
| + + | +Vorschau | +Bild | ++ + | +Aktion | +
|---|---|---|---|---|
|
+ {{ $img.Title }}
+ {{- if $img.Description -}}
+ {{ Safe $img.Description }}
+ {{- end -}}
+ {{ $img.Key }}
+ |
+
+ {{- if $preview -}}
+
+ |
+
+ {{- if $image -}}
+
+ |
+ {{ GermanDate $img.Created }} {{ GermanTime $img.Created }} | ++ {{- if $image -}} + + {{- end -}} + + | +