Baue Ebene 2: PostgreSQL-Backend, Medien-Upload und Playlist-UI
- DB-Package mit pgxpool, Migrations-Runner und eingebetteten SQL-Dateien
- Schema: tenants, screens, media_assets, playlists, playlist_items
- Store-Layer: alle Repositories (TenantStore, ScreenStore, MediaStore, PlaylistStore)
- JSON-API: Screens, Medien, Playlist-CRUD, Player-Sync-Endpunkt
- Admin-UI (/admin): Screens anlegen, löschen, zur Playlist navigieren
- Playlist-UI (/manage/{slug}): Drag&Drop-Sortierung, Item-Bearbeitung,
Medienbibliothek, Datei-Upload (Bild/Video/PDF) und Web-URL
- Router auf RouterDeps umgestellt; manage-Routen nur wenn Stores vorhanden
- parseOptionalTime akzeptiert nun RFC3339 und datetime-local HTML-Format
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bbcf0a1228
commit
803f355220
14 changed files with 2027 additions and 23 deletions
|
|
@ -1,3 +1,12 @@
|
|||
module git.az-it.net/az/morz-infoboard/server/backend
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
|
|
|
|||
19
server/backend/go.sum
Normal file
19
server/backend/go.sum
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/db"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
|
|
@ -15,17 +20,47 @@ type App struct {
|
|||
|
||||
func New() (*App, error) {
|
||||
cfg := config.Load()
|
||||
logger := log.New(os.Stdout, "backend ", log.LstdFlags|log.LUTC)
|
||||
|
||||
store, err := httpapi.NewStoreFromConfig(cfg.StatusStorePath)
|
||||
// Ensure upload directory exists.
|
||||
if err := os.MkdirAll(cfg.UploadDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Connect to database and run migrations.
|
||||
pool, err := db.Connect(context.Background(), cfg.DatabaseURL, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Status store (existing in-memory/file store).
|
||||
statusStore, err := httpapi.NewStoreFromConfig(cfg.StatusStorePath)
|
||||
if err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Domain stores.
|
||||
tenants := store.NewTenantStore(pool.Pool)
|
||||
screens := store.NewScreenStore(pool.Pool)
|
||||
media := store.NewMediaStore(pool.Pool)
|
||||
playlists := store.NewPlaylistStore(pool.Pool)
|
||||
|
||||
handler := httpapi.NewRouter(httpapi.RouterDeps{
|
||||
StatusStore: statusStore,
|
||||
TenantStore: tenants,
|
||||
ScreenStore: screens,
|
||||
MediaStore: media,
|
||||
PlaylistStore: playlists,
|
||||
UploadDir: cfg.UploadDir,
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
return &App{
|
||||
Config: cfg,
|
||||
server: &http.Server{
|
||||
Addr: cfg.HTTPAddress,
|
||||
Handler: httpapi.NewRouter(store),
|
||||
Handler: handler,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -35,6 +70,5 @@ func (a *App) Run() error {
|
|||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@ import "os"
|
|||
type Config struct {
|
||||
HTTPAddress string
|
||||
StatusStorePath string
|
||||
DatabaseURL string
|
||||
UploadDir string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
return Config{
|
||||
HTTPAddress: getenv("MORZ_INFOBOARD_HTTP_ADDR", ":8080"),
|
||||
StatusStorePath: os.Getenv("MORZ_INFOBOARD_STATUS_STORE_PATH"),
|
||||
DatabaseURL: getenv("MORZ_INFOBOARD_DATABASE_URL", "postgres://morz_infoboard:morz_infoboard@localhost:5432/morz_infoboard?sslmode=disable"),
|
||||
UploadDir: getenv("MORZ_INFOBOARD_UPLOAD_DIR", "/tmp/morz-uploads"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
90
server/backend/internal/db/db.go
Normal file
90
server/backend/internal/db/db.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// Pool wraps a pgxpool.Pool with migration support.
|
||||
type Pool struct {
|
||||
*pgxpool.Pool
|
||||
}
|
||||
|
||||
// Connect opens a connection pool and runs pending migrations.
|
||||
func Connect(ctx context.Context, databaseURL string, logger *log.Logger) (*Pool, error) {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: open pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("db: ping: %w", err)
|
||||
}
|
||||
|
||||
p := &Pool{pool}
|
||||
if err := p.migrate(ctx, logger); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("db: migrate: %w", err)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// migrate runs all embedded SQL migration files in order (idempotent).
|
||||
func (p *Pool) migrate(ctx context.Context, logger *log.Logger) error {
|
||||
// Ensure schema_migrations table exists first.
|
||||
_, err := p.Exec(ctx, `
|
||||
create table if not exists schema_migrations (
|
||||
version integer primary key,
|
||||
applied_at timestamptz not null default now()
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
entries, err := migrationsFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, e := range entries {
|
||||
version := i + 1
|
||||
var applied bool
|
||||
err := p.QueryRow(ctx,
|
||||
"select exists(select 1 from schema_migrations where version=$1)", version,
|
||||
).Scan(&applied)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check migration %d: %w", version, err)
|
||||
}
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
|
||||
sql, err := migrationsFS.ReadFile("migrations/" + e.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := p.Exec(ctx, string(sql)); err != nil {
|
||||
return fmt.Errorf("run migration %s: %w", e.Name(), err)
|
||||
}
|
||||
|
||||
if _, err := p.Exec(ctx,
|
||||
"insert into schema_migrations(version) values($1)", version,
|
||||
); err != nil {
|
||||
return fmt.Errorf("record migration %d: %w", version, err)
|
||||
}
|
||||
|
||||
logger.Printf("event=migration_applied version=%d file=%s", version, e.Name())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
76
server/backend/internal/db/migrations/001_initial.sql
Normal file
76
server/backend/internal/db/migrations/001_initial.sql
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
-- 001_initial.sql
|
||||
-- Basis-Schema: Tenants, Screens, Medien, Playlists
|
||||
|
||||
create extension if not exists pgcrypto;
|
||||
|
||||
create table if not exists tenants (
|
||||
id text primary key default gen_random_uuid()::text,
|
||||
slug text not null unique,
|
||||
name text not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists screens (
|
||||
id text primary key default gen_random_uuid()::text,
|
||||
tenant_id text not null references tenants(id) on delete cascade,
|
||||
slug text not null unique,
|
||||
name text not null,
|
||||
orientation text not null default 'landscape',
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists media_assets (
|
||||
id text primary key default gen_random_uuid()::text,
|
||||
tenant_id text not null references tenants(id) on delete cascade,
|
||||
title text not null,
|
||||
type text not null, -- image | video | pdf | web
|
||||
storage_path text null, -- set for uploads
|
||||
original_url text null, -- set for web/remote
|
||||
mime_type text null,
|
||||
size_bytes bigint null,
|
||||
enabled boolean not null default true,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists playlists (
|
||||
id text primary key default gen_random_uuid()::text,
|
||||
tenant_id text not null references tenants(id) on delete cascade,
|
||||
screen_id text not null references screens(id) on delete cascade,
|
||||
name text not null,
|
||||
is_active boolean not null default true,
|
||||
default_duration_seconds integer not null default 20,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (screen_id) -- one active playlist per screen (simplified)
|
||||
);
|
||||
|
||||
create table if not exists playlist_items (
|
||||
id text primary key default gen_random_uuid()::text,
|
||||
playlist_id text not null references playlists(id) on delete cascade,
|
||||
media_asset_id text null references media_assets(id) on delete set null,
|
||||
order_index integer not null default 0,
|
||||
type text not null, -- image | video | pdf | web
|
||||
src text not null, -- URL or served path
|
||||
title text null,
|
||||
duration_seconds integer not null default 20,
|
||||
valid_from timestamptz null,
|
||||
valid_until timestamptz null,
|
||||
enabled boolean not null default true,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_screens_tenant_id on screens(tenant_id);
|
||||
create index if not exists idx_media_assets_tenant_id on media_assets(tenant_id);
|
||||
create index if not exists idx_playlists_screen_id on playlists(screen_id);
|
||||
create index if not exists idx_playlist_items_order on playlist_items(playlist_id, order_index);
|
||||
|
||||
-- Schema-Versions-Tabelle
|
||||
create table if not exists schema_migrations (
|
||||
version integer primary key,
|
||||
applied_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- Seed: Standard-Tenant und erster Screen (idempotent)
|
||||
insert into tenants (id, slug, name)
|
||||
values ('tenant-morz', 'morz', 'MORZ Schule')
|
||||
on conflict (slug) do nothing;
|
||||
|
|
@ -17,7 +17,7 @@ func TestPlayerStatusLifecycle(t *testing.T) {
|
|||
store.now = func() time.Time {
|
||||
return time.Date(2026, 3, 22, 16, 10, 0, 0, time.UTC)
|
||||
}
|
||||
router := NewRouter(store)
|
||||
router := NewRouter(RouterDeps{StatusStore: store})
|
||||
|
||||
// 1. POST /api/v1/player/status – ingest a status report
|
||||
body := `{
|
||||
|
|
|
|||
154
server/backend/internal/httpapi/manage/media.go
Normal file
154
server/backend/internal/httpapi/manage/media.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package manage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||
)
|
||||
|
||||
const maxUploadSize = 512 << 20 // 512 MB
|
||||
|
||||
// HandleListMedia returns all media assets for a tenant as JSON.
|
||||
func HandleListMedia(media *store.MediaStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID := r.PathValue("tenantId")
|
||||
assets, err := media.List(r.Context(), tenantID)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(assets) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUploadMedia handles multipart file upload and web-URL registration.
|
||||
func HandleUploadMedia(media *store.MediaStore, uploadDir string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID := r.PathValue("tenantId")
|
||||
|
||||
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||
http.Error(w, "request too large or not multipart", 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 required for type=web", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if title == "" {
|
||||
title = url
|
||||
}
|
||||
asset, err := media.Create(r.Context(), tenantID, title, "web", "", url, "", 0)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(asset) //nolint:errcheck
|
||||
return
|
||||
|
||||
case "image", "video", "pdf":
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "file required for type="+assetType, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
mimeType := header.Header.Get("Content-Type")
|
||||
if title == "" {
|
||||
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
|
||||
}
|
||||
|
||||
// Generate unique storage path.
|
||||
ext := filepath.Ext(header.Filename)
|
||||
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitize(title), ext)
|
||||
destPath := filepath.Join(uploadDir, filename)
|
||||
|
||||
dest, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
http.Error(w, "storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer dest.Close()
|
||||
|
||||
size, err := io.Copy(dest, file)
|
||||
if err != nil {
|
||||
os.Remove(destPath) //nolint:errcheck
|
||||
http.Error(w, "write error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Storage path relative (served via /uploads/).
|
||||
storagePath := "/uploads/" + filename
|
||||
|
||||
asset, err := media.Create(r.Context(), tenantID, title, assetType, storagePath, "", mimeType, size)
|
||||
if err != nil {
|
||||
os.Remove(destPath) //nolint:errcheck
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(asset) //nolint:errcheck
|
||||
|
||||
default:
|
||||
http.Error(w, "type must be one of: image, video, pdf, web", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeleteMedia deletes a media asset from the database (and file if local).
|
||||
func HandleDeleteMedia(media *store.MediaStore, uploadDir string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
asset, err := media.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete physical file if it's a local upload.
|
||||
if asset.StoragePath != "" {
|
||||
filename := filepath.Base(asset.StoragePath)
|
||||
os.Remove(filepath.Join(uploadDir, filename)) //nolint:errcheck
|
||||
}
|
||||
|
||||
if err := media.Delete(r.Context(), id); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func sanitize(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if len(out) > 40 {
|
||||
out = out[:40]
|
||||
}
|
||||
return out
|
||||
}
|
||||
295
server/backend/internal/httpapi/manage/playlist.go
Normal file
295
server/backend/internal/httpapi/manage/playlist.go
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
package manage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||
)
|
||||
|
||||
// HandleGetPlaylist returns the playlist and its items for a screen.
|
||||
func HandleGetPlaylist(screens *store.ScreenStore, playlists *store.PlaylistStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
screenID := r.PathValue("screenId")
|
||||
|
||||
screen, err := screens.GetBySlug(r.Context(), screenID)
|
||||
if err != nil {
|
||||
// Try by id if slug not found.
|
||||
screen = &store.Screen{ID: screenID}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck
|
||||
"playlist": playlist,
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAddItem adds a playlist item (from existing media asset or direct URL).
|
||||
func HandleAddItem(playlists *store.PlaylistStore, media *store.MediaStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
playlistID := r.PathValue("playlistId")
|
||||
|
||||
var body struct {
|
||||
MediaAssetID string `json:"media_asset_id"`
|
||||
Type string `json:"type"`
|
||||
Src string `json:"src"`
|
||||
Title string `json:"title"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
ValidFrom string `json:"valid_from"`
|
||||
ValidUntil string `json:"valid_until"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// If adding from media library, fill in src and type from asset.
|
||||
if body.MediaAssetID != "" {
|
||||
asset, err := media.Get(r.Context(), body.MediaAssetID)
|
||||
if err != nil {
|
||||
http.Error(w, "media asset not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
body.Type = asset.Type
|
||||
if asset.StoragePath != "" {
|
||||
body.Src = asset.StoragePath
|
||||
} else {
|
||||
body.Src = asset.OriginalURL
|
||||
}
|
||||
if body.Title == "" {
|
||||
body.Title = asset.Title
|
||||
}
|
||||
}
|
||||
|
||||
if body.Type == "" || body.Src == "" {
|
||||
http.Error(w, "type and src required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if body.DurationSeconds <= 0 {
|
||||
body.DurationSeconds = 20
|
||||
}
|
||||
|
||||
validFrom, _ := parseOptionalTime(body.ValidFrom)
|
||||
validUntil, _ := parseOptionalTime(body.ValidUntil)
|
||||
|
||||
item, err := playlists.AddItem(r.Context(), playlistID, body.MediaAssetID,
|
||||
body.Type, body.Src, body.Title, body.DurationSeconds, validFrom, validUntil)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(item) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUpdateItem updates duration, title, enabled, valid_from, valid_until.
|
||||
func HandleUpdateItem(playlists *store.PlaylistStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("itemId")
|
||||
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ValidFrom string `json:"valid_from"`
|
||||
ValidUntil string `json:"valid_until"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
enabled := true
|
||||
if body.Enabled != nil {
|
||||
enabled = *body.Enabled
|
||||
}
|
||||
if body.DurationSeconds <= 0 {
|
||||
body.DurationSeconds = 20
|
||||
}
|
||||
|
||||
validFrom, _ := parseOptionalTime(body.ValidFrom)
|
||||
validUntil, _ := parseOptionalTime(body.ValidUntil)
|
||||
|
||||
if err := playlists.UpdateItem(r.Context(), id, body.Title, body.DurationSeconds, enabled, validFrom, validUntil); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeleteItem removes a playlist item.
|
||||
func HandleDeleteItem(playlists *store.PlaylistStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("itemId")
|
||||
if err := playlists.DeleteItem(r.Context(), id); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleReorder accepts an ordered list of item IDs and updates order_index.
|
||||
func HandleReorder(playlists *store.PlaylistStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
playlistID := r.PathValue("playlistId")
|
||||
|
||||
var ids []string
|
||||
if err := json.NewDecoder(r.Body).Decode(&ids); err != nil {
|
||||
http.Error(w, "body must be JSON array of item IDs", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := playlists.Reorder(r.Context(), playlistID, ids); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUpdatePlaylistDuration sets the default duration for a playlist.
|
||||
func HandleUpdatePlaylistDuration(playlists *store.PlaylistStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("playlistId")
|
||||
secs, err := strconv.Atoi(strings.TrimSpace(r.FormValue("default_duration_seconds")))
|
||||
if err != nil || secs <= 0 {
|
||||
http.Error(w, "invalid duration", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := playlists.UpdateDefaultDuration(r.Context(), id, secs); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePlayerPlaylist returns the active playlist for a screen (player sync).
|
||||
func HandlePlayerPlaylist(screens *store.ScreenStore, playlists *store.PlaylistStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
screenSlug := r.PathValue("screenId")
|
||||
|
||||
screen, err := screens.GetBySlug(r.Context(), screenSlug)
|
||||
if err != nil {
|
||||
http.Error(w, "screen not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
playlist, err := playlists.GetByScreen(r.Context(), screen.ID)
|
||||
if err != nil {
|
||||
// No playlist yet — return empty.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"items": []any{}}) //nolint:errcheck
|
||||
return
|
||||
}
|
||||
|
||||
items, err := playlists.ListActiveItems(r.Context(), playlist.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck
|
||||
"playlist_id": playlist.ID,
|
||||
"default_duration_seconds": playlist.DefaultDurationSeconds,
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleListScreens returns all screens for a tenant.
|
||||
func HandleListScreens(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tenantSlug := r.PathValue("tenantSlug")
|
||||
tenant, err := tenants.Get(r.Context(), tenantSlug)
|
||||
if err != nil {
|
||||
http.Error(w, "tenant not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
list, err := screens.List(r.Context(), tenant.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(list) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// HandleCreateScreen creates a new screen for a tenant.
|
||||
func HandleCreateScreen(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tenantSlug := r.PathValue("tenantSlug")
|
||||
tenant, err := tenants.Get(r.Context(), tenantSlug)
|
||||
if err != nil {
|
||||
http.Error(w, "tenant not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Orientation string `json:"orientation"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Slug == "" || body.Name == "" {
|
||||
http.Error(w, "slug and name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Orientation == "" {
|
||||
body.Orientation = "landscape"
|
||||
}
|
||||
|
||||
screen, err := screens.Create(r.Context(), tenant.ID, body.Slug, body.Name, body.Orientation)
|
||||
if err != nil {
|
||||
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(screen) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
func parseOptionalTime(s string) (*time.Time, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
// Accept RFC3339 (API) and datetime-local HTML input format.
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02T15:04:05"} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return &t, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("cannot parse time: %q", s)
|
||||
}
|
||||
422
server/backend/internal/httpapi/manage/templates.go
Normal file
422
server/backend/internal/httpapi/manage/templates.go
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
package manage
|
||||
|
||||
const adminTmpl = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MORZ Infoboard – Admin</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
|
||||
<style>
|
||||
body { background: #f5f5f5; }
|
||||
.navbar { margin-bottom: 1.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar is-dark" role="navigation">
|
||||
<div class="navbar-brand">
|
||||
<span class="navbar-item"><strong>📺 MORZ Infoboard</strong></span>
|
||||
</div>
|
||||
</nav>
|
||||
<section class="section pt-0">
|
||||
<div class="container">
|
||||
|
||||
<div class="box">
|
||||
<h2 class="title is-5">Bildschirme</h2>
|
||||
{{if .Screens}}
|
||||
<table class="table is-fullwidth is-hoverable is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th>Format</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Screens}}
|
||||
<tr>
|
||||
<td><strong>{{.Name}}</strong></td>
|
||||
<td><code>{{.Slug}}</code></td>
|
||||
<td>{{orientationLabel .Orientation}}</td>
|
||||
<td>
|
||||
<a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a>
|
||||
|
||||
<form method="POST" action="/admin/screens/{{.ID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Bildschirm löschen?\n\nAlle Playlist-Einträge werden ebenfalls gelöscht.')">
|
||||
<button class="button is-small is-danger is-outlined" type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="has-text-grey">Noch keine Bildschirme angelegt.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<h2 class="title is-5">Neuer Bildschirm</h2>
|
||||
<form method="POST" action="/admin/screens">
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Slug</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="slug" placeholder="z.B. flur-eg" required
|
||||
pattern="[a-z0-9-]+" title="Nur Kleinbuchstaben, Zahlen und Bindestriche">
|
||||
</div>
|
||||
<p class="help">URL-sichere Kennung (eindeutig)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="field">
|
||||
<label class="label">Name</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" name="name" placeholder="z.B. Flur Erdgeschoss" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<div class="field">
|
||||
<label class="label">Format</label>
|
||||
<div class="control">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="orientation">
|
||||
<option value="landscape">Querformat</option>
|
||||
<option value="portrait">Hochformat</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label"> </label>
|
||||
<div class="control">
|
||||
<button class="button is-primary is-fullwidth" type="submit">Erstellen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const manageTmpl = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Playlist – {{.Screen.Name}}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js"></script>
|
||||
<style>
|
||||
body { background: #f5f5f5; }
|
||||
.drag-handle { cursor: grab; color: #aaa; font-size: 1.2em; user-select: none; }
|
||||
.drag-handle:hover { color: #333; }
|
||||
.item-disabled td { opacity: 0.5; }
|
||||
.edit-row td { background: #fffbf0; padding: 0.75rem 1rem; }
|
||||
.tag-type { font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.sortable-ghost { background: #e8f4fd !important; }
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.is-active { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar is-dark" role="navigation">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="/admin">← Admin</a>
|
||||
<span class="navbar-item">
|
||||
<strong>{{.Screen.Name}}</strong>
|
||||
|
||||
<span class="tag is-info is-light">{{orientationLabel .Screen.Orientation}}</span>
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section class="section pt-4">
|
||||
<div class="container">
|
||||
|
||||
<!-- ── Playlist ── -->
|
||||
<div class="box">
|
||||
<h2 class="title is-5 mb-3">Aktuelle Playlist</h2>
|
||||
{{if .Items}}
|
||||
<table class="table is-fullwidth" id="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:2rem"></th>
|
||||
<th style="width:5rem">Typ</th>
|
||||
<th>Titel / Quelle</th>
|
||||
<th style="width:6rem">Dauer</th>
|
||||
<th style="width:7rem">Status</th>
|
||||
<th style="width:12rem">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sortable-items">
|
||||
{{range .Items}}
|
||||
<tr id="item-{{.ID}}" class="{{if not .Enabled}}item-disabled{{end}}">
|
||||
<td class="drag-handle" title="Ziehen zum Sortieren">⠿</td>
|
||||
<td>
|
||||
<span class="tag is-light tag-type">{{typeIcon .Type}} {{.Type}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div>{{if .Title}}<strong>{{.Title}}</strong>{{else}}<em class="has-text-grey">{{shortSrc .Src}}</em>{{end}}</div>
|
||||
{{if .Title}}<small class="has-text-grey">{{shortSrc .Src}}</small>{{end}}
|
||||
</td>
|
||||
<td>{{.DurationSeconds}} s</td>
|
||||
<td>
|
||||
{{if .Enabled}}
|
||||
<span class="tag is-success is-light">Aktiv</span>
|
||||
{{else}}
|
||||
<span class="tag is-warning is-light">Deaktiviert</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<button class="button is-small is-info is-outlined" onclick="toggleEdit('{{.ID}}')">Bearbeiten</button>
|
||||
<form method="POST" action="/manage/{{$.Screen.Slug}}/items/{{.ID}}/delete"
|
||||
style="display:inline"
|
||||
onsubmit="return confirm('Eintrag wirklich aus der Playlist entfernen?')">
|
||||
<button class="button is-small is-danger is-outlined" type="submit" title="Entfernen">✕</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="edit-{{.ID}}" class="edit-row" style="display:none">
|
||||
<td colspan="6">
|
||||
<form method="POST" action="/manage/{{$.Screen.Slug}}/items/{{.ID}}">
|
||||
<div class="columns is-vcentered is-multiline">
|
||||
<div class="column is-4">
|
||||
<label class="label is-small">Titel</label>
|
||||
<input class="input is-small" type="text" name="title" value="{{.Title}}"
|
||||
placeholder="Anzeigename (optional)">
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<label class="label is-small">Dauer (Sek.)</label>
|
||||
<input class="input is-small" type="number" name="duration_seconds"
|
||||
value="{{.DurationSeconds}}" min="1" max="3600" style="width:6rem">
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<label class="label is-small">Gültig ab</label>
|
||||
<input class="input is-small" type="datetime-local" name="valid_from"
|
||||
value="{{formatDT .ValidFrom}}">
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<label class="label is-small">Gültig bis</label>
|
||||
<input class="input is-small" type="datetime-local" name="valid_until"
|
||||
value="{{formatDT .ValidUntil}}">
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<label class="label is-small">Aktiv</label>
|
||||
<div class="select is-small">
|
||||
<select name="enabled">
|
||||
<option value="true"{{if .Enabled}} selected{{end}}>Ja</option>
|
||||
<option value="false"{{if not .Enabled}} selected{{end}}>Nein</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<label class="label is-small"> </label>
|
||||
<div class="buttons">
|
||||
<button class="button is-small is-success" type="submit">Speichern</button>
|
||||
<button class="button is-small" type="button" onclick="toggleEdit('{{.ID}}')">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="help has-text-grey mt-2">Einträge per Drag & Drop in der Reihenfolge verschieben.</p>
|
||||
{{else}}
|
||||
<div class="notification is-light">
|
||||
Die Playlist ist noch leer. Füge unten Medien aus der Bibliothek hinzu oder lade neue Dateien hoch.
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- ── Medienbibliothek ── -->
|
||||
<div class="box">
|
||||
<h2 class="title is-5 mb-3">Medienbibliothek</h2>
|
||||
{{if .Assets}}
|
||||
<table class="table is-fullwidth is-hoverable is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:5rem">Typ</th>
|
||||
<th>Titel</th>
|
||||
<th>Quelle</th>
|
||||
<th style="width:14rem">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Assets}}
|
||||
<tr>
|
||||
<td><span class="tag is-light tag-type">{{typeIcon .Type}} {{.Type}}</span></td>
|
||||
<td>{{.Title}}</td>
|
||||
<td>
|
||||
<small class="has-text-grey">
|
||||
{{if .StoragePath}}{{shortSrc .StoragePath}}{{else}}{{shortSrc .OriginalURL}}{{end}}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
{{if index $.AddedAssets .ID}}
|
||||
<span class="tag is-success is-light mr-2">✓ In Playlist</span>
|
||||
{{else}}
|
||||
<form method="POST" action="/manage/{{$.Screen.Slug}}/items" style="display:inline">
|
||||
<input type="hidden" name="media_asset_id" value="{{.ID}}">
|
||||
<button class="button is-small is-primary" type="submit">+ Hinzufügen</button>
|
||||
</form>
|
||||
|
||||
{{end}}
|
||||
<form method="POST" action="/manage/{{$.Screen.Slug}}/media/{{.ID}}/delete"
|
||||
style="display:inline"
|
||||
onsubmit="return confirm('Medium wirklich aus der Bibliothek löschen?\n(Playlist-Einträge bleiben bestehen, zeigen dann aber nichts an.)')">
|
||||
<button class="button is-small is-danger is-outlined" type="submit" title="Aus Bibliothek löschen">🗑</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="has-text-grey">Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder füge eine Webseite hinzu.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- ── Neues Medium hinzufügen ── -->
|
||||
<div class="box">
|
||||
<h2 class="title is-5 mb-3">Neues Medium hinzufügen</h2>
|
||||
|
||||
<div class="tabs" id="upload-tabs">
|
||||
<ul>
|
||||
<li id="tab-file" class="is-active"><a onclick="switchTab('file')">📁 Datei hochladen</a></li>
|
||||
<li id="tab-web"><a onclick="switchTab('web')">🌐 Webseite / URL</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="panel-file" class="tab-panel is-active">
|
||||
<form method="POST" action="/manage/{{.Screen.Slug}}/upload" enctype="multipart/form-data">
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column is-2">
|
||||
<div class="field">
|
||||
<label class="label">Typ</label>
|
||||
<div class="select is-fullwidth">
|
||||
<select name="type">
|
||||
<option value="image">🖼 Bild</option>
|
||||
<option value="video">🎬 Video</option>
|
||||
<option value="pdf">📄 PDF</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Titel <span class="has-text-grey is-size-7">(optional)</span></label>
|
||||
<input class="input" type="text" name="title"
|
||||
placeholder="Wird aus Dateinamen abgeleitet, wenn leer">
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Datei</label>
|
||||
<div class="control">
|
||||
<input class="input" type="file" name="file" required
|
||||
accept="image/*,video/*,application/pdf">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<div class="field">
|
||||
<label class="label"> </label>
|
||||
<button class="button is-primary" type="submit">Hochladen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="panel-web" class="tab-panel">
|
||||
<form method="POST" action="/manage/{{.Screen.Slug}}/upload" enctype="multipart/form-data">
|
||||
<input type="hidden" name="type" value="web">
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">URL</label>
|
||||
<input class="input" type="url" name="url"
|
||||
placeholder="https://example.com" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="field">
|
||||
<label class="label">Titel <span class="has-text-grey is-size-7">(optional)</span></label>
|
||||
<input class="input" type="text" name="title" placeholder="Anzeigename">
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<div class="field">
|
||||
<label class="label"> </label>
|
||||
<button class="button is-primary" type="submit">Hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function toggleEdit(id) {
|
||||
var row = document.getElementById('edit-' + id);
|
||||
if (row) {
|
||||
row.style.display = (row.style.display === 'none' || row.style.display === '') ? 'table-row' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
var panels = ['file', 'web'];
|
||||
panels.forEach(function(p) {
|
||||
var panel = document.getElementById('panel-' + p);
|
||||
var tabEl = document.getElementById('tab-' + p);
|
||||
if (p === tab) {
|
||||
panel.classList.add('is-active');
|
||||
tabEl.classList.add('is-active');
|
||||
} else {
|
||||
panel.classList.remove('is-active');
|
||||
tabEl.classList.remove('is-active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drag-and-drop reordering
|
||||
var sortableEl = document.getElementById('sortable-items');
|
||||
if (sortableEl) {
|
||||
Sortable.create(sortableEl, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
onEnd: function() {
|
||||
var ids = [];
|
||||
sortableEl.querySelectorAll('tr[id^="item-"]').forEach(function(tr) {
|
||||
ids.push(tr.id.replace('item-', ''));
|
||||
});
|
||||
fetch('/manage/{{.Screen.Slug}}/reorder', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(ids)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
390
server/backend/internal/httpapi/manage/ui.go
Normal file
390
server/backend/internal/httpapi/manage/ui.go
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
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/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
|
||||
}
|
||||
|
||||
tenant, _ := tenants.Get(r.Context(), "morz") // v1: single tenant
|
||||
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"
|
||||
}
|
||||
|
||||
tenant, err := tenants.Get(r.Context(), "morz")
|
||||
if err != nil {
|
||||
http.Error(w, "standard-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", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// 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", 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, http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAddItemUI handles form POST to add a playlist item, then redirects.
|
||||
func HandleAddItemUI(playlists *store.PlaylistStore, media *store.MediaStore, screens *store.ScreenStore) 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
|
||||
}
|
||||
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeleteItemUI removes a playlist item and redirects back.
|
||||
func HandleDeleteItemUI(playlists *store.PlaylistStore) 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
|
||||
}
|
||||
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleReorderUI accepts JSON body with ordered IDs (HTMX/fetch).
|
||||
func HandleReorderUI(playlists *store.PlaylistStore, screens *store.ScreenStore) 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
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUpdateItemUI handles form PATCH/POST to update a single item.
|
||||
func HandleUpdateItemUI(playlists *store.PlaylistStore) 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") != "false"
|
||||
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
|
||||
}
|
||||
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeleteMediaUI deletes media and redirects back.
|
||||
func HandleDeleteMediaUI(media *store.MediaStore, screens *store.ScreenStore, uploadDir string) 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
|
||||
|
||||
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,28 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/manage"
|
||||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||||
)
|
||||
|
||||
func NewRouter(store playerStatusStore) http.Handler {
|
||||
// RouterDeps holds all dependencies needed to build the HTTP router.
|
||||
type RouterDeps struct {
|
||||
StatusStore playerStatusStore
|
||||
TenantStore *store.TenantStore
|
||||
ScreenStore *store.ScreenStore
|
||||
MediaStore *store.MediaStore
|
||||
PlaylistStore *store.PlaylistStore
|
||||
UploadDir string
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
func NewRouter(deps RouterDeps) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// ── Health ───────────────────────────────────────────────────────────
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "ok",
|
||||
|
|
@ -14,9 +30,11 @@ func NewRouter(store playerStatusStore) http.Handler {
|
|||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /status", handleStatusPage(store))
|
||||
mux.HandleFunc("GET /status/{screenId}", handleScreenDetailPage(store))
|
||||
// ── Status / diagnostic UI ───────────────────────────────────────────
|
||||
mux.HandleFunc("GET /status", handleStatusPage(deps.StatusStore))
|
||||
mux.HandleFunc("GET /status/{screenId}", handleScreenDetailPage(deps.StatusStore))
|
||||
|
||||
// ── API meta ─────────────────────────────────────────────────────────
|
||||
mux.HandleFunc("GET /api/v1", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"name": "morz-infoboard-backend",
|
||||
|
|
@ -30,15 +48,82 @@ func NewRouter(store playerStatusStore) http.Handler {
|
|||
},
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /api/v1/meta", handleMeta)
|
||||
|
||||
mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(store))
|
||||
mux.HandleFunc("GET /api/v1/screens/status", handleListLatestPlayerStatuses(store))
|
||||
mux.HandleFunc("GET /api/v1/screens/{screenId}/status", handleGetLatestPlayerStatus(store))
|
||||
mux.HandleFunc("DELETE /api/v1/screens/{screenId}/status", handleDeletePlayerStatus(store))
|
||||
// ── Player status (existing) ──────────────────────────────────────────
|
||||
mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(deps.StatusStore))
|
||||
mux.HandleFunc("GET /api/v1/screens/status", handleListLatestPlayerStatuses(deps.StatusStore))
|
||||
mux.HandleFunc("GET /api/v1/screens/{screenId}/status", handleGetLatestPlayerStatus(deps.StatusStore))
|
||||
mux.HandleFunc("DELETE /api/v1/screens/{screenId}/status", handleDeletePlayerStatus(deps.StatusStore))
|
||||
|
||||
// ── Message wall ──────────────────────────────────────────────────────
|
||||
mux.HandleFunc("POST /api/v1/tools/message-wall/resolve", handleResolveMessageWall)
|
||||
|
||||
// ── Playlist management — only register if stores are wired up ────────
|
||||
if deps.TenantStore != nil {
|
||||
registerManageRoutes(mux, deps)
|
||||
}
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
||||
uploadDir := d.UploadDir
|
||||
if uploadDir == "" {
|
||||
uploadDir = "/tmp/morz-uploads"
|
||||
}
|
||||
|
||||
// Serve uploaded files.
|
||||
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir))))
|
||||
|
||||
// ── Admin UI ──────────────────────────────────────────────────────────
|
||||
mux.HandleFunc("GET /admin", manage.HandleAdminUI(d.TenantStore, d.ScreenStore))
|
||||
mux.HandleFunc("POST /admin/screens", manage.HandleCreateScreenUI(d.TenantStore, d.ScreenStore))
|
||||
mux.HandleFunc("POST /admin/screens/{screenId}/delete", manage.HandleDeleteScreenUI(d.ScreenStore))
|
||||
|
||||
// ── Playlist management UI ────────────────────────────────────────────
|
||||
mux.HandleFunc("GET /manage/{screenSlug}",
|
||||
manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.MediaStore, d.PlaylistStore))
|
||||
mux.HandleFunc("POST /manage/{screenSlug}/upload",
|
||||
manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir))
|
||||
mux.HandleFunc("POST /manage/{screenSlug}/items",
|
||||
manage.HandleAddItemUI(d.PlaylistStore, d.MediaStore, d.ScreenStore))
|
||||
mux.HandleFunc("POST /manage/{screenSlug}/items/{itemId}",
|
||||
manage.HandleUpdateItemUI(d.PlaylistStore))
|
||||
mux.HandleFunc("POST /manage/{screenSlug}/items/{itemId}/delete",
|
||||
manage.HandleDeleteItemUI(d.PlaylistStore))
|
||||
mux.HandleFunc("POST /manage/{screenSlug}/reorder",
|
||||
manage.HandleReorderUI(d.PlaylistStore, d.ScreenStore))
|
||||
mux.HandleFunc("POST /manage/{screenSlug}/media/{mediaId}/delete",
|
||||
manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir))
|
||||
|
||||
// ── JSON API — screens ────────────────────────────────────────────────
|
||||
mux.HandleFunc("GET /api/v1/tenants/{tenantSlug}/screens",
|
||||
manage.HandleListScreens(d.TenantStore, d.ScreenStore))
|
||||
mux.HandleFunc("POST /api/v1/tenants/{tenantSlug}/screens",
|
||||
manage.HandleCreateScreen(d.TenantStore, d.ScreenStore))
|
||||
|
||||
// ── JSON API — media ──────────────────────────────────────────────────
|
||||
mux.HandleFunc("GET /api/v1/tenants/{tenantSlug}/media",
|
||||
manage.HandleListMedia(d.MediaStore))
|
||||
mux.HandleFunc("POST /api/v1/tenants/{tenantSlug}/media",
|
||||
manage.HandleUploadMedia(d.MediaStore, uploadDir))
|
||||
mux.HandleFunc("DELETE /api/v1/media/{id}",
|
||||
manage.HandleDeleteMedia(d.MediaStore, uploadDir))
|
||||
|
||||
// ── JSON API — playlists ──────────────────────────────────────────────
|
||||
mux.HandleFunc("GET /api/v1/screens/{screenId}/playlist",
|
||||
manage.HandlePlayerPlaylist(d.ScreenStore, d.PlaylistStore))
|
||||
mux.HandleFunc("GET /api/v1/playlists/{screenId}",
|
||||
manage.HandleGetPlaylist(d.ScreenStore, d.PlaylistStore))
|
||||
mux.HandleFunc("POST /api/v1/playlists/{playlistId}/items",
|
||||
manage.HandleAddItem(d.PlaylistStore, d.MediaStore))
|
||||
mux.HandleFunc("PATCH /api/v1/items/{itemId}",
|
||||
manage.HandleUpdateItem(d.PlaylistStore))
|
||||
mux.HandleFunc("DELETE /api/v1/items/{itemId}",
|
||||
manage.HandleDeleteItem(d.PlaylistStore))
|
||||
mux.HandleFunc("PUT /api/v1/playlists/{playlistId}/order",
|
||||
manage.HandleReorder(d.PlaylistStore))
|
||||
mux.HandleFunc("PATCH /api/v1/playlists/{playlistId}/duration",
|
||||
manage.HandleUpdatePlaylistDuration(d.PlaylistStore))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ func TestRouterHealthz(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
|
||||
NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
|
||||
|
||||
if got, want := w.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status = %d, want %d", got, want)
|
||||
|
|
@ -42,7 +42,7 @@ func TestRouterBaseAPI(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodGet, "/api/v1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
|
||||
NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
|
||||
|
||||
if got, want := w.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status = %d, want %d", got, want)
|
||||
|
|
@ -95,7 +95,7 @@ func TestRouterMeta(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
|
||||
NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
|
||||
|
||||
if got, want := w.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status = %d, want %d", got, want)
|
||||
|
|
@ -168,7 +168,7 @@ func TestRouterPlayerStatusRoute(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString(`{"screen_id":"demo","ts":"2026-03-22T16:00:00Z","status":"running","heartbeat_every_seconds":30}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
|
||||
NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
|
||||
|
||||
if got, want := w.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status = %d, want %d", got, want)
|
||||
|
|
@ -181,7 +181,7 @@ func TestRouterScreenStatusRoute(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/demo/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
NewRouter(store).ServeHTTP(w, req)
|
||||
NewRouter(RouterDeps{StatusStore: store}).ServeHTTP(w, req)
|
||||
|
||||
if got, want := w.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status = %d, want %d", got, want)
|
||||
|
|
@ -193,7 +193,7 @@ func TestRouterScreenStatusListRoute(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/status", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
NewRouter(store).ServeHTTP(w, req)
|
||||
NewRouter(RouterDeps{StatusStore: store}).ServeHTTP(w, req)
|
||||
|
||||
if got, want := w.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status = %d, want %d", got, want)
|
||||
|
|
@ -219,7 +219,7 @@ func TestRouterScreenDetailPageRoute(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodGet, "/status/info01-dev", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
NewRouter(store).ServeHTTP(w, req)
|
||||
NewRouter(RouterDeps{StatusStore: store}).ServeHTTP(w, req)
|
||||
|
||||
if got, want := w.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status = %d, want %d", got, want)
|
||||
|
|
@ -254,7 +254,7 @@ func TestRouterScreenDetailPageNotFound(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodGet, "/status/missing-screen", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
|
||||
NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
|
||||
|
||||
if got, want := w.Code, http.StatusNotFound; got != want {
|
||||
t.Fatalf("status = %d, want %d", got, want)
|
||||
|
|
@ -286,7 +286,7 @@ func TestRouterStatusPageRejectsInvalidQueryParams(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodGet, "/status"+tc.query, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
|
||||
NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
|
||||
|
||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||
t.Fatalf("status = %d, want %d", got, want)
|
||||
|
|
@ -309,7 +309,7 @@ func TestRouterStatusPageRoute(t *testing.T) {
|
|||
req := httptest.NewRequest(http.MethodGet, "/status?server_connectivity=offline&stale=true&updated_since=2026-03-22T15:55:00Z&limit=10", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
NewRouter(store).ServeHTTP(w, req)
|
||||
NewRouter(RouterDeps{StatusStore: store}).ServeHTTP(w, req)
|
||||
|
||||
if got, want := w.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status = %d, want %d", got, want)
|
||||
|
|
|
|||
426
server/backend/internal/store/store.go
Normal file
426
server/backend/internal/store/store.go
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
// Package store contains all database repositories.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Domain types
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
type Tenant struct {
|
||||
ID string
|
||||
Slug string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Screen struct {
|
||||
ID string
|
||||
TenantID string
|
||||
Slug string
|
||||
Name string
|
||||
Orientation string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type MediaAsset struct {
|
||||
ID string
|
||||
TenantID string
|
||||
Title string
|
||||
Type string // image | video | pdf | web
|
||||
StoragePath string
|
||||
OriginalURL string
|
||||
MimeType string
|
||||
SizeBytes int64
|
||||
Enabled bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Playlist struct {
|
||||
ID string
|
||||
TenantID string
|
||||
ScreenID string
|
||||
Name string
|
||||
IsActive bool
|
||||
DefaultDurationSeconds int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type PlaylistItem struct {
|
||||
ID string
|
||||
PlaylistID string
|
||||
MediaAssetID string // may be empty for web items without asset
|
||||
OrderIndex int
|
||||
Type string // image | video | pdf | web
|
||||
Src string
|
||||
Title string
|
||||
DurationSeconds int
|
||||
ValidFrom *time.Time
|
||||
ValidUntil *time.Time
|
||||
Enabled bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Stores
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
type TenantStore struct{ pool *pgxpool.Pool }
|
||||
type ScreenStore struct{ pool *pgxpool.Pool }
|
||||
type MediaStore struct{ pool *pgxpool.Pool }
|
||||
type PlaylistStore struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewTenantStore(pool *pgxpool.Pool) *TenantStore { return &TenantStore{pool} }
|
||||
func NewScreenStore(pool *pgxpool.Pool) *ScreenStore { return &ScreenStore{pool} }
|
||||
func NewMediaStore(pool *pgxpool.Pool) *MediaStore { return &MediaStore{pool} }
|
||||
func NewPlaylistStore(pool *pgxpool.Pool) *PlaylistStore { return &PlaylistStore{pool} }
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TenantStore
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
func (s *TenantStore) Get(ctx context.Context, slug string) (*Tenant, error) {
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`select id, slug, name, created_at from tenants where slug=$1`, slug)
|
||||
return scanTenant(row)
|
||||
}
|
||||
|
||||
func (s *TenantStore) List(ctx context.Context) ([]*Tenant, error) {
|
||||
rows, err := s.pool.Query(ctx, `select id, slug, name, created_at from tenants order by name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Tenant
|
||||
for rows.Next() {
|
||||
t, err := scanTenant(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanTenant(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (*Tenant, error) {
|
||||
var t Tenant
|
||||
err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan tenant: %w", err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// ScreenStore
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
func (s *ScreenStore) List(ctx context.Context, tenantID string) ([]*Screen, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`select id, tenant_id, slug, name, orientation, created_at
|
||||
from screens where tenant_id=$1 order by name`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Screen
|
||||
for rows.Next() {
|
||||
sc, err := scanScreen(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sc)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ScreenStore) ListAll(ctx context.Context) ([]*Screen, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`select id, tenant_id, slug, name, orientation, created_at from screens order by name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Screen
|
||||
for rows.Next() {
|
||||
sc, err := scanScreen(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sc)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ScreenStore) GetBySlug(ctx context.Context, slug string) (*Screen, error) {
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`select id, tenant_id, slug, name, orientation, created_at from screens where slug=$1`, slug)
|
||||
return scanScreen(row)
|
||||
}
|
||||
|
||||
func (s *ScreenStore) Create(ctx context.Context, tenantID, slug, name, orientation string) (*Screen, error) {
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`insert into screens(tenant_id, slug, name, orientation)
|
||||
values($1,$2,$3,$4)
|
||||
returning id, tenant_id, slug, name, orientation, created_at`,
|
||||
tenantID, slug, name, orientation)
|
||||
return scanScreen(row)
|
||||
}
|
||||
|
||||
func (s *ScreenStore) Update(ctx context.Context, id, name, orientation string) (*Screen, error) {
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`update screens set name=$2, orientation=$3 where id=$1
|
||||
returning id, tenant_id, slug, name, orientation, created_at`,
|
||||
id, name, orientation)
|
||||
return scanScreen(row)
|
||||
}
|
||||
|
||||
func (s *ScreenStore) Delete(ctx context.Context, id string) error {
|
||||
_, err := s.pool.Exec(ctx, `delete from screens where id=$1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanScreen(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (*Screen, error) {
|
||||
var sc Screen
|
||||
err := row.Scan(&sc.ID, &sc.TenantID, &sc.Slug, &sc.Name, &sc.Orientation, &sc.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan screen: %w", err)
|
||||
}
|
||||
return &sc, nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// MediaStore
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
func (s *MediaStore) List(ctx context.Context, tenantID string) ([]*MediaAsset, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`select id, tenant_id, title, type, coalesce(storage_path,''),
|
||||
coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
|
||||
enabled, created_at
|
||||
from media_assets where tenant_id=$1 order by created_at desc`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*MediaAsset
|
||||
for rows.Next() {
|
||||
m, err := scanMedia(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *MediaStore) Get(ctx context.Context, id string) (*MediaAsset, error) {
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`select id, tenant_id, title, type, coalesce(storage_path,''),
|
||||
coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
|
||||
enabled, created_at
|
||||
from media_assets where id=$1`, id)
|
||||
return scanMedia(row)
|
||||
}
|
||||
|
||||
func (s *MediaStore) Create(ctx context.Context, tenantID, title, assetType, storagePath, originalURL, mimeType string, sizeBytes int64) (*MediaAsset, error) {
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`insert into media_assets(tenant_id, title, type, storage_path, original_url, mime_type, size_bytes)
|
||||
values($1,$2,$3,nullif($4,''),nullif($5,''),nullif($6,''),nullif($7,0))
|
||||
returning id, tenant_id, title, type, coalesce(storage_path,''),
|
||||
coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
|
||||
enabled, created_at`,
|
||||
tenantID, title, assetType, storagePath, originalURL, mimeType, sizeBytes)
|
||||
return scanMedia(row)
|
||||
}
|
||||
|
||||
func (s *MediaStore) Delete(ctx context.Context, id string) error {
|
||||
_, err := s.pool.Exec(ctx, `delete from media_assets where id=$1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanMedia(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (*MediaAsset, error) {
|
||||
var m MediaAsset
|
||||
err := row.Scan(&m.ID, &m.TenantID, &m.Title, &m.Type,
|
||||
&m.StoragePath, &m.OriginalURL, &m.MimeType, &m.SizeBytes,
|
||||
&m.Enabled, &m.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan media: %w", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// PlaylistStore
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
func (s *PlaylistStore) GetOrCreateForScreen(ctx context.Context, tenantID, screenID, screenName string) (*Playlist, error) {
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`insert into playlists(tenant_id, screen_id, name)
|
||||
values($1,$2,$3)
|
||||
on conflict(screen_id) do update set updated_at=now()
|
||||
returning id, tenant_id, screen_id, name, is_active, default_duration_seconds, created_at, updated_at`,
|
||||
tenantID, screenID, screenName+" Playlist")
|
||||
return scanPlaylist(row)
|
||||
}
|
||||
|
||||
func (s *PlaylistStore) Get(ctx context.Context, id string) (*Playlist, error) {
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`select id, tenant_id, screen_id, name, is_active, default_duration_seconds, created_at, updated_at
|
||||
from playlists where id=$1`, id)
|
||||
return scanPlaylist(row)
|
||||
}
|
||||
|
||||
func (s *PlaylistStore) GetByScreen(ctx context.Context, screenID string) (*Playlist, error) {
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`select id, tenant_id, screen_id, name, is_active, default_duration_seconds, created_at, updated_at
|
||||
from playlists where screen_id=$1`, screenID)
|
||||
return scanPlaylist(row)
|
||||
}
|
||||
|
||||
func (s *PlaylistStore) UpdateDefaultDuration(ctx context.Context, id string, seconds int) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`update playlists set default_duration_seconds=$2, updated_at=now() where id=$1`, id, seconds)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanPlaylist(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (*Playlist, error) {
|
||||
var p Playlist
|
||||
err := row.Scan(&p.ID, &p.TenantID, &p.ScreenID, &p.Name,
|
||||
&p.IsActive, &p.DefaultDurationSeconds, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan playlist: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// PlaylistItemStore (part of PlaylistStore for simplicity)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
func (s *PlaylistStore) ListItems(ctx context.Context, playlistID string) ([]*PlaylistItem, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`select id, playlist_id, coalesce(media_asset_id,''), order_index, type, src,
|
||||
coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at
|
||||
from playlist_items where playlist_id=$1 order by order_index`, playlistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*PlaylistItem
|
||||
for rows.Next() {
|
||||
item, err := scanPlaylistItem(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PlaylistStore) ListActiveItems(ctx context.Context, playlistID string) ([]*PlaylistItem, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`select id, playlist_id, coalesce(media_asset_id,''), order_index, type, src,
|
||||
coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at
|
||||
from playlist_items
|
||||
where playlist_id=$1
|
||||
and enabled=true
|
||||
and (valid_from is null or valid_from <= now())
|
||||
and (valid_until is null or valid_until > now())
|
||||
order by order_index`, playlistID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*PlaylistItem
|
||||
for rows.Next() {
|
||||
item, err := scanPlaylistItem(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PlaylistStore) AddItem(ctx context.Context, playlistID, mediaAssetID, itemType, src, title string, durationSeconds int, validFrom, validUntil *time.Time) (*PlaylistItem, error) {
|
||||
// Place at end of list.
|
||||
var maxIdx int
|
||||
s.pool.QueryRow(ctx,
|
||||
`select coalesce(max(order_index)+1, 0) from playlist_items where playlist_id=$1`, playlistID,
|
||||
).Scan(&maxIdx) //nolint:errcheck
|
||||
|
||||
var mediaID *string
|
||||
if mediaAssetID != "" {
|
||||
mediaID = &mediaAssetID
|
||||
}
|
||||
|
||||
row := s.pool.QueryRow(ctx,
|
||||
`insert into playlist_items(playlist_id, media_asset_id, order_index, type, src, title, duration_seconds, valid_from, valid_until)
|
||||
values($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
returning id, playlist_id, coalesce(media_asset_id,''), order_index, type, src,
|
||||
coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at`,
|
||||
playlistID, mediaID, maxIdx, itemType, src, title, durationSeconds, validFrom, validUntil)
|
||||
return scanPlaylistItem(row)
|
||||
}
|
||||
|
||||
func (s *PlaylistStore) UpdateItem(ctx context.Context, id, title string, durationSeconds int, enabled bool, validFrom, validUntil *time.Time) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`update playlist_items
|
||||
set title=$2, duration_seconds=$3, enabled=$4, valid_from=$5, valid_until=$6
|
||||
where id=$1`,
|
||||
id, title, durationSeconds, enabled, validFrom, validUntil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PlaylistStore) DeleteItem(ctx context.Context, id string) error {
|
||||
_, err := s.pool.Exec(ctx, `delete from playlist_items where id=$1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Reorder sets order_index for each item ID in the given slice order.
|
||||
func (s *PlaylistStore) Reorder(ctx context.Context, playlistID string, itemIDs []string) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
for i, id := range itemIDs {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`update playlist_items set order_index=$1 where id=$2 and playlist_id=$3`,
|
||||
i, id, playlistID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func scanPlaylistItem(row interface {
|
||||
Scan(dest ...any) error
|
||||
}) (*PlaylistItem, error) {
|
||||
var it PlaylistItem
|
||||
err := row.Scan(&it.ID, &it.PlaylistID, &it.MediaAssetID, &it.OrderIndex,
|
||||
&it.Type, &it.Src, &it.Title, &it.DurationSeconds,
|
||||
&it.ValidFrom, &it.ValidUntil, &it.Enabled, &it.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan playlist_item: %w", err)
|
||||
}
|
||||
return &it, nil
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue