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

+23 -7
View File
@@ -25,16 +25,17 @@ type List struct {
Position int `json:"position"`
Visible bool `json:"visible"`
Layout string `json:"layout"` // "grid" or "rows"
Art string `json:"art"` // "", "poster" or "square"; "" = category default
ItemCount int `json:"itemCount"`
UpdatedAt int64 `json:"updatedAt"`
}
const listColumns = `l.id, l.slug, l.title, l.category, l.description, l.max_items, l.position, l.visible, l.layout, l.updated_at,
const listColumns = `l.id, l.slug, l.title, l.category, l.description, l.max_items, l.position, l.visible, l.layout, l.art, l.updated_at,
(SELECT COUNT(*) FROM items i WHERE i.list_id = l.id)`
func scanList(row interface{ Scan(...any) error }) (List, error) {
var l List
err := row.Scan(&l.ID, &l.Slug, &l.Title, &l.Category, &l.Description, &l.MaxItems, &l.Position, &l.Visible, &l.Layout, &l.UpdatedAt, &l.ItemCount)
err := row.Scan(&l.ID, &l.Slug, &l.Title, &l.Category, &l.Description, &l.MaxItems, &l.Position, &l.Visible, &l.Layout, &l.Art, &l.UpdatedAt, &l.ItemCount)
if errors.Is(err, sql.ErrNoRows) {
return l, ErrNotFound
}
@@ -99,6 +100,9 @@ func (l *List) normalize() error {
if l.Layout != "rows" {
l.Layout = "grid"
}
if l.Art != "poster" && l.Art != "square" {
l.Art = ""
}
return nil
}
@@ -140,14 +144,17 @@ func (s *Store) CreateList(ctx context.Context, l List) (List, error) {
if strings.TrimSpace(l.Slug) == "" {
c, _ := CategoryByKey(l.Category)
base = slugify(c.Label)
if c.Key == "custom" {
base = slugify(l.Title)
}
}
slug, err := s.uniqueSlug(ctx, base, 0)
if err != nil {
return l, err
}
res, err := s.db.ExecContext(ctx, `INSERT INTO lists (slug, title, category, description, max_items, position, visible, layout)
VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM lists), ?, ?)`,
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout)
res, err := s.db.ExecContext(ctx, `INSERT INTO lists (slug, title, category, description, max_items, position, visible, layout, art)
VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM lists), ?, ?, ?)`,
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout, l.Art)
if err != nil {
return l, err
}
@@ -175,8 +182,8 @@ func (s *Store) UpdateList(ctx context.Context, l List) (List, error) {
return l, err
}
}
_, err = s.db.ExecContext(ctx, `UPDATE lists SET slug=?, title=?, category=?, description=?, max_items=?, visible=?, layout=?, updated_at=unixepoch() WHERE id=?`,
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout, l.ID)
_, err = s.db.ExecContext(ctx, `UPDATE lists SET slug=?, title=?, category=?, description=?, max_items=?, visible=?, layout=?, art=?, updated_at=unixepoch() WHERE id=?`,
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout, l.Art, l.ID)
if err != nil {
return l, err
}
@@ -229,3 +236,12 @@ func (s *Store) ListCovers(ctx context.Context, n int) (map[int64][]string, erro
}
return out, rows.Err()
}
// Aspect is the list's artwork shape: its own choice, or the category's.
func (l List) Aspect() string {
if l.Art != "" {
return l.Art
}
c, _ := CategoryByKey(l.Category)
return c.Aspect
}