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>
217 lines
5.1 KiB
Go
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
|
|
}
|