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>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-24 17:18:24 -07:00
1 parent e9a75a968a
commit 58a29b7339
23 files changed
+314 -37

No files matched your search

+27 -9
View File
@@ -77,6 +77,14 @@ func itemView(it store.Item, showNotes bool) ItemView {
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
@@ -110,7 +118,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
return
}
sec := listSection{List: l, More: l.ItemCount - len(items)}
sec.Category, _ = store.CategoryByKey(l.Category)
sec.Category = listCategory(l)
for _, it := range items {
sec.Items = append(sec.Items, itemView(it, site.ShowNotes))
}
@@ -138,26 +146,36 @@ type listPage struct {
PageNums []int
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l, err := s.store.ListBySlug(ctx, r.PathValue("slug"))
// 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
return l, false
}
if err != nil {
s.serverError(w, r, err)
return
return l, false
}
if !l.Visible {
if _, ok := s.currentUser(r); !ok {
if _, loggedIn := s.currentUser(r); !loggedIn {
s.notFound(w, r)
return
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, _ = store.CategoryByKey(l.Category)
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)