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,198 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/cdnutter/topdeck/internal/store"
|
||||
)
|
||||
|
||||
// ItemView is an item shaped for public display.
|
||||
type ItemView struct {
|
||||
ID int64 `json:"id"`
|
||||
Rank int `json:"rank"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Year string `json:"year"`
|
||||
Image string `json:"image"`
|
||||
ExternalURL string `json:"link"`
|
||||
Source string `json:"source"`
|
||||
Note string `json:"note"`
|
||||
Badge string `json:"badge"` // e.g. theater production
|
||||
Meta []MetaKV `json:"meta"`
|
||||
}
|
||||
|
||||
type MetaKV struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
var providerLabels = map[string]string{
|
||||
"tmdb": "TMDB",
|
||||
"tvdb": "TheTVDB",
|
||||
"igdb": "IGDB",
|
||||
"deezer": "Deezer",
|
||||
"musicbrainz": "MusicBrainz",
|
||||
"wikipedia": "Wikipedia",
|
||||
}
|
||||
|
||||
// extraFields lists the known item extras in display order.
|
||||
var extraFields = []struct{ Key, Label string }{
|
||||
{"production", "Production"},
|
||||
{"venue", "Venue"},
|
||||
{"cast", "Notable cast"},
|
||||
{"album", "Album"},
|
||||
{"type", "Type"},
|
||||
{"platforms", "Platforms"},
|
||||
{"genres", "Genres"},
|
||||
{"runtime", "Runtime"},
|
||||
{"length", "Length"},
|
||||
{"label", "Label"},
|
||||
}
|
||||
|
||||
func itemView(it store.Item, showNotes bool) ItemView {
|
||||
v := ItemView{
|
||||
ID: it.ID,
|
||||
Rank: it.Rank,
|
||||
Title: it.Title,
|
||||
Subtitle: it.Subtitle,
|
||||
Year: it.Year,
|
||||
ExternalURL: it.ExternalURL,
|
||||
Source: providerLabels[it.Provider],
|
||||
Badge: it.Extra["production"],
|
||||
Meta: []MetaKV{},
|
||||
}
|
||||
if it.Image != "" {
|
||||
v.Image = "/images/" + it.Image
|
||||
}
|
||||
if showNotes {
|
||||
v.Note = it.Note
|
||||
}
|
||||
for _, f := range extraFields {
|
||||
if val := it.Extra[f.Key]; val != "" {
|
||||
v.Meta = append(v.Meta, MetaKV{f.Label, val})
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
type listSection struct {
|
||||
List store.List
|
||||
Category store.Category
|
||||
Items []ItemView
|
||||
More int // items not shown in the preview
|
||||
}
|
||||
|
||||
type homePage struct {
|
||||
Sections []listSection
|
||||
TotalItems int
|
||||
TotalLists int
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
_, loggedIn := s.currentUser(r)
|
||||
site := s.site(ctx)
|
||||
lists, err := s.store.Lists(ctx, false)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
var page homePage
|
||||
for _, l := range lists {
|
||||
if l.ItemCount == 0 {
|
||||
continue
|
||||
}
|
||||
items, err := s.store.Items(ctx, l.ID, 0, site.PreviewCount)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
sec := listSection{List: l, More: l.ItemCount - len(items)}
|
||||
sec.Category, _ = store.CategoryByKey(l.Category)
|
||||
for _, it := range items {
|
||||
sec.Items = append(sec.Items, itemView(it, site.ShowNotes))
|
||||
}
|
||||
page.Sections = append(page.Sections, sec)
|
||||
page.TotalItems += l.ItemCount
|
||||
page.TotalLists++
|
||||
}
|
||||
// With nothing to show yet, send a fresh owner straight to the editor.
|
||||
if len(page.Sections) == 0 && loggedIn && len(lists) == 0 {
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, r, http.StatusOK, "home", "", page)
|
||||
}
|
||||
|
||||
const listPageSize = 100
|
||||
|
||||
type listPage struct {
|
||||
List store.List
|
||||
Category store.Category
|
||||
Items []ItemView
|
||||
Hero *ItemView
|
||||
Page int
|
||||
Pages int
|
||||
PageNums []int
|
||||
}
|
||||
|
||||
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
l, err := s.store.ListBySlug(ctx, r.PathValue("slug"))
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
s.notFound(w, r)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !l.Visible {
|
||||
if _, ok := s.currentUser(r); !ok {
|
||||
s.notFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
site := s.site(ctx)
|
||||
p := listPage{List: l, Page: 1}
|
||||
p.Category, _ = store.CategoryByKey(l.Category)
|
||||
p.Pages = max(1, (l.ItemCount+listPageSize-1)/listPageSize)
|
||||
if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n > 1 {
|
||||
p.Page = min(n, p.Pages)
|
||||
}
|
||||
items, err := s.store.Items(ctx, l.ID, (p.Page-1)*listPageSize, listPageSize)
|
||||
if err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
p.Items = append(p.Items, itemView(it, site.ShowNotes))
|
||||
}
|
||||
if len(p.Items) > 0 && p.Items[0].Rank == 1 && p.Items[0].Image != "" {
|
||||
p.Hero = &p.Items[0]
|
||||
}
|
||||
p.PageNums = pageWindow(p.Page, p.Pages)
|
||||
s.render(w, r, http.StatusOK, "list", l.Title, p)
|
||||
}
|
||||
|
||||
// pageWindow returns page numbers to show around the current page, with 0
|
||||
// standing in for an ellipsis.
|
||||
func pageWindow(cur, total int) []int {
|
||||
if total <= 1 {
|
||||
return nil
|
||||
}
|
||||
var out []int
|
||||
last := 0
|
||||
for i := 1; i <= total; i++ {
|
||||
if i == 1 || i == total || (i >= cur-2 && i <= cur+2) {
|
||||
if last != 0 && i-last > 1 {
|
||||
out = append(out, 0)
|
||||
}
|
||||
out = append(out, i)
|
||||
last = i
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user