mirror of
https://github.com/Theodor-Springmann-Stiftung/musenalm.git
synced 2026-02-04 10:35:30 +00:00
+Bilder, Files endpoint
This commit is contained in:
144
controllers/files_admin.go
Normal file
144
controllers/files_admin.go
Normal file
@@ -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."})
|
||||||
|
}
|
||||||
|
}
|
||||||
237
controllers/images_admin.go
Normal file
237
controllers/images_admin.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
489
views/routes/components/_file_uploader.gohtml
Normal file
489
views/routes/components/_file_uploader.gohtml
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
{{ $model := . }}
|
||||||
|
|
||||||
|
<file-uploader
|
||||||
|
class="block"
|
||||||
|
data-upload-url="/redaktion/files/upload/"
|
||||||
|
data-list-url="/redaktion/files/list/"
|
||||||
|
data-delete-url="/redaktion/files/delete/"
|
||||||
|
data-csrf="{{ $model.csrf_token }}">
|
||||||
|
<button type="button" class="w-full flex items-center justify-between rounded-md border border-slate-300 bg-white px-4 py-3 text-lg font-semibold text-slate-900 hover:bg-slate-50" data-role="collapse-toggle">
|
||||||
|
<span>Dateien</span>
|
||||||
|
<i class="ri-arrow-down-s-line transition-transform" data-role="collapse-icon"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mt-4 hidden" data-role="collapse-panel">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">Dateien</h2>
|
||||||
|
<p class="text-sm text-slate-600 mt-1">
|
||||||
|
Dateien sind öffentlich erreichbar. Verwenden Sie den Link in den Seiteninhalten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="inline-flex items-center gap-2 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" data-role="toggle-upload">
|
||||||
|
<i class="ri-add-line"></i> Datei hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 hidden grid grid-cols-1 md:grid-cols-12 gap-3 items-end" data-role="upload-form">
|
||||||
|
<div class="md:col-span-4 flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Datei</label>
|
||||||
|
<div class="border border-slate-300 rounded-md px-3 py-2 text-sm text-slate-600 bg-white min-h-[2.5rem] flex items-center"
|
||||||
|
data-role="dropzone">
|
||||||
|
<div class="flex items-center justify-between gap-3 w-full">
|
||||||
|
<span data-role="dropzone-text">Datei hier ablegen oder auswählen</span>
|
||||||
|
<button type="button" class="text-sm font-semibold text-slate-700 hover:text-slate-900" data-role="choose-button">Auswählen</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" class="hidden" data-role="file-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-3 flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Titel</label>
|
||||||
|
<input type="text" class="block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-slate-500 focus:ring-2 focus:ring-slate-400/30 min-h-[2.5rem]" placeholder="(Optional, sonst Dateiname)" data-role="title-input" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-4 flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Beschreibung</label>
|
||||||
|
<input type="text" class="block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-slate-500 focus:ring-2 focus:ring-slate-400/30 min-h-[2.5rem]" data-role="description-input" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-1 flex items-center">
|
||||||
|
<tool-tip position="top" class="inline-block w-full">
|
||||||
|
<button type="button" class="w-full inline-flex items-center justify-center gap-2 rounded-md bg-slate-900 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50 disabled:opacity-60" data-role="upload-button">
|
||||||
|
<i class="ri-upload-2-line"></i>
|
||||||
|
</button>
|
||||||
|
<div class="data-tip">Datei hochladen</div>
|
||||||
|
</tool-tip>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-12">
|
||||||
|
<span class="text-sm text-slate-600" data-role="status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 max-h-96 overflow-auto rounded-sm border border-slate-200 bg-white p-3" data-role="file-list">
|
||||||
|
{{ template "_file_uploader_list" $model }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</file-uploader>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
if (window.FileUploaderDefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.FileUploaderDefined = true;
|
||||||
|
|
||||||
|
class FileUploader extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
this.uploadUrl = this.dataset.uploadUrl || "";
|
||||||
|
this.listUrl = this.dataset.listUrl || "";
|
||||||
|
this.deleteUrl = this.dataset.deleteUrl || "";
|
||||||
|
this.csrf = this.dataset.csrf || "";
|
||||||
|
this.form = this.querySelector("[data-role='upload-form']");
|
||||||
|
this.collapseToggle = this.querySelector("[data-role='collapse-toggle']");
|
||||||
|
this.collapsePanel = this.querySelector("[data-role='collapse-panel']");
|
||||||
|
this.collapseIcon = this.querySelector("[data-role='collapse-icon']");
|
||||||
|
this.toggleButton = this.querySelector("[data-role='toggle-upload']");
|
||||||
|
this.fileInput = this.querySelector("[data-role='file-input']");
|
||||||
|
this.dropzone = this.querySelector("[data-role='dropzone']");
|
||||||
|
this.dropzoneText = this.querySelector("[data-role='dropzone-text']");
|
||||||
|
this.chooseButton = this.querySelector("[data-role='choose-button']");
|
||||||
|
this.titleInput = this.querySelector("[data-role='title-input']");
|
||||||
|
this.descriptionInput = this.querySelector("[data-role='description-input']");
|
||||||
|
this.list = this.querySelector("[data-role='file-list']");
|
||||||
|
this.status = this.querySelector("[data-role='status']");
|
||||||
|
this.uploadButton = this.querySelector("[data-role='upload-button']");
|
||||||
|
|
||||||
|
if (this.uploadButton) {
|
||||||
|
this.uploadButton.addEventListener("click", (event) => this.handleUpload(event));
|
||||||
|
}
|
||||||
|
if (this.collapseToggle) {
|
||||||
|
this.collapseToggle.addEventListener("click", () => this.togglePanel());
|
||||||
|
}
|
||||||
|
if (this.toggleButton && this.form) {
|
||||||
|
this.toggleButton.addEventListener("click", () => this.toggleForm());
|
||||||
|
}
|
||||||
|
if (this.chooseButton && this.fileInput) {
|
||||||
|
this.chooseButton.addEventListener("click", () => this.fileInput.click());
|
||||||
|
}
|
||||||
|
if (this.fileInput) {
|
||||||
|
this.fileInput.addEventListener("change", () => this.updateDropzone());
|
||||||
|
}
|
||||||
|
if (this.dropzone) {
|
||||||
|
this.dropzone.addEventListener("dragover", (event) => this.onDragOver(event));
|
||||||
|
this.dropzone.addEventListener("dragleave", (event) => this.onDragLeave(event));
|
||||||
|
this.dropzone.addEventListener("drop", (event) => this.onDrop(event));
|
||||||
|
}
|
||||||
|
this.addEventListener("click", (event) => this.handleClick(event));
|
||||||
|
this.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}, true);
|
||||||
|
this.hydrateMeta();
|
||||||
|
this.initSort();
|
||||||
|
this.applyDefaultSort();
|
||||||
|
this.normalizeUrls();
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(message, isError) {
|
||||||
|
if (!this.status) return;
|
||||||
|
this.status.textContent = message || "";
|
||||||
|
this.status.classList.remove("text-red-600", "text-green-600");
|
||||||
|
if (isError) {
|
||||||
|
this.status.classList.add("text-red-600");
|
||||||
|
} else if (message) {
|
||||||
|
this.status.classList.add("text-green-600");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleUpload(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
if (!this.uploadUrl || !this.form) return;
|
||||||
|
this.setStatus("Upload läuft…");
|
||||||
|
if (this.uploadButton) this.uploadButton.disabled = true;
|
||||||
|
if (!this.fileInput || !this.fileInput.files || this.fileInput.files.length === 0) {
|
||||||
|
this.setStatus("Bitte eine Datei auswählen.", true);
|
||||||
|
if (this.uploadButton) this.uploadButton.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = new FormData();
|
||||||
|
payload.append("file", this.fileInput.files[0]);
|
||||||
|
if (this.titleInput && this.titleInput.value) {
|
||||||
|
payload.append("title", this.titleInput.value);
|
||||||
|
}
|
||||||
|
if (this.descriptionInput && this.descriptionInput.value) {
|
||||||
|
payload.append("description", this.descriptionInput.value);
|
||||||
|
}
|
||||||
|
payload.set("csrf_token", this.csrf);
|
||||||
|
|
||||||
|
const response = await fetch(this.uploadUrl, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await this.extractError(response);
|
||||||
|
this.setStatus(message || "Upload fehlgeschlagen.", true);
|
||||||
|
if (this.uploadButton) this.uploadButton.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await this.safeJson(response);
|
||||||
|
if (json && json.error) {
|
||||||
|
this.setStatus(json.error, true);
|
||||||
|
if (this.uploadButton) this.uploadButton.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.fileInput) this.fileInput.value = "";
|
||||||
|
this.updateDropzone();
|
||||||
|
if (this.titleInput) this.titleInput.value = "";
|
||||||
|
if (this.descriptionInput) this.descriptionInput.value = "";
|
||||||
|
this.setStatus((json && json.message) || "Datei hochgeladen.");
|
||||||
|
this.lastUploadedId = json && json.id ? json.id : "";
|
||||||
|
await this.refreshList();
|
||||||
|
if (this.form) this.form.classList.add("hidden");
|
||||||
|
if (this.uploadButton) this.uploadButton.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleClick(event) {
|
||||||
|
const target = event.target.closest("[data-action]");
|
||||||
|
if (!target) return;
|
||||||
|
const action = target.getAttribute("data-action");
|
||||||
|
if (action === "copy") {
|
||||||
|
const url = target.getAttribute("data-url");
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
this.setStatus("Link kopiert.");
|
||||||
|
} catch {
|
||||||
|
this.setStatus("Link kopieren fehlgeschlagen.", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action === "delete") {
|
||||||
|
const id = target.getAttribute("data-id");
|
||||||
|
if (!id || !this.deleteUrl) return;
|
||||||
|
if (!confirm("Datei wirklich löschen?")) return;
|
||||||
|
const payload = new FormData();
|
||||||
|
payload.set("csrf_token", this.csrf);
|
||||||
|
const response = await fetch(`${this.deleteUrl}${id}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await this.extractError(response);
|
||||||
|
this.setStatus(message || "Löschen fehlgeschlagen.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json = await this.safeJson(response);
|
||||||
|
if (json && json.error) {
|
||||||
|
this.setStatus(json.error, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setStatus((json && json.message) || "Datei gelöscht.");
|
||||||
|
await this.refreshList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshList() {
|
||||||
|
if (!this.listUrl || !this.list) return;
|
||||||
|
if (window.htmx && typeof window.htmx.ajax === "function") {
|
||||||
|
window.htmx.ajax("GET", this.listUrl, {
|
||||||
|
target: this.list,
|
||||||
|
swap: "innerHTML",
|
||||||
|
}).then(() => {
|
||||||
|
this.hydrateMeta();
|
||||||
|
this.initSort();
|
||||||
|
this.applyDefaultSort();
|
||||||
|
this.normalizeUrls();
|
||||||
|
this.highlightNew();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fetch(this.listUrl, { credentials: "same-origin" });
|
||||||
|
if (!response.ok) {
|
||||||
|
this.setStatus("Aktualisieren fehlgeschlagen.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const html = await response.text();
|
||||||
|
this.list.innerHTML = html;
|
||||||
|
this.hydrateMeta();
|
||||||
|
this.initSort();
|
||||||
|
this.applyDefaultSort();
|
||||||
|
this.normalizeUrls();
|
||||||
|
this.highlightNew();
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrateMeta() {
|
||||||
|
if (!this.list) return;
|
||||||
|
const items = Array.from(this.list.querySelectorAll("[data-role='file-meta']"));
|
||||||
|
if (items.length === 0) return;
|
||||||
|
items.forEach((item) => this.loadMeta(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeUrls() {
|
||||||
|
if (!this.list) return;
|
||||||
|
const origin = window.location.origin;
|
||||||
|
const withOrigin = (url) => {
|
||||||
|
if (!url) return url;
|
||||||
|
if (url.startsWith("http://") || url.startsWith("https://")) return url;
|
||||||
|
try {
|
||||||
|
return new URL(url, origin).toString();
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlNodes = Array.from(this.list.querySelectorAll("[data-url]"));
|
||||||
|
urlNodes.forEach((node) => {
|
||||||
|
const raw = node.getAttribute("data-url");
|
||||||
|
const abs = withOrigin(raw);
|
||||||
|
node.setAttribute("data-url", abs);
|
||||||
|
if (node.tagName === "A") {
|
||||||
|
node.setAttribute("href", abs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initSort() {
|
||||||
|
if (!this.list) return;
|
||||||
|
const buttons = Array.from(this.list.querySelectorAll("[data-role='file-sort']"));
|
||||||
|
if (buttons.length === 0) return;
|
||||||
|
buttons.forEach((button) => {
|
||||||
|
button.addEventListener("click", () => this.sortBy(button));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sortBy(button) {
|
||||||
|
if (!this.list) return;
|
||||||
|
const key = button.getAttribute("data-sort-key");
|
||||||
|
const table = this.list.querySelector("table");
|
||||||
|
const tbody = table ? table.querySelector("tbody") : null;
|
||||||
|
if (!tbody || !key) return;
|
||||||
|
|
||||||
|
const current = button.getAttribute("data-sort-dir") || "desc";
|
||||||
|
const next = current === "asc" ? "desc" : "asc";
|
||||||
|
button.setAttribute("data-sort-dir", next);
|
||||||
|
|
||||||
|
const rows = Array.from(tbody.querySelectorAll("tr"));
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
if (key === "created") {
|
||||||
|
const av = parseInt(a.getAttribute("data-created") || "0", 10);
|
||||||
|
const bv = parseInt(b.getAttribute("data-created") || "0", 10);
|
||||||
|
return next === "asc" ? av - bv : bv - av;
|
||||||
|
}
|
||||||
|
const at = (a.getAttribute("data-title") || "").toLowerCase();
|
||||||
|
const bt = (b.getAttribute("data-title") || "").toLowerCase();
|
||||||
|
if (at === bt) return 0;
|
||||||
|
const cmp = at < bt ? -1 : 1;
|
||||||
|
return next === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
rows.forEach((row) => tbody.appendChild(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDefaultSort() {
|
||||||
|
if (!this.list) return;
|
||||||
|
const createdButton = this.list.querySelector("[data-role='file-sort'][data-sort-key='created']");
|
||||||
|
if (!createdButton) return;
|
||||||
|
createdButton.setAttribute("data-sort-dir", "asc");
|
||||||
|
this.sortBy(createdButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightNew() {
|
||||||
|
if (!this.list || !this.lastUploadedId) return;
|
||||||
|
const row = this.list.querySelector(`tr[data-id="${this.lastUploadedId}"]`);
|
||||||
|
if (!row) return;
|
||||||
|
row.classList.add("bg-yellow-50");
|
||||||
|
setTimeout(() => row.classList.remove("bg-yellow-50"), 2000);
|
||||||
|
row.scrollIntoView({ block: "nearest" });
|
||||||
|
this.lastUploadedId = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleForm() {
|
||||||
|
if (!this.form) return;
|
||||||
|
this.form.classList.toggle("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePanel() {
|
||||||
|
if (!this.collapsePanel) return;
|
||||||
|
const isHidden = this.collapsePanel.classList.contains("hidden");
|
||||||
|
this.collapsePanel.classList.toggle("hidden");
|
||||||
|
if (this.collapseIcon) {
|
||||||
|
this.collapseIcon.classList.toggle("rotate-180", isHidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDropzone() {
|
||||||
|
if (!this.dropzoneText || !this.fileInput) return;
|
||||||
|
const file = this.fileInput.files && this.fileInput.files[0];
|
||||||
|
if (!file) {
|
||||||
|
this.dropzoneText.textContent = "Datei hier ablegen oder auswählen";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.dropzoneText.textContent = `${file.name} (${this.formatBytes(file.size) || "?"})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragOver(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.dropzone) {
|
||||||
|
this.dropzone.classList.add("border-slate-500", "bg-slate-50");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragLeave(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.dropzone) {
|
||||||
|
this.dropzone.classList.remove("border-slate-500", "bg-slate-50");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDrop(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!this.fileInput) return;
|
||||||
|
const files = event.dataTransfer?.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
this.fileInput.files = files;
|
||||||
|
this.updateDropzone();
|
||||||
|
this.onDragLeave(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMeta(item) {
|
||||||
|
const url = item.getAttribute("data-url");
|
||||||
|
const filename = item.getAttribute("data-filename") || "";
|
||||||
|
const value = item.querySelector("[data-role='file-meta-value']");
|
||||||
|
if (!url || !value) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { method: "HEAD", credentials: "same-origin" });
|
||||||
|
const typeHeader = response.headers.get("content-type") || "";
|
||||||
|
const lengthHeader = response.headers.get("content-length") || "";
|
||||||
|
const typeLabel = this.formatType(typeHeader, filename);
|
||||||
|
const sizeLabel = this.formatBytes(parseInt(lengthHeader || "0", 10));
|
||||||
|
value.textContent = sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel;
|
||||||
|
} catch {
|
||||||
|
value.textContent = this.formatType("", filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatType(mime, filename) {
|
||||||
|
if (mime) {
|
||||||
|
if (mime.includes("pdf")) return "PDF";
|
||||||
|
if (mime.startsWith("image/")) return "Bild";
|
||||||
|
if (mime.startsWith("audio/")) return "Audio";
|
||||||
|
if (mime.startsWith("video/")) return "Video";
|
||||||
|
if (mime.includes("zip")) return "ZIP";
|
||||||
|
if (mime.includes("msword") || mime.includes("officedocument.wordprocessingml")) return "DOC";
|
||||||
|
if (mime.includes("spreadsheetml")) return "XLS";
|
||||||
|
if (mime.includes("presentationml")) return "PPT";
|
||||||
|
return mime.split(";")[0].toUpperCase();
|
||||||
|
}
|
||||||
|
const ext = filename.split(".").pop();
|
||||||
|
if (!ext || ext === filename) return "Datei";
|
||||||
|
return ext.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
formatBytes(bytes) {
|
||||||
|
if (!bytes || Number.isNaN(bytes)) return "";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let size = bytes;
|
||||||
|
let unit = 0;
|
||||||
|
while (size >= 1024 && unit < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
const precision = unit === 0 ? 0 : 1;
|
||||||
|
return `${size.toFixed(precision)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async safeJson(response) {
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
if (!contentType.includes("application/json")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async extractError(response) {
|
||||||
|
const json = await this.safeJson(response);
|
||||||
|
if (json) {
|
||||||
|
if (json.error) {
|
||||||
|
return this.localizeError(json.error);
|
||||||
|
}
|
||||||
|
if (json.message) {
|
||||||
|
return this.localizeError(json.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text = await response.text().catch(() => "");
|
||||||
|
return this.localizeError(text.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
localizeError(message) {
|
||||||
|
if (!message) return message;
|
||||||
|
const lower = message.toLowerCase();
|
||||||
|
if (lower.includes("request entity too large") || lower.includes("entity too large")) {
|
||||||
|
return "Datei ist zu groß. Maximale Größe: 100 MB.";
|
||||||
|
}
|
||||||
|
if (lower.includes("maximum allowed file size")) {
|
||||||
|
return "Datei ist zu groß. Maximale Größe: 100 MB.";
|
||||||
|
}
|
||||||
|
if (lower.includes("mime type must be one of")) {
|
||||||
|
const parts = message.split("mime type must be one of:");
|
||||||
|
const list = parts.length > 1 ? parts[1].trim().replace(/\.+$/, "") : "";
|
||||||
|
return list
|
||||||
|
? `Dateityp nicht erlaubt. Erlaubte Typen: ${list}.`
|
||||||
|
: "Dateityp nicht erlaubt.";
|
||||||
|
}
|
||||||
|
if (lower.includes("failed to upload")) {
|
||||||
|
return "Datei-Upload fehlgeschlagen.";
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("file-uploader", FileUploader);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
63
views/routes/components/_file_uploader_list.gohtml
Normal file
63
views/routes/components/_file_uploader_list.gohtml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{{ $model := . }}
|
||||||
|
|
||||||
|
{{- if not $model.files -}}
|
||||||
|
<div class="text-sm text-gray-500">Noch keine Dateien hochgeladen.</div>
|
||||||
|
{{- else -}}
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-gray-600 border-b">
|
||||||
|
<th class="py-2 pr-4">
|
||||||
|
<button type="button" class="inline-flex items-center gap-1 text-sm font-semibold text-slate-700 hover:text-slate-900" data-role="file-sort" data-sort-key="title">
|
||||||
|
Titel
|
||||||
|
<i class="ri-arrow-up-down-line text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th class="py-2 pr-4">Datei</th>
|
||||||
|
<th class="py-2 pr-4">Typ/Größe</th>
|
||||||
|
<th class="py-2 pr-4">
|
||||||
|
<button type="button" class="inline-flex items-center gap-1 text-sm font-semibold text-slate-700 hover:text-slate-900" data-role="file-sort" data-sort-key="created">
|
||||||
|
Erstellt
|
||||||
|
<i class="ri-arrow-up-down-line text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th class="py-2 pr-4">Link</th>
|
||||||
|
<th class="py-2 pr-2 text-right">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{- range $_, $file := $model.files -}}
|
||||||
|
{{- $url := $file.PublicURL -}}
|
||||||
|
<tr class="border-b align-top" data-id="{{ $file.Id }}" data-title="{{ $file.Title }}" data-created="{{ $file.CreatedUnix }}">
|
||||||
|
<td class="py-2 pr-4">
|
||||||
|
<div class="font-semibold text-slate-900">{{ $file.Title }}</div>
|
||||||
|
{{- if $file.Description -}}
|
||||||
|
<div class="text-xs text-slate-500">{{ $file.Description }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 text-xs text-slate-600">{{ $file.FileField }}</td>
|
||||||
|
<td class="py-2 pr-4 text-xs text-slate-600">
|
||||||
|
{{- if $url -}}
|
||||||
|
<span data-role="file-meta" data-url="{{ $url }}" data-filename="{{ $file.FileField }}">
|
||||||
|
<span data-role="file-meta-value">lädt…</span>
|
||||||
|
</span>
|
||||||
|
{{- end -}}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 text-xs text-slate-600 whitespace-nowrap">{{ GermanDate $file.Created }} {{ GermanTime $file.Created }}</td>
|
||||||
|
<td class="py-2 pr-4 text-xs">
|
||||||
|
{{- if $url -}}
|
||||||
|
<a class="text-blue-700 hover:text-blue-900 underline" href="{{ $url }}" target="_blank" rel="noopener noreferrer" data-role="file-link" data-url="{{ $url }}">Link</a>
|
||||||
|
<button type="button" class="ml-2 text-slate-600 hover:text-slate-900" data-action="copy" data-url="{{ $url }}">
|
||||||
|
<i class="ri-file-copy-line"></i>
|
||||||
|
</button>
|
||||||
|
{{- end -}}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-2 text-right">
|
||||||
|
<button type="button" class="text-red-700 hover:text-red-900" data-action="delete" data-id="{{ $file.Id }}">
|
||||||
|
<i class="ri-delete-bin-line"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{- end -}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{- end -}}
|
||||||
525
views/routes/components/_image_uploader.gohtml
Normal file
525
views/routes/components/_image_uploader.gohtml
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
{{ $model := . }}
|
||||||
|
|
||||||
|
<image-uploader
|
||||||
|
class="block"
|
||||||
|
data-upload-url="/redaktion/images/upload/"
|
||||||
|
data-list-url="/redaktion/images/list/"
|
||||||
|
data-delete-url="/redaktion/images/delete/"
|
||||||
|
data-prefix="{{ $model.prefix }}"
|
||||||
|
data-csrf="{{ $model.csrf_token }}">
|
||||||
|
<button type="button" class="w-full flex items-center justify-between rounded-md border border-slate-300 bg-white px-4 py-3 text-lg font-semibold text-slate-900 hover:bg-slate-50" data-role="collapse-toggle">
|
||||||
|
<span>Bilder</span>
|
||||||
|
<i class="ri-arrow-down-s-line transition-transform" data-role="collapse-icon"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mt-4 hidden" data-role="collapse-panel">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">Bilder</h2>
|
||||||
|
<p class="text-sm text-slate-600 mt-1">
|
||||||
|
Bilder sind öffentlich erreichbar. Der Schlüssel wird automatisch aus dem Titel gebildet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="inline-flex items-center gap-2 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" data-role="toggle-upload">
|
||||||
|
<i class="ri-add-line"></i> Bild hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 hidden grid grid-cols-1 md:grid-cols-12 gap-3 items-end" data-role="upload-form">
|
||||||
|
<div class="md:col-span-4 flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Bild</label>
|
||||||
|
<div class="border border-slate-300 rounded-md px-3 py-2 text-sm text-slate-600 bg-white min-h-[2.5rem] flex items-center" data-role="image-dropzone">
|
||||||
|
<div class="flex items-center justify-between gap-3 w-full">
|
||||||
|
<span data-role="image-dropzone-text">Bild ablegen oder auswählen</span>
|
||||||
|
<button type="button" class="text-sm font-semibold text-slate-700 hover:text-slate-900" data-role="image-choose">Auswählen</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" class="hidden" data-role="image-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-4 flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Vorschau</label>
|
||||||
|
<div class="border border-slate-300 rounded-md px-3 py-2 text-sm text-slate-600 bg-white min-h-[2.5rem] flex items-center" data-role="preview-dropzone">
|
||||||
|
<div class="flex items-center justify-between gap-3 w-full">
|
||||||
|
<span data-role="preview-dropzone-text">Optional – Vorschau ablegen</span>
|
||||||
|
<button type="button" class="text-sm font-semibold text-slate-700 hover:text-slate-900" data-role="preview-choose">Auswählen</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" class="hidden" data-role="preview-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-4 flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Titel</label>
|
||||||
|
<input type="text" class="block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-slate-500 focus:ring-2 focus:ring-slate-400/30 min-h-[2.5rem]" data-role="title-input" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-12 flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Beschreibung</label>
|
||||||
|
<trix-toolbar data-role="description-toolbar">
|
||||||
|
<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="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>
|
||||||
|
</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 data-role="description-input" autocomplete="off"></textarea>
|
||||||
|
<trix-editor data-role="description-editor" class="rounded-md border border-slate-300 bg-white p-2"></trix-editor>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-12 flex items-center justify-end">
|
||||||
|
<tool-tip position="top" class="inline-block w-full">
|
||||||
|
<button type="button" class="inline-flex items-center justify-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50 disabled:opacity-60" data-role="upload-button">
|
||||||
|
<i class="ri-upload-2-line"></i>
|
||||||
|
</button>
|
||||||
|
<div class="data-tip">Bild hochladen</div>
|
||||||
|
</tool-tip>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-12">
|
||||||
|
<span class="text-sm text-slate-600" data-role="status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 max-h-96 overflow-auto rounded-sm border border-slate-200 bg-white p-3" data-role="image-list">
|
||||||
|
{{ template "_image_uploader_list" $model }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</image-uploader>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
if (window.ImageUploaderDefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.ImageUploaderDefined = true;
|
||||||
|
|
||||||
|
class ImageUploader extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
this.uploadUrl = this.dataset.uploadUrl || "";
|
||||||
|
this.listUrl = this.dataset.listUrl || "";
|
||||||
|
this.deleteUrl = this.dataset.deleteUrl || "";
|
||||||
|
this.imagePrefix = this.dataset.prefix || "";
|
||||||
|
this.csrf = this.dataset.csrf || "";
|
||||||
|
this.form = this.querySelector("[data-role='upload-form']");
|
||||||
|
this.list = this.querySelector("[data-role='image-list']");
|
||||||
|
this.status = this.querySelector("[data-role='status']");
|
||||||
|
this.uploadButton = this.querySelector("[data-role='upload-button']");
|
||||||
|
this.collapseToggle = this.querySelector("[data-role='collapse-toggle']");
|
||||||
|
this.collapsePanel = this.querySelector("[data-role='collapse-panel']");
|
||||||
|
this.collapseIcon = this.querySelector("[data-role='collapse-icon']");
|
||||||
|
this.toggleButton = this.querySelector("[data-role='toggle-upload']");
|
||||||
|
this.titleInput = this.querySelector("[data-role='title-input']");
|
||||||
|
this.descriptionInput = this.querySelector("[data-role='description-input']");
|
||||||
|
this.descriptionEditor = this.querySelector("[data-role='description-editor']");
|
||||||
|
this.descriptionToolbar = this.querySelector("[data-role='description-toolbar']");
|
||||||
|
this.imageInput = this.querySelector("[data-role='image-input']");
|
||||||
|
this.previewInput = this.querySelector("[data-role='preview-input']");
|
||||||
|
this.imageDropzone = this.querySelector("[data-role='image-dropzone']");
|
||||||
|
this.previewDropzone = this.querySelector("[data-role='preview-dropzone']");
|
||||||
|
this.imageDropText = this.querySelector("[data-role='image-dropzone-text']");
|
||||||
|
this.previewDropText = this.querySelector("[data-role='preview-dropzone-text']");
|
||||||
|
this.imageChoose = this.querySelector("[data-role='image-choose']");
|
||||||
|
this.previewChoose = this.querySelector("[data-role='preview-choose']");
|
||||||
|
|
||||||
|
if (this.uploadButton) {
|
||||||
|
this.uploadButton.addEventListener("click", (event) => this.handleUpload(event));
|
||||||
|
}
|
||||||
|
if (this.collapseToggle) {
|
||||||
|
this.collapseToggle.addEventListener("click", () => this.togglePanel());
|
||||||
|
}
|
||||||
|
if (this.toggleButton && this.form) {
|
||||||
|
this.toggleButton.addEventListener("click", () => this.toggleForm());
|
||||||
|
}
|
||||||
|
if (this.imageChoose && this.imageInput) {
|
||||||
|
this.imageChoose.addEventListener("click", () => this.imageInput.click());
|
||||||
|
}
|
||||||
|
if (this.previewChoose && this.previewInput) {
|
||||||
|
this.previewChoose.addEventListener("click", () => this.previewInput.click());
|
||||||
|
}
|
||||||
|
if (this.imageInput) {
|
||||||
|
this.imageInput.addEventListener("change", () => this.updateDropzone());
|
||||||
|
}
|
||||||
|
if (this.previewInput) {
|
||||||
|
this.previewInput.addEventListener("change", () => this.updateDropzone());
|
||||||
|
}
|
||||||
|
if (this.imageDropzone) {
|
||||||
|
this.imageDropzone.addEventListener("dragover", (event) => this.onDragOver(event, this.imageDropzone));
|
||||||
|
this.imageDropzone.addEventListener("dragleave", (event) => this.onDragLeave(event, this.imageDropzone));
|
||||||
|
this.imageDropzone.addEventListener("drop", (event) => this.onDrop(event, this.imageInput, "image"));
|
||||||
|
}
|
||||||
|
if (this.previewDropzone) {
|
||||||
|
this.previewDropzone.addEventListener("dragover", (event) => this.onDragOver(event, this.previewDropzone));
|
||||||
|
this.previewDropzone.addEventListener("dragleave", (event) => this.onDragLeave(event, this.previewDropzone));
|
||||||
|
this.previewDropzone.addEventListener("drop", (event) => this.onDrop(event, this.previewInput, "preview"));
|
||||||
|
}
|
||||||
|
this.addEventListener("click", (event) => this.handleClick(event));
|
||||||
|
this.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}, true);
|
||||||
|
this.initTrix();
|
||||||
|
this.initSort();
|
||||||
|
this.applyDefaultSort();
|
||||||
|
this.normalizeUrls();
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(message, isError) {
|
||||||
|
if (!this.status) return;
|
||||||
|
this.status.textContent = message || "";
|
||||||
|
this.status.classList.remove("text-red-600", "text-green-600");
|
||||||
|
if (isError) {
|
||||||
|
this.status.classList.add("text-red-600");
|
||||||
|
} else if (message) {
|
||||||
|
this.status.classList.add("text-green-600");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleUpload(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
if (!this.uploadUrl || !this.form) return;
|
||||||
|
if (!this.imagePrefix) {
|
||||||
|
this.setStatus("Prefix fehlt.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setStatus("Upload läuft…");
|
||||||
|
if (this.uploadButton) this.uploadButton.disabled = true;
|
||||||
|
if (!this.imageInput || !this.imageInput.files || this.imageInput.files.length === 0) {
|
||||||
|
this.setStatus("Bitte ein Bild auswählen.", true);
|
||||||
|
if (this.uploadButton) this.uploadButton.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = new FormData();
|
||||||
|
payload.append("image", this.imageInput.files[0]);
|
||||||
|
if (this.previewInput && this.previewInput.files && this.previewInput.files[0]) {
|
||||||
|
payload.append("preview", this.previewInput.files[0]);
|
||||||
|
}
|
||||||
|
payload.append("prefix", this.imagePrefix);
|
||||||
|
if (this.titleInput && this.titleInput.value) {
|
||||||
|
payload.append("title", this.titleInput.value);
|
||||||
|
}
|
||||||
|
if (this.descriptionInput && this.descriptionInput.value) {
|
||||||
|
payload.append("description", this.descriptionInput.value);
|
||||||
|
}
|
||||||
|
payload.set("csrf_token", this.csrf);
|
||||||
|
|
||||||
|
const response = await fetch(this.uploadUrl, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await this.extractError(response);
|
||||||
|
this.setStatus(message || "Upload fehlgeschlagen.", true);
|
||||||
|
if (this.uploadButton) this.uploadButton.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await this.safeJson(response);
|
||||||
|
if (json && json.error) {
|
||||||
|
this.setStatus(json.error, true);
|
||||||
|
if (this.uploadButton) this.uploadButton.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.imageInput) this.imageInput.value = "";
|
||||||
|
if (this.previewInput) this.previewInput.value = "";
|
||||||
|
this.updateDropzone();
|
||||||
|
if (this.titleInput) this.titleInput.value = "";
|
||||||
|
if (this.descriptionInput) this.descriptionInput.value = "";
|
||||||
|
if (this.descriptionEditor && this.descriptionEditor.editor) {
|
||||||
|
this.descriptionEditor.editor.setSelectedRange([0, this.descriptionEditor.editor.getDocument().getLength()]);
|
||||||
|
this.descriptionEditor.editor.deleteInDirection("forward");
|
||||||
|
}
|
||||||
|
this.setStatus((json && json.message) || "Bild gespeichert.");
|
||||||
|
this.lastUploadedId = json && json.id ? json.id : "";
|
||||||
|
await this.refreshList();
|
||||||
|
if (this.form) this.form.classList.add("hidden");
|
||||||
|
if (this.uploadButton) this.uploadButton.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleClick(event) {
|
||||||
|
const target = event.target.closest("[data-action]");
|
||||||
|
if (!target) return;
|
||||||
|
const action = target.getAttribute("data-action");
|
||||||
|
if (action === "copy") {
|
||||||
|
const url = target.getAttribute("data-url");
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
this.setStatus("Link kopiert.");
|
||||||
|
} catch {
|
||||||
|
this.setStatus("Link kopieren fehlgeschlagen.", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action === "delete") {
|
||||||
|
const id = target.getAttribute("data-id");
|
||||||
|
if (!id || !this.deleteUrl) return;
|
||||||
|
if (!confirm("Bild wirklich löschen?")) return;
|
||||||
|
const payload = new FormData();
|
||||||
|
payload.set("csrf_token", this.csrf);
|
||||||
|
const response = await fetch(`${this.deleteUrl}${id}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await this.extractError(response);
|
||||||
|
this.setStatus(message || "Löschen fehlgeschlagen.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json = await this.safeJson(response);
|
||||||
|
if (json && json.error) {
|
||||||
|
this.setStatus(json.error, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setStatus((json && json.message) || "Bild gelöscht.");
|
||||||
|
await this.refreshList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshList() {
|
||||||
|
if (!this.listUrl || !this.list) return;
|
||||||
|
const url = `${this.listUrl}?prefix=${encodeURIComponent(this.imagePrefix)}`;
|
||||||
|
if (window.htmx && typeof window.htmx.ajax === "function") {
|
||||||
|
window.htmx.ajax("GET", url, {
|
||||||
|
target: this.list,
|
||||||
|
swap: "innerHTML",
|
||||||
|
}).then(() => {
|
||||||
|
this.initSort();
|
||||||
|
this.applyDefaultSort();
|
||||||
|
this.normalizeUrls();
|
||||||
|
this.highlightNew();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (!response.ok) {
|
||||||
|
this.setStatus("Aktualisieren fehlgeschlagen.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const html = await response.text();
|
||||||
|
this.list.innerHTML = html;
|
||||||
|
this.initSort();
|
||||||
|
this.applyDefaultSort();
|
||||||
|
this.normalizeUrls();
|
||||||
|
this.highlightNew();
|
||||||
|
}
|
||||||
|
|
||||||
|
initSort() {
|
||||||
|
if (!this.list) return;
|
||||||
|
const buttons = Array.from(this.list.querySelectorAll("[data-role='image-sort']"));
|
||||||
|
if (buttons.length === 0) return;
|
||||||
|
buttons.forEach((button) => {
|
||||||
|
button.addEventListener("click", () => this.sortBy(button));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sortBy(button) {
|
||||||
|
if (!this.list) return;
|
||||||
|
const key = button.getAttribute("data-sort-key");
|
||||||
|
const table = this.list.querySelector("table");
|
||||||
|
const tbody = table ? table.querySelector("tbody") : null;
|
||||||
|
if (!tbody || !key) return;
|
||||||
|
const current = button.getAttribute("data-sort-dir") || "desc";
|
||||||
|
const next = current === "asc" ? "desc" : "asc";
|
||||||
|
button.setAttribute("data-sort-dir", next);
|
||||||
|
const rows = Array.from(tbody.querySelectorAll("tr"));
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
if (key === "created") {
|
||||||
|
const av = parseInt(a.getAttribute("data-created") || "0", 10);
|
||||||
|
const bv = parseInt(b.getAttribute("data-created") || "0", 10);
|
||||||
|
return next === "asc" ? av - bv : bv - av;
|
||||||
|
}
|
||||||
|
const at = (a.getAttribute("data-title") || "").toLowerCase();
|
||||||
|
const bt = (b.getAttribute("data-title") || "").toLowerCase();
|
||||||
|
if (at === bt) return 0;
|
||||||
|
const cmp = at < bt ? -1 : 1;
|
||||||
|
return next === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
rows.forEach((row) => tbody.appendChild(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDefaultSort() {
|
||||||
|
if (!this.list) return;
|
||||||
|
const createdButton = this.list.querySelector("[data-role='image-sort'][data-sort-key='created']");
|
||||||
|
if (!createdButton) return;
|
||||||
|
createdButton.setAttribute("data-sort-dir", "asc");
|
||||||
|
this.sortBy(createdButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightNew() {
|
||||||
|
if (!this.list || !this.lastUploadedId) return;
|
||||||
|
const row = this.list.querySelector(`tr[data-id="${this.lastUploadedId}"]`);
|
||||||
|
if (!row) return;
|
||||||
|
row.classList.add("bg-yellow-50");
|
||||||
|
setTimeout(() => row.classList.remove("bg-yellow-50"), 2000);
|
||||||
|
row.scrollIntoView({ block: "nearest" });
|
||||||
|
this.lastUploadedId = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeUrls() {
|
||||||
|
if (!this.list) return;
|
||||||
|
const origin = window.location.origin;
|
||||||
|
const withOrigin = (url) => {
|
||||||
|
if (!url) return url;
|
||||||
|
if (url.startsWith("http://") || url.startsWith("https://")) return url;
|
||||||
|
try {
|
||||||
|
return new URL(url, origin).toString();
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const urlNodes = Array.from(this.list.querySelectorAll("[data-url]"));
|
||||||
|
urlNodes.forEach((node) => {
|
||||||
|
const raw = node.getAttribute("data-url");
|
||||||
|
const abs = withOrigin(raw);
|
||||||
|
node.setAttribute("data-url", abs);
|
||||||
|
if (node.tagName === "A") {
|
||||||
|
node.setAttribute("href", abs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleForm() {
|
||||||
|
if (!this.form) return;
|
||||||
|
this.form.classList.toggle("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePanel() {
|
||||||
|
if (!this.collapsePanel) return;
|
||||||
|
const isHidden = this.collapsePanel.classList.contains("hidden");
|
||||||
|
this.collapsePanel.classList.toggle("hidden");
|
||||||
|
if (this.collapseIcon) {
|
||||||
|
this.collapseIcon.classList.toggle("rotate-180", isHidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDropzone() {
|
||||||
|
if (this.imageDropText && this.imageInput) {
|
||||||
|
const file = this.imageInput.files && this.imageInput.files[0];
|
||||||
|
this.imageDropText.textContent = file
|
||||||
|
? `${file.name} (${this.formatBytes(file.size) || "?"})`
|
||||||
|
: "Bild ablegen oder auswählen";
|
||||||
|
}
|
||||||
|
if (this.previewDropText && this.previewInput) {
|
||||||
|
const file = this.previewInput.files && this.previewInput.files[0];
|
||||||
|
this.previewDropText.textContent = file
|
||||||
|
? `${file.name} (${this.formatBytes(file.size) || "?"})`
|
||||||
|
: "Optional – Vorschau ablegen";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initTrix() {
|
||||||
|
if (!this.descriptionInput || !this.descriptionEditor || !this.descriptionToolbar) return;
|
||||||
|
if (!this.descriptionInput.id) {
|
||||||
|
const id = "image-desc-" + Math.random().toString(36).slice(2);
|
||||||
|
this.descriptionInput.id = id;
|
||||||
|
this.descriptionToolbar.id = id + "-toolbar";
|
||||||
|
this.descriptionEditor.setAttribute("input", id);
|
||||||
|
this.descriptionEditor.setAttribute("toolbar", this.descriptionToolbar.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragOver(event, zone) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (zone) {
|
||||||
|
zone.classList.add("border-slate-500", "bg-slate-50");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragLeave(event, zone) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (zone) {
|
||||||
|
zone.classList.remove("border-slate-500", "bg-slate-50");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDrop(event, input, kind) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!input) return;
|
||||||
|
const files = event.dataTransfer?.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
input.files = files;
|
||||||
|
this.updateDropzone();
|
||||||
|
this.onDragLeave(event, kind === "preview" ? this.previewDropzone : this.imageDropzone);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatBytes(bytes) {
|
||||||
|
if (!bytes || Number.isNaN(bytes)) return "";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let size = bytes;
|
||||||
|
let unit = 0;
|
||||||
|
while (size >= 1024 && unit < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
const precision = unit === 0 ? 0 : 1;
|
||||||
|
return `${size.toFixed(precision)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async safeJson(response) {
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
if (!contentType.includes("application/json")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async extractError(response) {
|
||||||
|
const json = await this.safeJson(response);
|
||||||
|
if (json) {
|
||||||
|
if (json.error) {
|
||||||
|
return json.error;
|
||||||
|
}
|
||||||
|
if (json.message) {
|
||||||
|
return json.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text = await response.text().catch(() => "");
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("image-uploader", ImageUploader);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
67
views/routes/components/_image_uploader_list.gohtml
Normal file
67
views/routes/components/_image_uploader_list.gohtml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{{ $model := . }}
|
||||||
|
|
||||||
|
{{- if not $model.images -}}
|
||||||
|
<div class="text-sm text-gray-500">Noch keine Bilder vorhanden.</div>
|
||||||
|
{{- else -}}
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-gray-600 border-b">
|
||||||
|
<th class="py-2 pr-4">
|
||||||
|
<button type="button" class="inline-flex items-center gap-1 text-sm font-semibold text-slate-700 hover:text-slate-900" data-role="image-sort" data-sort-key="title">
|
||||||
|
Titel
|
||||||
|
<i class="ri-arrow-up-down-line text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th class="py-2 pr-4">Vorschau</th>
|
||||||
|
<th class="py-2 pr-4">Bild</th>
|
||||||
|
<th class="py-2 pr-4">
|
||||||
|
<button type="button" class="inline-flex items-center gap-1 text-sm font-semibold text-slate-700 hover:text-slate-900" data-role="image-sort" data-sort-key="created">
|
||||||
|
Erstellt
|
||||||
|
<i class="ri-arrow-up-down-line text-xs"></i>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th class="py-2 pr-2 text-right">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{- range $_, $img := $model.images -}}
|
||||||
|
{{- $preview := $img.PreviewPath -}}
|
||||||
|
{{- $image := $img.ImagePath -}}
|
||||||
|
<tr class="border-b align-top" data-id="{{ $img.Id }}" data-title="{{ $img.Title }}" data-created="{{ $img.CreatedUnix }}">
|
||||||
|
<td class="py-2 pr-4">
|
||||||
|
<div class="font-semibold text-slate-900">{{ $img.Title }}</div>
|
||||||
|
{{- if $img.Description -}}
|
||||||
|
<div class="text-xs text-slate-500 annotation-content">{{ Safe $img.Description }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
<div class="text-xs text-slate-400 mt-1">{{ $img.Key }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 text-xs">
|
||||||
|
{{- if $preview -}}
|
||||||
|
<a href="{{ $preview }}" target="_blank" rel="noopener noreferrer" data-url="{{ $preview }}">
|
||||||
|
<img src="{{ $preview }}" alt="" class="h-12 w-12 object-cover rounded-sm border border-slate-200 hover:opacity-90" />
|
||||||
|
</a>
|
||||||
|
{{- end -}}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 text-xs">
|
||||||
|
{{- if $image -}}
|
||||||
|
<a href="{{ $image }}" target="_blank" rel="noopener noreferrer" data-url="{{ $image }}">
|
||||||
|
<img src="{{ $image }}" alt="" class="h-12 w-12 object-cover rounded-sm border border-slate-200 hover:opacity-90" />
|
||||||
|
</a>
|
||||||
|
{{- end -}}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-4 text-xs text-slate-600 whitespace-nowrap">{{ GermanDate $img.Created }} {{ GermanTime $img.Created }}</td>
|
||||||
|
<td class="py-2 pr-2 text-right">
|
||||||
|
{{- if $image -}}
|
||||||
|
<button type="button" class="mr-2 text-slate-600 hover:text-slate-900" data-action="copy" data-url="{{ $image }}">
|
||||||
|
<i class="ri-file-copy-line"></i>
|
||||||
|
</button>
|
||||||
|
{{- end -}}
|
||||||
|
<button type="button" class="text-red-700 hover:text-red-900" data-action="delete" data-id="{{ $img.Id }}">
|
||||||
|
<i class="ri-delete-bin-line"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{- end -}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{- end -}}
|
||||||
447
views/routes/components/_image_uploader_single.gohtml
Normal file
447
views/routes/components/_image_uploader_single.gohtml
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
{{ $model := . }}
|
||||||
|
|
||||||
|
<image-uploader-single
|
||||||
|
class="block"
|
||||||
|
data-upload-url="/redaktion/images/upload/"
|
||||||
|
data-list-url="/redaktion/images/list/"
|
||||||
|
data-delete-url="/redaktion/images/delete/"
|
||||||
|
data-key="{{ $model.key }}"
|
||||||
|
data-csrf="{{ $model.csrf_token }}">
|
||||||
|
<button type="button" class="w-full flex items-center justify-between rounded-md border border-slate-300 bg-white px-4 py-3 text-lg font-semibold text-slate-900 hover:bg-slate-50" data-role="collapse-toggle">
|
||||||
|
<span>Bild</span>
|
||||||
|
<i class="ri-arrow-down-s-line transition-transform" data-role="collapse-icon"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mt-4 hidden" data-role="collapse-panel">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">Bild</h2>
|
||||||
|
<p class="text-sm text-slate-600 mt-1">
|
||||||
|
Ersetzt das Bild der Reihen-Seite. Der Schlüssel bleibt fest.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="inline-flex items-center gap-2 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50" data-role="toggle-upload">
|
||||||
|
<i class="ri-image-add-line"></i> Bild ersetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 hidden grid grid-cols-1 md:grid-cols-12 gap-3 items-end" data-role="upload-form">
|
||||||
|
<div class="md:col-span-4 flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Bild</label>
|
||||||
|
<div class="border border-slate-300 rounded-md px-3 py-2 text-sm text-slate-600 bg-white min-h-[2.5rem] flex items-center" data-role="image-dropzone">
|
||||||
|
<div class="flex items-center justify-between gap-3 w-full">
|
||||||
|
<span data-role="image-dropzone-text">Bild ablegen oder auswählen</span>
|
||||||
|
<button type="button" class="text-sm font-semibold text-slate-700 hover:text-slate-900" data-role="image-choose">Auswählen</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" class="hidden" data-role="image-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-4 flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Vorschau</label>
|
||||||
|
<div class="border border-slate-300 rounded-md px-3 py-2 text-sm text-slate-600 bg-white min-h-[2.5rem] flex items-center" data-role="preview-dropzone">
|
||||||
|
<div class="flex items-center justify-between gap-3 w-full">
|
||||||
|
<span data-role="preview-dropzone-text">Optional – Vorschau ablegen</span>
|
||||||
|
<button type="button" class="text-sm font-semibold text-slate-700 hover:text-slate-900" data-role="preview-choose">Auswählen</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" class="hidden" data-role="preview-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-4 flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Titel</label>
|
||||||
|
<input type="text" class="block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-slate-500 focus:ring-2 focus:ring-slate-400/30 min-h-[2.5rem]" data-role="title-input" autocomplete="off" value="{{- if $model.image -}}{{ $model.image.Title }}{{- end -}}" />
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-12 flex flex-col gap-1">
|
||||||
|
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Beschreibung</label>
|
||||||
|
<trix-toolbar data-role="description-toolbar">
|
||||||
|
<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="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>
|
||||||
|
</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 data-role="description-input" autocomplete="off">{{- if $model.image -}}{{ $model.image.Description }}{{- end -}}</textarea>
|
||||||
|
<trix-editor data-role="description-editor" class="rounded-md border border-slate-300 bg-white p-2"></trix-editor>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-12 flex items-center justify-end">
|
||||||
|
<tool-tip position="top" class="inline-block">
|
||||||
|
<button type="button" class="inline-flex items-center justify-center gap-2 rounded-md bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-slate-400/50 disabled:opacity-60" data-role="upload-button">
|
||||||
|
<i class="ri-upload-2-line"></i>
|
||||||
|
</button>
|
||||||
|
<div class="data-tip">Bild speichern</div>
|
||||||
|
</tool-tip>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-12">
|
||||||
|
<span class="text-sm text-slate-600" data-role="status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 rounded-sm border border-slate-200 bg-white p-3" data-role="image-view">
|
||||||
|
{{ template "_image_uploader_single_view" $model }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</image-uploader-single>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
if (window.ImageUploaderSingleDefined) return;
|
||||||
|
window.ImageUploaderSingleDefined = true;
|
||||||
|
|
||||||
|
class ImageUploaderSingle extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
this.uploadUrl = this.dataset.uploadUrl || "";
|
||||||
|
this.listUrl = this.dataset.listUrl || "";
|
||||||
|
this.deleteUrl = this.dataset.deleteUrl || "";
|
||||||
|
this.imageKey = this.dataset.key || "";
|
||||||
|
this.csrf = this.dataset.csrf || "";
|
||||||
|
this.form = this.querySelector("[data-role='upload-form']");
|
||||||
|
this.view = this.querySelector("[data-role='image-view']");
|
||||||
|
this.status = this.querySelector("[data-role='status']");
|
||||||
|
this.uploadButton = this.querySelector("[data-role='upload-button']");
|
||||||
|
this.collapseToggle = this.querySelector("[data-role='collapse-toggle']");
|
||||||
|
this.collapsePanel = this.querySelector("[data-role='collapse-panel']");
|
||||||
|
this.collapseIcon = this.querySelector("[data-role='collapse-icon']");
|
||||||
|
this.toggleButton = this.querySelector("[data-role='toggle-upload']");
|
||||||
|
this.titleInput = this.querySelector("[data-role='title-input']");
|
||||||
|
this.descriptionInput = this.querySelector("[data-role='description-input']");
|
||||||
|
this.descriptionEditor = this.querySelector("[data-role='description-editor']");
|
||||||
|
this.descriptionToolbar = this.querySelector("[data-role='description-toolbar']");
|
||||||
|
this.imageInput = this.querySelector("[data-role='image-input']");
|
||||||
|
this.previewInput = this.querySelector("[data-role='preview-input']");
|
||||||
|
this.imageDropzone = this.querySelector("[data-role='image-dropzone']");
|
||||||
|
this.previewDropzone = this.querySelector("[data-role='preview-dropzone']");
|
||||||
|
this.imageDropText = this.querySelector("[data-role='image-dropzone-text']");
|
||||||
|
this.previewDropText = this.querySelector("[data-role='preview-dropzone-text']");
|
||||||
|
this.imageChoose = this.querySelector("[data-role='image-choose']");
|
||||||
|
this.previewChoose = this.querySelector("[data-role='preview-choose']");
|
||||||
|
|
||||||
|
if (this.uploadButton) {
|
||||||
|
this.uploadButton.addEventListener("click", (event) => this.handleUpload(event));
|
||||||
|
}
|
||||||
|
if (this.collapseToggle) {
|
||||||
|
this.collapseToggle.addEventListener("click", () => this.togglePanel());
|
||||||
|
}
|
||||||
|
if (this.toggleButton && this.form) {
|
||||||
|
this.toggleButton.addEventListener("click", () => this.toggleForm());
|
||||||
|
}
|
||||||
|
if (this.imageChoose && this.imageInput) {
|
||||||
|
this.imageChoose.addEventListener("click", () => this.imageInput.click());
|
||||||
|
}
|
||||||
|
if (this.previewChoose && this.previewInput) {
|
||||||
|
this.previewChoose.addEventListener("click", () => this.previewInput.click());
|
||||||
|
}
|
||||||
|
if (this.imageInput) {
|
||||||
|
this.imageInput.addEventListener("change", () => this.updateDropzone());
|
||||||
|
}
|
||||||
|
if (this.previewInput) {
|
||||||
|
this.previewInput.addEventListener("change", () => this.updateDropzone());
|
||||||
|
}
|
||||||
|
if (this.imageDropzone) {
|
||||||
|
this.imageDropzone.addEventListener("dragover", (event) => this.onDragOver(event, this.imageDropzone));
|
||||||
|
this.imageDropzone.addEventListener("dragleave", (event) => this.onDragLeave(event, this.imageDropzone));
|
||||||
|
this.imageDropzone.addEventListener("drop", (event) => this.onDrop(event, this.imageInput, "image"));
|
||||||
|
}
|
||||||
|
if (this.previewDropzone) {
|
||||||
|
this.previewDropzone.addEventListener("dragover", (event) => this.onDragOver(event, this.previewDropzone));
|
||||||
|
this.previewDropzone.addEventListener("dragleave", (event) => this.onDragLeave(event, this.previewDropzone));
|
||||||
|
this.previewDropzone.addEventListener("drop", (event) => this.onDrop(event, this.previewInput, "preview"));
|
||||||
|
}
|
||||||
|
this.addEventListener("click", (event) => this.handleClick(event));
|
||||||
|
this.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}, true);
|
||||||
|
this.initTrix();
|
||||||
|
this.normalizeUrls();
|
||||||
|
}
|
||||||
|
|
||||||
|
initTrix() {
|
||||||
|
if (!this.descriptionInput || !this.descriptionEditor || !this.descriptionToolbar) return;
|
||||||
|
if (!this.descriptionInput.id) {
|
||||||
|
const id = "image-single-desc-" + Math.random().toString(36).slice(2);
|
||||||
|
this.descriptionInput.id = id;
|
||||||
|
this.descriptionToolbar.id = id + "-toolbar";
|
||||||
|
this.descriptionEditor.setAttribute("input", id);
|
||||||
|
this.descriptionEditor.setAttribute("toolbar", this.descriptionToolbar.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(message, isError) {
|
||||||
|
if (!this.status) return;
|
||||||
|
this.status.textContent = message || "";
|
||||||
|
this.status.classList.remove("text-red-600", "text-green-600");
|
||||||
|
if (isError) {
|
||||||
|
this.status.classList.add("text-red-600");
|
||||||
|
} else if (message) {
|
||||||
|
this.status.classList.add("text-green-600");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleUpload(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
if (!this.uploadUrl || !this.form) return;
|
||||||
|
if (!this.imageKey) {
|
||||||
|
this.setStatus("Schlüssel fehlt.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setStatus("Upload läuft…");
|
||||||
|
if (this.uploadButton) this.uploadButton.disabled = true;
|
||||||
|
if (!this.imageInput || !this.imageInput.files || this.imageInput.files.length === 0) {
|
||||||
|
this.setStatus("Bitte ein Bild auswählen.", true);
|
||||||
|
if (this.uploadButton) this.uploadButton.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = new FormData();
|
||||||
|
payload.append("image", this.imageInput.files[0]);
|
||||||
|
if (this.previewInput && this.previewInput.files && this.previewInput.files[0]) {
|
||||||
|
payload.append("preview", this.previewInput.files[0]);
|
||||||
|
}
|
||||||
|
payload.append("key", this.imageKey);
|
||||||
|
if (this.titleInput && this.titleInput.value) {
|
||||||
|
payload.append("title", this.titleInput.value);
|
||||||
|
}
|
||||||
|
if (this.descriptionInput && this.descriptionInput.value) {
|
||||||
|
payload.append("description", this.descriptionInput.value);
|
||||||
|
}
|
||||||
|
payload.set("csrf_token", this.csrf);
|
||||||
|
|
||||||
|
const response = await fetch(this.uploadUrl, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await this.extractError(response);
|
||||||
|
this.setStatus(message || "Upload fehlgeschlagen.", true);
|
||||||
|
if (this.uploadButton) this.uploadButton.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await this.safeJson(response);
|
||||||
|
if (json && json.error) {
|
||||||
|
this.setStatus(json.error, true);
|
||||||
|
if (this.uploadButton) this.uploadButton.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.imageInput) this.imageInput.value = "";
|
||||||
|
if (this.previewInput) this.previewInput.value = "";
|
||||||
|
this.updateDropzone();
|
||||||
|
this.setStatus((json && json.message) || "Bild gespeichert.");
|
||||||
|
await this.refreshView();
|
||||||
|
if (this.form) this.form.classList.add("hidden");
|
||||||
|
if (this.uploadButton) this.uploadButton.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleClick(event) {
|
||||||
|
const target = event.target.closest("[data-action]");
|
||||||
|
if (!target) return;
|
||||||
|
const action = target.getAttribute("data-action");
|
||||||
|
if (action === "copy") {
|
||||||
|
const url = target.getAttribute("data-url");
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
this.setStatus("Link kopiert.");
|
||||||
|
} catch {
|
||||||
|
this.setStatus("Link kopieren fehlgeschlagen.", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action === "delete") {
|
||||||
|
const id = target.getAttribute("data-id");
|
||||||
|
if (!id || !this.deleteUrl) return;
|
||||||
|
if (!confirm("Bild wirklich löschen?")) return;
|
||||||
|
const payload = new FormData();
|
||||||
|
payload.set("csrf_token", this.csrf);
|
||||||
|
const response = await fetch(`${this.deleteUrl}${id}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await this.extractError(response);
|
||||||
|
this.setStatus(message || "Löschen fehlgeschlagen.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const json = await this.safeJson(response);
|
||||||
|
if (json && json.error) {
|
||||||
|
this.setStatus(json.error, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setStatus((json && json.message) || "Bild gelöscht.");
|
||||||
|
await this.refreshView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshView() {
|
||||||
|
if (!this.listUrl || !this.view) return;
|
||||||
|
const url = `${this.listUrl}?key=${encodeURIComponent(this.imageKey)}`;
|
||||||
|
const response = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (!response.ok) {
|
||||||
|
this.setStatus("Aktualisieren fehlgeschlagen.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const html = await response.text();
|
||||||
|
this.view.innerHTML = html;
|
||||||
|
this.normalizeUrls();
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeUrls() {
|
||||||
|
if (!this.view) return;
|
||||||
|
const origin = window.location.origin;
|
||||||
|
const withOrigin = (url) => {
|
||||||
|
if (!url) return url;
|
||||||
|
if (url.startsWith("http://") || url.startsWith("https://")) return url;
|
||||||
|
try {
|
||||||
|
return new URL(url, origin).toString();
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const urlNodes = Array.from(this.view.querySelectorAll("[data-url]"));
|
||||||
|
urlNodes.forEach((node) => {
|
||||||
|
const raw = node.getAttribute("data-url");
|
||||||
|
const abs = withOrigin(raw);
|
||||||
|
node.setAttribute("data-url", abs);
|
||||||
|
if (node.tagName === "A") {
|
||||||
|
node.setAttribute("href", abs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleForm() {
|
||||||
|
if (!this.form) return;
|
||||||
|
this.form.classList.toggle("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePanel() {
|
||||||
|
if (!this.collapsePanel) return;
|
||||||
|
const isHidden = this.collapsePanel.classList.contains("hidden");
|
||||||
|
this.collapsePanel.classList.toggle("hidden");
|
||||||
|
if (this.collapseIcon) {
|
||||||
|
this.collapseIcon.classList.toggle("rotate-180", isHidden);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDropzone() {
|
||||||
|
if (this.imageDropText && this.imageInput) {
|
||||||
|
const file = this.imageInput.files && this.imageInput.files[0];
|
||||||
|
this.imageDropText.textContent = file
|
||||||
|
? `${file.name} (${this.formatBytes(file.size) || "?"})`
|
||||||
|
: "Bild ablegen oder auswählen";
|
||||||
|
}
|
||||||
|
if (this.previewDropText && this.previewInput) {
|
||||||
|
const file = this.previewInput.files && this.previewInput.files[0];
|
||||||
|
this.previewDropText.textContent = file
|
||||||
|
? `${file.name} (${this.formatBytes(file.size) || "?"})`
|
||||||
|
: "Optional – Vorschau ablegen";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragOver(event, zone) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (zone) {
|
||||||
|
zone.classList.add("border-slate-500", "bg-slate-50");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragLeave(event, zone) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (zone) {
|
||||||
|
zone.classList.remove("border-slate-500", "bg-slate-50");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDrop(event, input, kind) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!input) return;
|
||||||
|
const files = event.dataTransfer?.files;
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
input.files = files;
|
||||||
|
this.updateDropzone();
|
||||||
|
this.onDragLeave(event, kind === "preview" ? this.previewDropzone : this.imageDropzone);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatBytes(bytes) {
|
||||||
|
if (!bytes || Number.isNaN(bytes)) return "";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let size = bytes;
|
||||||
|
let unit = 0;
|
||||||
|
while (size >= 1024 && unit < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
const precision = unit === 0 ? 0 : 1;
|
||||||
|
return `${size.toFixed(precision)} ${units[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async safeJson(response) {
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
if (!contentType.includes("application/json")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async extractError(response) {
|
||||||
|
const json = await this.safeJson(response);
|
||||||
|
if (json) {
|
||||||
|
if (json.error) {
|
||||||
|
return json.error;
|
||||||
|
}
|
||||||
|
if (json.message) {
|
||||||
|
return json.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text = await response.text().catch(() => "");
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("image-uploader-single", ImageUploaderSingle);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
45
views/routes/components/_image_uploader_single_view.gohtml
Normal file
45
views/routes/components/_image_uploader_single_view.gohtml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{{ $model := . }}
|
||||||
|
|
||||||
|
{{- if not $model.image -}}
|
||||||
|
<div class="text-sm text-gray-500">Kein Bild vorhanden.</div>
|
||||||
|
{{- else -}}
|
||||||
|
{{- $preview := $model.image.PreviewPath -}}
|
||||||
|
{{- $image := $model.image.ImagePath -}}
|
||||||
|
<div class="border-b pb-3">
|
||||||
|
<div class="font-semibold text-slate-900">{{ $model.image.Title }}</div>
|
||||||
|
{{- if $model.image.Description -}}
|
||||||
|
<div class="text-xs text-slate-500 annotation-content mt-1">{{ Safe $model.image.Description }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
<div class="text-xs text-slate-400 mt-1">{{ $model.image.Key }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div class="text-xs">
|
||||||
|
<div class="text-slate-500 uppercase tracking-wide text-[0.65rem] mb-1">Vorschau</div>
|
||||||
|
{{- if $preview -}}
|
||||||
|
<a href="{{ $preview }}" target="_blank" rel="noopener noreferrer" data-url="{{ $preview }}">
|
||||||
|
<img src="{{ $preview }}" alt="" class="h-20 w-20 object-cover rounded-sm border border-slate-200 hover:opacity-90" />
|
||||||
|
</a>
|
||||||
|
{{- else -}}
|
||||||
|
<span class="text-slate-400">—</span>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs">
|
||||||
|
<div class="text-slate-500 uppercase tracking-wide text-[0.65rem] mb-1">Bild</div>
|
||||||
|
{{- if $image -}}
|
||||||
|
<a href="{{ $image }}" target="_blank" rel="noopener noreferrer" data-url="{{ $image }}">
|
||||||
|
<img src="{{ $image }}" alt="" class="h-20 w-20 object-cover rounded-sm border border-slate-200 hover:opacity-90" />
|
||||||
|
</a>
|
||||||
|
{{- else -}}
|
||||||
|
<span class="text-slate-400">—</span>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center justify-between text-xs text-slate-500">
|
||||||
|
<span>{{ GermanDate $model.image.Created }} {{ GermanTime $model.image.Created }}</span>
|
||||||
|
{{- if $image -}}
|
||||||
|
<button type="button" class="text-slate-600 hover:text-slate-900" data-action="copy" data-url="{{ $image }}">
|
||||||
|
<i class="ri-file-copy-line"></i> Link kopieren
|
||||||
|
</button>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
1
views/routes/components/file_uploader_list/body.gohtml
Normal file
1
views/routes/components/file_uploader_list/body.gohtml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{{ template "_file_uploader_list" . }}
|
||||||
1
views/routes/components/image_uploader_list/body.gohtml
Normal file
1
views/routes/components/image_uploader_list/body.gohtml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{{ template "_image_uploader_list" . }}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{ template "_image_uploader_single_view" . }}
|
||||||
Reference in New Issue
Block a user