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:
1 parent
e9a75a968a
commit
58a29b7339
23 files changed
+314
-37
No files matched your search
@@ -19,6 +19,9 @@ var Categories = []Category{
|
||||
{Key: "song", Label: "Songs", Singular: "Song", Icon: "music", Aspect: "square", SubtitleHint: "Artist"},
|
||||
{Key: "game", Label: "Video Games", Singular: "Video Game", Icon: "gamepad", Aspect: "poster", SubtitleHint: "Developer"},
|
||||
{Key: "theater", Label: "Theater", Singular: "Show", Icon: "masks", Aspect: "poster", SubtitleHint: "Creators"},
|
||||
// Custom lists hold anything else (sports teams, drinks…); the owner
|
||||
// names the list and adds entries by hand or from Wikipedia.
|
||||
{Key: "custom", Label: "Custom", Singular: "Favorite", Icon: "star", Aspect: "square", SubtitleHint: "Subtitle"},
|
||||
}
|
||||
|
||||
func CategoryByKey(key string) (Category, bool) {
|
||||
|
||||
+23
-7
@@ -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
|
||||
}
|
||||
@@ -86,6 +86,8 @@ CREATE TABLE items (
|
||||
);
|
||||
CREATE INDEX items_list_rank ON items(list_id, rank);
|
||||
`,
|
||||
// Per-list artwork shape; '' uses the category's default.
|
||||
`ALTER TABLE lists ADD COLUMN art TEXT NOT NULL DEFAULT '';`,
|
||||
}
|
||||
|
||||
func (s *Store) migrate(ctx context.Context) error {
|
||||
|
||||
@@ -151,3 +151,29 @@ func BenchmarkMoveInLargeList(b *testing.B) {
|
||||
s.MoveItem(ctx, first, 5000-(i%2)*4999)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomList(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
l, err := s.CreateList(ctx, List{Title: "Favorite Sports Teams", Category: "custom", MaxItems: 10, Art: "poster"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l.Slug != "favorite-sports-teams" {
|
||||
t.Errorf("custom slug = %q, want it from the title", l.Slug)
|
||||
}
|
||||
if l.Aspect() != "poster" {
|
||||
t.Errorf("aspect = %q, want the list's own choice", l.Aspect())
|
||||
}
|
||||
l.Art = "sideways"
|
||||
if l, err = s.UpdateList(ctx, l); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if l.Art != "" || l.Aspect() != "square" {
|
||||
t.Errorf("invalid art should fall back to the category default, got art=%q aspect=%q", l.Art, l.Aspect())
|
||||
}
|
||||
m, _ := s.CreateList(ctx, List{Title: "Top 10 Movies", Category: "movie", MaxItems: 10})
|
||||
if m.Aspect() != "poster" {
|
||||
t.Errorf("movie aspect = %q", m.Aspect())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user