Initial TopDeck: self-hosted ranked favorites
A single Go binary with SQLite that serves a public page of the owner's ranked lists (movies, TV, albums, songs, video games, theater) and a login-gated editor. - Lists of 1–65,535 entries (100 recommended), several per category - Search-to-add via TMDB, TheTVDB, IGDB, Deezer/MusicBrainz, Wikipedia; manual entry as a fallback - Theater entries carry a production (Broadway, tour, …), venue and cast - Artwork cached locally; URL override and uploads - Drag, arrow and type-a-rank reordering; paginated for long lists - 12 theme presets plus custom CSS - First-run setup code or env-provided admin; bcrypt, rate-limited login, CSRF-guarded JSON API - Dockerfile and docker-compose.yml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e9a75a968a
53 files changed
+7078
No files matched your search
@@ -0,0 +1,193 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cdnutter/topdeck/internal/store"
|
||||
"github.com/cdnutter/topdeck/web"
|
||||
)
|
||||
|
||||
// Site is the owner-configurable identity shown on every page.
|
||||
type Site struct {
|
||||
Title string
|
||||
OwnerName string
|
||||
Tagline string
|
||||
AvatarURL string
|
||||
Theme string
|
||||
CustomCSS bool
|
||||
CSSVersion string
|
||||
PreviewCount int
|
||||
ShowNotes bool
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Site Site
|
||||
PageTitle string
|
||||
LoggedIn bool
|
||||
Username string
|
||||
Path string
|
||||
AssetVer string
|
||||
Data any
|
||||
}
|
||||
|
||||
func (s *Server) site(ctx context.Context) Site {
|
||||
set, err := s.store.Settings(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("loading settings", "err", err)
|
||||
set = map[string]string{}
|
||||
}
|
||||
site := Site{
|
||||
Title: set[store.SettingSiteTitle],
|
||||
OwnerName: set[store.SettingOwnerName],
|
||||
Tagline: set[store.SettingTagline],
|
||||
Theme: set[store.SettingTheme],
|
||||
PreviewCount: 10,
|
||||
ShowNotes: set[store.SettingShowNotes] != "false",
|
||||
}
|
||||
if site.Title == "" {
|
||||
site.Title = "TopDeck"
|
||||
}
|
||||
if !validTheme(site.Theme) {
|
||||
site.Theme = DefaultTheme
|
||||
}
|
||||
if n, err := strconv.Atoi(set[store.SettingPreviewCount]); err == nil && n >= 0 && n <= 50 {
|
||||
site.PreviewCount = n
|
||||
}
|
||||
if a := set[store.SettingAvatar]; a != "" {
|
||||
site.AvatarURL = "/images/" + a
|
||||
}
|
||||
if css := strings.TrimSpace(set[store.SettingCustomCSS]); css != "" {
|
||||
site.CustomCSS = true
|
||||
site.CSSVersion = shortHash(css)
|
||||
}
|
||||
return site
|
||||
}
|
||||
|
||||
func (s *Server) handleCustomCSS(w http.ResponseWriter, r *http.Request) {
|
||||
set, err := s.store.Settings(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
w.Write([]byte(set[store.SettingCustomCSS]))
|
||||
}
|
||||
|
||||
var funcs = template.FuncMap{
|
||||
"icon": func(name string) template.HTML {
|
||||
return template.HTML(fmt.Sprintf(`<svg class="icon" aria-hidden="true"><use href="/static/img/icons.svg#%s"></use></svg>`, template.HTMLEscapeString(name)))
|
||||
},
|
||||
"category": func(key string) store.Category {
|
||||
c, _ := store.CategoryByKey(key)
|
||||
return c
|
||||
},
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"hasPrefix": strings.HasPrefix,
|
||||
// dict builds a map for passing several values to a partial.
|
||||
"dict": func(kv ...any) map[string]any {
|
||||
m := map[string]any{}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
m[kv[i].(string)] = kv[i+1]
|
||||
}
|
||||
return m
|
||||
},
|
||||
"num": formatNumber,
|
||||
"plural": func(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return one
|
||||
}
|
||||
return many
|
||||
},
|
||||
"initial": func(s string) string {
|
||||
for _, r := range strings.TrimSpace(s) {
|
||||
return strings.ToUpper(string(r))
|
||||
}
|
||||
return "?"
|
||||
},
|
||||
"pct": func(a, b int) int {
|
||||
if b <= 0 {
|
||||
return 0
|
||||
}
|
||||
p := a * 100 / b
|
||||
if a > 0 && p == 0 {
|
||||
p = 1
|
||||
}
|
||||
return p
|
||||
},
|
||||
}
|
||||
|
||||
func formatNumber(n int) string {
|
||||
s := strconv.Itoa(n)
|
||||
if n < 0 {
|
||||
return "-" + formatNumber(-n)
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, c := range s {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteRune(c)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseTemplates builds one template set per page, each combining the base
|
||||
// layout, shared partials and the page itself.
|
||||
func (s *Server) parseTemplates() error {
|
||||
shared, err := fs.Glob(web.FS, "templates/partials/*.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pages, err := fs.Glob(web.FS, "templates/pages/*.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.pages = map[string]*template.Template{}
|
||||
for _, p := range pages {
|
||||
files := append([]string{"templates/layout.html"}, shared...)
|
||||
files = append(files, p)
|
||||
t, err := template.New("layout.html").Funcs(funcs).ParseFS(web.FS, files...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing %s: %w", p, err)
|
||||
}
|
||||
s.pages[strings.TrimSuffix(path.Base(p), ".html")] = t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, r *http.Request, status int, page, title string, data any) {
|
||||
t, ok := s.pages[page]
|
||||
if !ok {
|
||||
http.Error(w, "missing template "+page, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
pd := pageData{
|
||||
Site: s.site(r.Context()),
|
||||
PageTitle: title,
|
||||
Path: r.URL.Path,
|
||||
AssetVer: s.assetVer,
|
||||
Data: data,
|
||||
}
|
||||
if u, ok := s.currentUser(r); ok {
|
||||
pd.LoggedIn, pd.Username = true, u.Username
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, pd); err != nil {
|
||||
s.log.Error("rendering", "page", page, "err", err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.WriteHeader(status)
|
||||
buf.WriteTo(w)
|
||||
}
|
||||
Reference in new issue
Block a user