package manage import ( "encoding/json" "fmt" "html/template" "io" "net/http" "os" "path/filepath" "strconv" "strings" "time" "git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier" "git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext" "git.az-it.net/az/morz-infoboard/server/backend/internal/store" ) var tmplFuncs = template.FuncMap{ "typeIcon": func(t string) string { switch t { case "image": return "🖼" case "video": return "🎬" case "pdf": return "📄" case "web": return "🌐" default: return "📁" } }, "orientationLabel": func(o string) string { if o == "portrait" { return "Hochformat" } return "Querformat" }, "shortSrc": func(s string) string { if len(s) > 60 { return s[:57] + "..." } return s }, "formatDT": func(t *time.Time) string { if t == nil { return "" } return t.Format("2006-01-02T15:04") }, } // HandleAdminUI renders the admin overview page. func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc { t := template.Must(template.New("admin").Funcs(tmplFuncs).Parse(adminTmpl)) return func(w http.ResponseWriter, r *http.Request) { allScreens, err := screens.ListAll(r.Context()) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } allTenants, err := tenants.List(r.Context()) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") t.Execute(w, map[string]any{ //nolint:errcheck "Screens": allScreens, "Tenants": allTenants, }) } } // HandleManageUI renders the playlist management UI for a specific screen. func HandleManageUI( tenants *store.TenantStore, screens *store.ScreenStore, media *store.MediaStore, playlists *store.PlaylistStore, ) http.HandlerFunc { t := template.Must(template.New("manage").Funcs(tmplFuncs).Parse(manageTmpl)) return func(w http.ResponseWriter, r *http.Request) { screenSlug := r.PathValue("screenSlug") screen, err := screens.GetBySlug(r.Context(), screenSlug) if err != nil { http.Error(w, "Screen nicht gefunden: "+screenSlug, http.StatusNotFound) return } var tenant *store.Tenant if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" { tenant, _ = tenants.Get(r.Context(), u.TenantSlug) } if tenant == nil { tenant = &store.Tenant{ID: screen.TenantID, Name: "MORZ"} } playlist, err := playlists.GetOrCreateForScreen(r.Context(), screen.TenantID, screen.ID, screen.Name) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } items, err := playlists.ListItems(r.Context(), playlist.ID) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } assets, err := media.List(r.Context(), screen.TenantID) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } // Build set of already-added asset IDs to mark them in library. addedAssets := map[string]bool{} for _, it := range items { if it.MediaAssetID != "" { addedAssets[it.MediaAssetID] = true } } w.Header().Set("Content-Type", "text/html; charset=utf-8") t.Execute(w, map[string]any{ //nolint:errcheck "Screen": screen, "Tenant": tenant, "Playlist": playlist, "Items": items, "Assets": assets, "AddedAssets": addedAssets, }) } } // HandleCreateScreenUI handles form POST to create a screen, then redirects. func HandleCreateScreenUI(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } slug := strings.TrimSpace(r.FormValue("slug")) name := strings.TrimSpace(r.FormValue("name")) orientation := r.FormValue("orientation") if slug == "" || name == "" { http.Error(w, "slug und name erforderlich", http.StatusBadRequest) return } if orientation == "" { orientation = "landscape" } tenantSlug := "morz" if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" { tenantSlug = u.TenantSlug } tenant, err := tenants.Get(r.Context(), tenantSlug) if err != nil { http.Error(w, "tenant nicht gefunden", http.StatusInternalServerError) return } _, err = screens.Create(r.Context(), tenant.ID, slug, name, orientation) if err != nil { http.Error(w, "Fehler: "+err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/admin?msg=added", http.StatusSeeOther) } } // HandleProvisionUI creates a screen in DB and shows the Ansible setup instructions. func HandleProvisionUI(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc { t := template.Must(template.New("provision").Funcs(tmplFuncs).Parse(provisionTmpl)) return func(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } slug := strings.TrimSpace(r.FormValue("slug")) name := strings.TrimSpace(r.FormValue("name")) ip := strings.TrimSpace(r.FormValue("ip")) sshUser := strings.TrimSpace(r.FormValue("ssh_user")) orientation := r.FormValue("orientation") if slug == "" || ip == "" { http.Error(w, "slug und IP-Adresse erforderlich", http.StatusBadRequest) return } if name == "" { name = slug } if sshUser == "" { sshUser = "morz" } if orientation == "" { orientation = "landscape" } tenantSlug := "morz" if u := reqcontext.UserFromContext(r.Context()); u != nil && u.TenantSlug != "" { tenantSlug = u.TenantSlug } tenant, err := tenants.Get(r.Context(), tenantSlug) if err != nil { http.Error(w, "tenant nicht gefunden", http.StatusInternalServerError) return } screen, err := screens.Upsert(r.Context(), tenant.ID, slug, name, orientation) if err != nil { http.Error(w, "DB-Fehler: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") t.Execute(w, map[string]any{ //nolint:errcheck "Screen": screen, "IP": ip, "SSHUser": sshUser, "Orientation": orientation, }) } } // HandleDeleteScreenUI handles DELETE for a screen, then redirects. func HandleDeleteScreenUI(screens *store.ScreenStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("screenId") if err := screens.Delete(r.Context(), id); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } http.Redirect(w, r, "/admin?msg=deleted", http.StatusSeeOther) } } // HandleUploadMediaUI handles form upload from the manage UI and redirects back. func HandleUploadMediaUI(media *store.MediaStore, screens *store.ScreenStore, uploadDir string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { screenSlug := r.PathValue("screenSlug") screen, err := screens.GetBySlug(r.Context(), screenSlug) if err != nil { http.Error(w, "screen nicht gefunden", http.StatusNotFound) return } if err := r.ParseMultipartForm(maxUploadSize); err != nil { http.Error(w, "Upload zu groß oder ungültig", http.StatusBadRequest) return } assetType := strings.TrimSpace(r.FormValue("type")) title := strings.TrimSpace(r.FormValue("title")) switch assetType { case "web": url := strings.TrimSpace(r.FormValue("url")) if url == "" { http.Error(w, "URL erforderlich", http.StatusBadRequest) return } if title == "" { title = url } _, err = media.Create(r.Context(), screen.TenantID, title, "web", "", url, "", 0) case "image", "video", "pdf": file, header, ferr := r.FormFile("file") if ferr != nil { http.Error(w, "Datei erforderlich", http.StatusBadRequest) return } defer file.Close() if title == "" { title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)) } mimeType := header.Header.Get("Content-Type") ext := filepath.Ext(header.Filename) filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitize(title), ext) destPath := filepath.Join(uploadDir, filename) dest, ferr := os.Create(destPath) if ferr != nil { http.Error(w, "Speicherfehler", http.StatusInternalServerError) return } defer dest.Close() size, _ := io.Copy(dest, file) storagePath := "/uploads/" + filename _, err = media.Create(r.Context(), screen.TenantID, title, assetType, storagePath, "", mimeType, size) default: http.Error(w, "Unbekannter Typ", http.StatusBadRequest) return } if err != nil { http.Error(w, "DB-Fehler: "+err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/manage/"+screenSlug+"?msg=uploaded", http.StatusSeeOther) } } // HandleAddItemUI handles form POST to add a playlist item, then redirects. func HandleAddItemUI(playlists *store.PlaylistStore, media *store.MediaStore, screens *store.ScreenStore, notifier *mqttnotifier.Notifier) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { screenSlug := r.PathValue("screenSlug") if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } screen, err := screens.GetBySlug(r.Context(), screenSlug) if err != nil { http.Error(w, "screen nicht gefunden", http.StatusNotFound) return } playlist, err := playlists.GetOrCreateForScreen(r.Context(), screen.TenantID, screen.ID, screen.Name) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } mediaAssetID := r.FormValue("media_asset_id") itemType := r.FormValue("type") src := r.FormValue("src") title := r.FormValue("title") durationSeconds := 20 if d, err := strconv.Atoi(strings.TrimSpace(r.FormValue("duration_seconds"))); err == nil && d > 0 { durationSeconds = d } validFrom, _ := parseOptionalTime(r.FormValue("valid_from")) validUntil, _ := parseOptionalTime(r.FormValue("valid_until")) if mediaAssetID != "" { asset, err := media.Get(r.Context(), mediaAssetID) if err != nil { http.Error(w, "Medium nicht gefunden", http.StatusBadRequest) return } itemType = asset.Type if asset.StoragePath != "" { src = asset.StoragePath } else { src = asset.OriginalURL } if title == "" { title = asset.Title } } if itemType == "" || src == "" { http.Error(w, "type und src erforderlich", http.StatusBadRequest) return } _, err = playlists.AddItem(r.Context(), playlist.ID, mediaAssetID, itemType, src, title, durationSeconds, validFrom, validUntil) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } notifier.NotifyChanged(screenSlug) http.Redirect(w, r, "/manage/"+screenSlug+"?msg=added", http.StatusSeeOther) } } // HandleDeleteItemUI removes a playlist item and redirects back. func HandleDeleteItemUI(playlists *store.PlaylistStore, notifier *mqttnotifier.Notifier) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { screenSlug := r.PathValue("screenSlug") itemID := r.PathValue("itemId") if err := playlists.DeleteItem(r.Context(), itemID); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } notifier.NotifyChanged(screenSlug) http.Redirect(w, r, "/manage/"+screenSlug+"?msg=deleted", http.StatusSeeOther) } } // HandleReorderUI accepts JSON body with ordered IDs (HTMX/fetch). func HandleReorderUI(playlists *store.PlaylistStore, screens *store.ScreenStore, notifier *mqttnotifier.Notifier) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { screenSlug := r.PathValue("screenSlug") screen, err := screens.GetBySlug(r.Context(), screenSlug) if err != nil { http.Error(w, "screen nicht gefunden", http.StatusNotFound) return } playlist, err := playlists.GetByScreen(r.Context(), screen.ID) if err != nil { http.Error(w, "playlist nicht gefunden", http.StatusNotFound) return } var ids []string if err := json.NewDecoder(r.Body).Decode(&ids); err != nil { http.Error(w, "JSON erwartet: array von item-IDs", http.StatusBadRequest) return } if err := playlists.Reorder(r.Context(), playlist.ID, ids); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } notifier.NotifyChanged(screenSlug) w.WriteHeader(http.StatusNoContent) } } // HandleUpdateItemUI handles form PATCH/POST to update a single item. func HandleUpdateItemUI(playlists *store.PlaylistStore, notifier *mqttnotifier.Notifier) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { screenSlug := r.PathValue("screenSlug") itemID := r.PathValue("itemId") if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } title := r.FormValue("title") durationSeconds := 20 if d, err := strconv.Atoi(strings.TrimSpace(r.FormValue("duration_seconds"))); err == nil && d > 0 { durationSeconds = d } enabled := r.FormValue("enabled") == "true" validFrom, _ := parseOptionalTime(r.FormValue("valid_from")) validUntil, _ := parseOptionalTime(r.FormValue("valid_until")) if err := playlists.UpdateItem(r.Context(), itemID, title, durationSeconds, enabled, validFrom, validUntil); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } notifier.NotifyChanged(screenSlug) http.Redirect(w, r, "/manage/"+screenSlug+"?msg=saved", http.StatusSeeOther) } } // HandleDeleteMediaUI deletes media and redirects back. func HandleDeleteMediaUI(media *store.MediaStore, screens *store.ScreenStore, uploadDir string, notifier *mqttnotifier.Notifier) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { screenSlug := r.PathValue("screenSlug") mediaID := r.PathValue("mediaId") asset, err := media.Get(r.Context(), mediaID) if err == nil && asset.StoragePath != "" { os.Remove(filepath.Join(uploadDir, filepath.Base(asset.StoragePath))) //nolint:errcheck } media.Delete(r.Context(), mediaID) //nolint:errcheck notifier.NotifyChanged(screenSlug) http.Redirect(w, r, "/manage/"+screenSlug+"?msg=deleted", http.StatusSeeOther) } }