From f1fdd8d2c2cdd0f14e8c102a6a277522056d8df1 Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 00:34:58 -0500 Subject: [PATCH] Web UI: podcasts, radio, shares, bookmarks pages; admin podcast/jukebox management; per-user Subsonic permission editor --- internal/app/app.go | 17 +++-- internal/web/admin_handlers.go | 41 +++++++++++ internal/web/admin_jukebox_handlers.go | 42 ++++++++++++ internal/web/admin_podcasts_handlers.go | 83 +++++++++++++++++++++++ internal/web/bookmarks_handlers.go | 40 +++++++++++ internal/web/podcasts_handlers.go | 36 ++++++++++ internal/web/radio_handlers.go | 44 ++++++++++++ internal/web/render.go | 5 +- internal/web/router.go | 28 ++++++++ internal/web/shares_handlers.go | 66 ++++++++++++++++++ internal/web/web.go | 4 ++ web/templates/admin_jukebox.html | 41 +++++++++++ web/templates/admin_podcasts.html | 61 +++++++++++++++++ web/templates/admin_user_permissions.html | 56 +++++++++++++++ web/templates/admin_users.html | 1 + web/templates/album.html | 4 ++ web/templates/bookmarks.html | 24 +++++++ web/templates/partials/header.html | 6 ++ web/templates/podcasts.html | 31 +++++++++ web/templates/radio.html | 42 ++++++++++++ web/templates/shares.html | 25 +++++++ 21 files changed, 688 insertions(+), 9 deletions(-) create mode 100644 internal/web/admin_jukebox_handlers.go create mode 100644 internal/web/admin_podcasts_handlers.go create mode 100644 internal/web/bookmarks_handlers.go create mode 100644 internal/web/podcasts_handlers.go create mode 100644 internal/web/radio_handlers.go create mode 100644 internal/web/shares_handlers.go create mode 100644 web/templates/admin_jukebox.html create mode 100644 web/templates/admin_podcasts.html create mode 100644 web/templates/admin_user_permissions.html create mode 100644 web/templates/bookmarks.html create mode 100644 web/templates/podcasts.html create mode 100644 web/templates/radio.html create mode 100644 web/templates/shares.html diff --git a/internal/app/app.go b/internal/app/app.go index c612b5c..93f450c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -72,6 +72,12 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) { return nil, fmt.Errorf("deriving auth encryption key: %w", err) } + podcastMgr := podcasts.NewManager(db, cfg.DataDir, cfg.FFprobePath, log) + if err := podcastMgr.RecoverInterruptedDownloads(); err != nil { + log.Warn("podcast download recovery failed", "err", err) + } + jukeboxCtl := jukebox.New(cfg.MPVPath) + webApp := &web.Web{ DB: db, Config: cfg, @@ -81,14 +87,11 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) { Scanner: sc, CoverArt: covers, EncryptionKey: encryptionKey, + Podcasts: podcastMgr, + Jukebox: jukeboxCtl, Log: log, } - podcastMgr := podcasts.NewManager(db, cfg.DataDir, cfg.FFprobePath, log) - if err := podcastMgr.RecoverInterruptedDownloads(); err != nil { - log.Warn("podcast download recovery failed", "err", err) - } - subsonicSrv := &subsonic.Server{ DB: db, Config: cfg, @@ -97,7 +100,7 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) { CoverArt: covers, Transcoder: transcoding.NewManager(cfg.FFmpegPath), Podcasts: podcastMgr, - Jukebox: jukebox.New(cfg.MPVPath), + Jukebox: jukeboxCtl, Log: log, } @@ -105,7 +108,7 @@ func Open(cfg config.Config, log *slog.Logger) (*App, error) { } func (a *App) Close() error { - a.Subsonic.Jukebox.Close() + a.Web.Jukebox.Close() return a.DB.Close() } diff --git a/internal/web/admin_handlers.go b/internal/web/admin_handlers.go index 17216c6..90486bf 100644 --- a/internal/web/admin_handlers.go +++ b/internal/web/admin_handlers.go @@ -48,6 +48,47 @@ func (h *Web) handleAdminUserCreate(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/users", http.StatusFound) } +func (h *Web) handleAdminUserPermissions(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + target, err := database.GetUserByID(h.DB, id) + if err != nil { + http.NotFound(w, r) + return + } + h.page(w, r, "admin_user_permissions", "admin_users", "Permissions: "+target.Username, target) +} + +func (h *Web) handleAdminUserPermissionsUpdate(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if _, err := database.GetUserByID(h.DB, id); err != nil { + http.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + get := func(name string) bool { return r.FormValue(name) == "1" } + perms := database.Permissions{ + Settings: get("settings"), + Stream: get("stream"), + Download: get("download"), + Upload: get("upload"), + Playlist: get("playlist"), + CoverArt: get("cover_art"), + Comment: get("comment"), + Podcast: get("podcast"), + Share: get("share"), + Jukebox: get("jukebox"), + VideoConversion: get("video_conversion"), + } + if err := database.SetUserPermissions(h.DB, id, perms); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/admin/users", http.StatusFound) +} + func (h *Web) handleAdminUserToggleDisabled(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") target, err := database.GetUserByID(h.DB, id) diff --git a/internal/web/admin_jukebox_handlers.go b/internal/web/admin_jukebox_handlers.go new file mode 100644 index 0000000..6d6682e --- /dev/null +++ b/internal/web/admin_jukebox_handlers.go @@ -0,0 +1,42 @@ +package web + +import "net/http" + +func (h *Web) handleAdminJukebox(w http.ResponseWriter, r *http.Request) { + data := struct { + Available bool + Status any + Error string + }{Available: h.Jukebox.Available} + + if h.Jukebox.Available { + state, err := h.Jukebox.Status() + if err != nil { + data.Error = err.Error() + } else { + data.Status = state + } + } + h.page(w, r, "admin_jukebox", "admin_jukebox", "Jukebox", data) +} + +func (h *Web) handleAdminJukeboxStop(w http.ResponseWriter, r *http.Request) { + if h.Jukebox.Available { + _, _ = h.Jukebox.Stop() + } + http.Redirect(w, r, "/admin/jukebox", http.StatusFound) +} + +func (h *Web) handleAdminJukeboxStart(w http.ResponseWriter, r *http.Request) { + if h.Jukebox.Available { + _, _ = h.Jukebox.Start() + } + http.Redirect(w, r, "/admin/jukebox", http.StatusFound) +} + +func (h *Web) handleAdminJukeboxClear(w http.ResponseWriter, r *http.Request) { + if h.Jukebox.Available { + _, _ = h.Jukebox.Clear() + } + http.Redirect(w, r, "/admin/jukebox", http.StatusFound) +} diff --git a/internal/web/admin_podcasts_handlers.go b/internal/web/admin_podcasts_handlers.go new file mode 100644 index 0000000..10386c8 --- /dev/null +++ b/internal/web/admin_podcasts_handlers.go @@ -0,0 +1,83 @@ +package web + +import ( + "context" + "net/http" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func (h *Web) handleAdminPodcasts(w http.ResponseWriter, r *http.Request) { + channels, err := database.ListPodcastChannels(h.DB) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + type channelView struct { + Channel *database.PodcastChannel + Episodes []*database.PodcastEpisode + } + var views []channelView + for _, c := range channels { + episodes, _ := database.ListPodcastEpisodes(h.DB, c.ID) + views = append(views, channelView{Channel: c, Episodes: episodes}) + } + h.page(w, r, "admin_podcasts", "admin_podcasts", "Podcasts", views) +} + +func (h *Web) handleAdminPodcastCreate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + feedURL := r.FormValue("url") + if feedURL == "" { + http.Redirect(w, r, "/admin/podcasts", http.StatusFound) + return + } + channel, err := database.CreatePodcastChannel(h.DB, feedURL) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + go func() { _ = h.Podcasts.RefreshChannel(context.Background(), channel.ID) }() + http.Redirect(w, r, "/admin/podcasts", http.StatusFound) +} + +func (h *Web) handleAdminPodcastRefresh(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if _, err := database.GetPodcastChannel(h.DB, id); err != nil { + http.NotFound(w, r) + return + } + go func() { _ = h.Podcasts.RefreshChannel(context.Background(), id) }() + http.Redirect(w, r, "/admin/podcasts", http.StatusFound) +} + +func (h *Web) handleAdminPodcastDelete(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if err := database.DeletePodcastChannel(h.DB, id); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/admin/podcasts", http.StatusFound) +} + +func (h *Web) handleAdminPodcastEpisodeDownload(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if _, err := database.GetPodcastEpisode(h.DB, id); err != nil { + http.NotFound(w, r) + return + } + go func() { _ = h.Podcasts.DownloadEpisode(context.Background(), id) }() + http.Redirect(w, r, "/admin/podcasts", http.StatusFound) +} + +func (h *Web) handleAdminPodcastEpisodeDelete(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if err := database.DeletePodcastEpisode(h.DB, id); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/admin/podcasts", http.StatusFound) +} diff --git a/internal/web/bookmarks_handlers.go b/internal/web/bookmarks_handlers.go new file mode 100644 index 0000000..26c48b9 --- /dev/null +++ b/internal/web/bookmarks_handlers.go @@ -0,0 +1,40 @@ +package web + +import ( + "net/http" + + "git.noroutes.net/NoRoutes/vibetunes/internal/auth" + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func (h *Web) handleBookmarks(w http.ResponseWriter, r *http.Request) { + user := auth.UserFromContext(r.Context()) + bookmarks, err := database.ListBookmarks(h.DB, user.ID) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + type bookmarkView struct { + Bookmark *database.Bookmark + Song SongRow + } + var views []bookmarkView + for _, b := range bookmarks { + song, err := database.GetSong(h.DB, b.SongID) + if err != nil { + continue + } + views = append(views, bookmarkView{Bookmark: b, Song: toSongRow(h.DB, song, albumOf(h.DB, song), user.ID)}) + } + h.page(w, r, "bookmarks", "bookmarks", "Bookmarks", views) +} + +func (h *Web) handleBookmarkDelete(w http.ResponseWriter, r *http.Request) { + user := auth.UserFromContext(r.Context()) + songID := r.PathValue("songID") + if err := database.DeleteBookmark(h.DB, user.ID, songID); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/bookmarks", http.StatusFound) +} diff --git a/internal/web/podcasts_handlers.go b/internal/web/podcasts_handlers.go new file mode 100644 index 0000000..219e82a --- /dev/null +++ b/internal/web/podcasts_handlers.go @@ -0,0 +1,36 @@ +package web + +import ( + "context" + "net/http" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func (h *Web) handlePodcasts(w http.ResponseWriter, r *http.Request) { + channels, err := database.ListPodcastChannels(h.DB) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + type channelView struct { + Channel *database.PodcastChannel + Episodes []*database.PodcastEpisode + } + var views []channelView + for _, c := range channels { + episodes, _ := database.ListPodcastEpisodes(h.DB, c.ID) + views = append(views, channelView{Channel: c, Episodes: episodes}) + } + h.page(w, r, "podcasts", "podcasts", "Podcasts", views) +} + +func (h *Web) handlePodcastDownload(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if _, err := database.GetPodcastEpisode(h.DB, id); err != nil { + http.NotFound(w, r) + return + } + go func() { _ = h.Podcasts.DownloadEpisode(context.Background(), id) }() + http.Redirect(w, r, "/podcasts", http.StatusFound) +} diff --git a/internal/web/radio_handlers.go b/internal/web/radio_handlers.go new file mode 100644 index 0000000..536f0f1 --- /dev/null +++ b/internal/web/radio_handlers.go @@ -0,0 +1,44 @@ +package web + +import ( + "net/http" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" +) + +func (h *Web) handleRadio(w http.ResponseWriter, r *http.Request) { + stations, err := database.ListRadioStations(h.DB) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.page(w, r, "radio", "radio", "Internet Radio", stations) +} + +func (h *Web) handleRadioCreate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.FormValue("name") + streamURL := r.FormValue("stream_url") + homepage := r.FormValue("homepage_url") + if name == "" || streamURL == "" { + http.Redirect(w, r, "/radio", http.StatusFound) + return + } + if _, err := database.CreateRadioStation(h.DB, name, streamURL, homepage); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/radio", http.StatusFound) +} + +func (h *Web) handleRadioDelete(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if err := database.DeleteRadioStation(h.DB, id); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/radio", http.StatusFound) +} diff --git a/internal/web/render.go b/internal/web/render.go index b823744..8365c13 100644 --- a/internal/web/render.go +++ b/internal/web/render.go @@ -26,8 +26,9 @@ var funcMap = template.FuncMap{ } return t.Format("2006-01-02 15:04") }, - "add": func(a, b int) int { return a + b }, - "sub": func(a, b int) int { return a - b }, + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + "millisToSec": func(ms int) float64 { return float64(ms) / 1000 }, } var templates *template.Template diff --git a/internal/web/router.go b/internal/web/router.go index 4c00c58..df2a533 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -48,6 +48,20 @@ func (h *Web) NewRouter() http.Handler { mux.HandleFunc("POST /favorites/unstar", h.requireUser(h.handleFavoriteUnstar)) mux.HandleFunc("GET /recent", h.requireUser(h.handleRecent)) + mux.HandleFunc("GET /podcasts", h.requireUser(h.handlePodcasts)) + mux.HandleFunc("POST /podcasts/{id}/download", h.requireUser(h.handlePodcastDownload)) + + mux.HandleFunc("GET /radio", h.requireUser(h.handleRadio)) + mux.HandleFunc("POST /radio", h.requireAdmin(h.handleRadioCreate)) + mux.HandleFunc("POST /radio/{id}/delete", h.requireAdmin(h.handleRadioDelete)) + + mux.HandleFunc("GET /shares", h.requireUser(h.handleShares)) + mux.HandleFunc("POST /albums/{id}/share", h.requireUser(h.handleShareCreateForAlbum)) + mux.HandleFunc("POST /shares/{id}/delete", h.requireUser(h.handleShareDelete)) + + mux.HandleFunc("GET /bookmarks", h.requireUser(h.handleBookmarks)) + mux.HandleFunc("POST /bookmarks/{songID}/delete", h.requireUser(h.handleBookmarkDelete)) + mux.HandleFunc("GET /settings", h.requireUser(h.handleSettings)) mux.HandleFunc("POST /settings/password", h.requireUser(h.handlePasswordChange)) mux.HandleFunc("POST /settings/api-keys", h.requireUser(h.handleAPIKeyCreate)) @@ -62,6 +76,8 @@ func (h *Web) NewRouter() http.Handler { mux.HandleFunc("POST /admin/users", h.requireAdmin(h.handleAdminUserCreate)) mux.HandleFunc("POST /admin/users/{id}/toggle-disabled", h.requireAdmin(h.handleAdminUserToggleDisabled)) mux.HandleFunc("POST /admin/users/{id}/delete", h.requireAdmin(h.handleAdminUserDelete)) + mux.HandleFunc("GET /admin/users/{id}/permissions", h.requireAdmin(h.handleAdminUserPermissions)) + mux.HandleFunc("POST /admin/users/{id}/permissions", h.requireAdmin(h.handleAdminUserPermissionsUpdate)) mux.HandleFunc("GET /admin/folders", h.requireAdmin(h.handleAdminFolders)) mux.HandleFunc("POST /admin/folders", h.requireAdmin(h.handleAdminFolderCreate)) @@ -73,6 +89,18 @@ func (h *Web) NewRouter() http.Handler { mux.HandleFunc("GET /admin/settings", h.requireAdmin(h.handleAdminSettings)) mux.HandleFunc("POST /admin/settings", h.requireAdmin(h.handleAdminSettingsUpdate)) + mux.HandleFunc("GET /admin/podcasts", h.requireAdmin(h.handleAdminPodcasts)) + mux.HandleFunc("POST /admin/podcasts", h.requireAdmin(h.handleAdminPodcastCreate)) + mux.HandleFunc("POST /admin/podcasts/{id}/refresh", h.requireAdmin(h.handleAdminPodcastRefresh)) + mux.HandleFunc("POST /admin/podcasts/{id}/delete", h.requireAdmin(h.handleAdminPodcastDelete)) + mux.HandleFunc("POST /admin/podcasts/episodes/{id}/download", h.requireAdmin(h.handleAdminPodcastEpisodeDownload)) + mux.HandleFunc("POST /admin/podcasts/episodes/{id}/delete", h.requireAdmin(h.handleAdminPodcastEpisodeDelete)) + + mux.HandleFunc("GET /admin/jukebox", h.requireAdmin(h.handleAdminJukebox)) + mux.HandleFunc("POST /admin/jukebox/start", h.requireAdmin(h.handleAdminJukeboxStart)) + mux.HandleFunc("POST /admin/jukebox/stop", h.requireAdmin(h.handleAdminJukeboxStop)) + mux.HandleFunc("POST /admin/jukebox/clear", h.requireAdmin(h.handleAdminJukeboxClear)) + var handler http.Handler = mux handler = h.CSRF.Protect(handler) handler = h.setupGate(handler) diff --git a/internal/web/shares_handlers.go b/internal/web/shares_handlers.go new file mode 100644 index 0000000..cf9f168 --- /dev/null +++ b/internal/web/shares_handlers.go @@ -0,0 +1,66 @@ +package web + +import ( + "database/sql" + "net/http" + + "git.noroutes.net/NoRoutes/vibetunes/internal/auth" + "git.noroutes.net/NoRoutes/vibetunes/internal/database" + "git.noroutes.net/NoRoutes/vibetunes/internal/idgen" +) + +func (h *Web) handleShares(w http.ResponseWriter, r *http.Request) { + user := auth.UserFromContext(r.Context()) + var shares []*database.Share + var err error + if user.IsAdmin { + shares, err = database.ListAllShares(h.DB) + } else { + shares, err = database.ListSharesByOwner(h.DB, user.ID) + } + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.page(w, r, "shares", "shares", "Shares", shares) +} + +// handleShareCreateForAlbum creates a public share link for an entire +// album, reachable from the album page. +func (h *Web) handleShareCreateForAlbum(w http.ResponseWriter, r *http.Request) { + user := auth.UserFromContext(r.Context()) + albumID := r.PathValue("id") + if _, err := database.GetAlbum(h.DB, albumID); err != nil { + http.NotFound(w, r) + return + } + share, err := database.CreateShare(h.DB, user.ID, idgen.New(""), "", sql.NullString{}) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := database.AddShareItem(h.DB, share.ID, sql.NullString{}, sql.NullString{String: albumID, Valid: true}, 0); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/shares", http.StatusFound) +} + +func (h *Web) handleShareDelete(w http.ResponseWriter, r *http.Request) { + user := auth.UserFromContext(r.Context()) + id := r.PathValue("id") + share, err := database.GetShare(h.DB, id) + if err != nil { + http.NotFound(w, r) + return + } + if share.OwnerID != user.ID && !user.IsAdmin { + http.Error(w, "403 Forbidden", http.StatusForbidden) + return + } + if err := database.DeleteShare(h.DB, id); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/shares", http.StatusFound) +} diff --git a/internal/web/web.go b/internal/web/web.go index 6b5e829..08476fa 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -10,6 +10,8 @@ import ( "git.noroutes.net/NoRoutes/vibetunes/internal/auth" "git.noroutes.net/NoRoutes/vibetunes/internal/config" "git.noroutes.net/NoRoutes/vibetunes/internal/coverart" + "git.noroutes.net/NoRoutes/vibetunes/internal/jukebox" + "git.noroutes.net/NoRoutes/vibetunes/internal/podcasts" "git.noroutes.net/NoRoutes/vibetunes/internal/scanner" ) @@ -22,6 +24,8 @@ type Web struct { Scanner *scanner.Scanner CoverArt *coverart.Store EncryptionKey []byte // for auth.SetUserPassword; see internal/auth/subsonic_secret.go + Podcasts *podcasts.Manager + Jukebox *jukebox.Jukebox Log *slog.Logger } diff --git a/web/templates/admin_jukebox.html b/web/templates/admin_jukebox.html new file mode 100644 index 0000000..e33b25f --- /dev/null +++ b/web/templates/admin_jukebox.html @@ -0,0 +1,41 @@ +{{define "admin_jukebox"}}{{template "header" .}} +{{if not .Data.Available}} +
+ The jukebox is not available: no mpv binary was found on this server. Install mpv and restart VibeSonic to enable server-side jukebox playback. +
+{{else}} +
+
+

Status

+ {{if .Data.Error}} +

{{.Data.Error}}

+ {{else}} + + + + + + + + +
Playing{{.Data.Status.Playing}}
Current index{{.Data.Status.CurrentIndex}}
Position{{.Data.Status.PositionSecs}}s
Gain{{.Data.Status.Gain}}
Playlist length{{len .Data.Status.SongIDs}}
+ {{end}} +
+ {{template "csrf_field" .CSRFToken}} + +
+
+ {{template "csrf_field" .CSRFToken}} + +
+
+ {{template "csrf_field" .CSRFToken}} + +
+

+ Build a jukebox playlist from a Subsonic client (e.g. DSub, Symfonium) using the jukeboxControl API — this page is for server status and emergency stop/clear only. +

+
+
+{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/admin_podcasts.html b/web/templates/admin_podcasts.html new file mode 100644 index 0000000..5d5e122 --- /dev/null +++ b/web/templates/admin_podcasts.html @@ -0,0 +1,61 @@ +{{define "admin_podcasts"}}{{template "header" .}} +
+
+

Add channel

+
+ {{template "csrf_field" .CSRFToken}} +
+ + +
+
+
+
+ +{{range .Data}} +
+
+
+
+

{{.Channel.Title}} {{.Channel.Status}}

+
{{.Channel.URL}}
+ {{if .Channel.ErrorMessage}}
{{.Channel.ErrorMessage}}
{{end}} +
+
+
+ {{template "csrf_field" $.CSRFToken}} + +
+
+ {{template "csrf_field" $.CSRFToken}} + +
+
+
+ + + {{range .Episodes}} + + + + + + + {{end}} + +
{{.Title}}{{.PublishDate}}{{.Status}} + {{if ne .Status "completed"}} +
+ {{template "csrf_field" $.CSRFToken}} + +
+ {{end}} +
+ {{template "csrf_field" $.CSRFToken}} + +
+
+
+
+{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/admin_user_permissions.html b/web/templates/admin_user_permissions.html new file mode 100644 index 0000000..63fd46c --- /dev/null +++ b/web/templates/admin_user_permissions.html @@ -0,0 +1,56 @@ +{{define "admin_user_permissions"}}{{template "header" .}} +
+
+

{{.Data.Username}}

+
+ {{template "csrf_field" .CSRFToken}} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + Cancel +
+
+
+{{template "footer" .}}{{end}} diff --git a/web/templates/admin_users.html b/web/templates/admin_users.html index a8e9f28..2dfec42 100644 --- a/web/templates/admin_users.html +++ b/web/templates/admin_users.html @@ -27,6 +27,7 @@ {{if .IsAdmin}}Admin{{else}}User{{end}} {{if .Disabled}}Disabled{{else}}Active{{end}} + Permissions
{{template "csrf_field" $.CSRFToken}} diff --git a/web/templates/album.html b/web/templates/album.html index b42ebee..9f287e6 100644 --- a/web/templates/album.html +++ b/web/templates/album.html @@ -7,6 +7,10 @@ + + {{template "csrf_field" .CSRFToken}} + +
{{template "song_rows" .Data.Songs}} diff --git a/web/templates/bookmarks.html b/web/templates/bookmarks.html new file mode 100644 index 0000000..d6ac6bb --- /dev/null +++ b/web/templates/bookmarks.html @@ -0,0 +1,24 @@ +{{define "bookmarks"}}{{template "header" .}} +{{if .Data}} + + + + {{range .Data}} + + + + + + + {{end}} + +
SongPositionComment
{{.Song.Title}} — {{.Song.ArtistName}}{{fmtDuration (millisToSec .Bookmark.PositionMs)}}{{.Bookmark.Comment}} +
+ {{template "csrf_field" $.CSRFToken}} + +
+
+{{else}} +

No bookmarks yet.

+{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/partials/header.html b/web/templates/partials/header.html index 105a31a..0e6a745 100644 --- a/web/templates/partials/header.html +++ b/web/templates/partials/header.html @@ -69,6 +69,10 @@ + + + + {{if and .User .User.IsAdmin}}
  • Admin
  • @@ -76,6 +80,8 @@ + + {{end}}
    diff --git a/web/templates/podcasts.html b/web/templates/podcasts.html new file mode 100644 index 0000000..95321d6 --- /dev/null +++ b/web/templates/podcasts.html @@ -0,0 +1,31 @@ +{{define "podcasts"}}{{template "header" .}} +{{if not .Data}}

    No podcasts yet. Ask an administrator to add a channel.

    {{end}} +{{range .Data}} +
    +
    +

    {{.Channel.Title}}

    +

    {{.Channel.Description}}

    + + + {{range .Episodes}} + + + + + + + {{end}} + +
    {{.Title}}{{.PublishDate}}{{.Status}} + {{if eq .Status "completed"}} Downloaded + {{else}} +
    + {{template "csrf_field" $.CSRFToken}} + +
    + {{end}} +
    +
    +
    +{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/radio.html b/web/templates/radio.html new file mode 100644 index 0000000..82f51a9 --- /dev/null +++ b/web/templates/radio.html @@ -0,0 +1,42 @@ +{{define "radio"}}{{template "header" .}} +{{if and .User .User.IsAdmin}} +
    +
    +

    Add station

    +
    + {{template "csrf_field" .CSRFToken}} +
    +
    +
    +
    +
    + +
    +
    +
    +{{end}} + +{{if .Data}} +
    + {{range .Data}} +
    +
    +
    {{.Name}}
    +
    {{.StreamURL}}
    +
    +
    + Play + {{if and $.User $.User.IsAdmin}} +
    + {{template "csrf_field" $.CSRFToken}} + +
    + {{end}} +
    +
    + {{end}} +
    +{{else}} +

    No internet radio stations yet.

    +{{end}} +{{template "footer" .}}{{end}} diff --git a/web/templates/shares.html b/web/templates/shares.html new file mode 100644 index 0000000..04cd22f --- /dev/null +++ b/web/templates/shares.html @@ -0,0 +1,25 @@ +{{define "shares"}}{{template "header" .}} +{{if .Data}} + + + + {{range .Data}} + + + + + + + + {{end}} + +
    DescriptionOwnerVisitsLink
    {{if .Description}}{{.Description}}{{else}}(no description){{end}}{{.OwnerID}}{{.VisitCount}}/share/{{.Token}} +
    + {{template "csrf_field" $.CSRFToken}} + +
    +
    +{{else}} +

    No shares yet. Create one from an album page.

    +{{end}} +{{template "footer" .}}{{end}}