Files
canon/internal/server/public.go
T
cdnutterandClaude Opus 5.5 58a29b7339 Add titles-only list export and Custom lists
Export
- GET /l/<list>/export?format=csv|txt downloads a list's titles in rank
  order, one per line, with no header or other fields (CSV gets a UTF-8
  BOM for Excel)
- Export menu in the list editor and, for the owner, on the public list
  page; hidden lists stay owner-only

Custom lists
- New "Custom" category for anything not built in (sports teams,
  drinks…): owner-named, slug from the title, entries added by hand or
  from an unfiltered Wikipedia search
- Per-list artwork shape (square or tall), stored in a new lists.art
  column (migration 2)
- Art can be uploaded while adding an entry, not only after

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:18:24 -07:00

217 lines
5.1 KiB
Go

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
}
// listCategory is the list's category with the list's own artwork shape
// applied.
func listCategory(l store.List) store.Category {
c, _ := store.CategoryByKey(l.Category)
c.Aspect = l.Aspect()
return c
}
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 = listCategory(l)
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
}
// visibleList loads the list named in the URL, treating hidden lists as
// missing for visitors. It writes the error response when ok is false.
func (s *Server) visibleList(w http.ResponseWriter, r *http.Request) (l store.List, ok bool) {
l, err := s.store.ListBySlug(r.Context(), r.PathValue("slug"))
if errors.Is(err, store.ErrNotFound) {
s.notFound(w, r)
return l, false
}
if err != nil {
s.serverError(w, r, err)
return l, false
}
if !l.Visible {
if _, loggedIn := s.currentUser(r); !loggedIn {
s.notFound(w, r)
return l, false
}
}
return l, true
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l, ok := s.visibleList(w, r)
if !ok {
return
}
site := s.site(ctx)
p := listPage{List: l, Page: 1}
p.Category = listCategory(l)
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
}