Entries now sit at places 1…max with gaps allowed (#28, nothing at #29, #30). Moving onto an empty place just puts the entry there; onto a taken place, the entries in between shift one step, stopping at the first empty place, so lists without gaps reorder exactly as before. Inserts push down only to the next gap; deleting can close up or leave the place empty. New endpoint POST /api/lists/{id}/places inserts or removes empty places or closes every gap; move and delete reply with every entry's place. The podium shows an empty step for an empty #2 or #3, and a list can't shrink below its last used place. The editor loads every entry (no paging) and applies edits instantly with the same rules (places.js mirrors places.go), queueing saves in the background and reconciling with the server's reply. Type a place and press Enter (↑/↓ move between boxes, Alt+↑/↓ moves the entry), drag, or use the row menu. Empty runs show as rows with Add here and Remove, and a toolbar shows the gap count, Close gaps and a Go to # box. Clicking an entry opens it for editing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
279 lines
7.9 KiB
Go
279 lines
7.9 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
MaxListSize = 65535
|
|
RecommendedListSize = 100
|
|
SoftListSizeWarning = 1000
|
|
)
|
|
|
|
type List struct {
|
|
ID int64 `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Category string `json:"category"`
|
|
Description string `json:"description"`
|
|
MaxItems int `json:"maxItems"`
|
|
Position int `json:"position"`
|
|
Visible bool `json:"visible"`
|
|
Layout string `json:"layout"` // a ListStyles key, e.g. "podium"
|
|
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.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.Art, &l.UpdatedAt, &l.ItemCount)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return l, ErrNotFound
|
|
}
|
|
return l, err
|
|
}
|
|
|
|
func (s *Store) Lists(ctx context.Context, includeHidden bool) ([]List, error) {
|
|
q := `SELECT ` + listColumns + ` FROM lists l`
|
|
if !includeHidden {
|
|
q += ` WHERE l.visible = 1`
|
|
}
|
|
q += ` ORDER BY l.position, l.id`
|
|
rows, err := s.db.QueryContext(ctx, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []List
|
|
for rows.Next() {
|
|
l, err := scanList(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, l)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) ListByID(ctx context.Context, id int64) (List, error) {
|
|
return scanList(s.db.QueryRowContext(ctx, `SELECT `+listColumns+` FROM lists l WHERE l.id = ?`, id))
|
|
}
|
|
|
|
func (s *Store) ListBySlug(ctx context.Context, slug string) (List, error) {
|
|
return scanList(s.db.QueryRowContext(ctx, `SELECT `+listColumns+` FROM lists l WHERE l.slug = ?`, slug))
|
|
}
|
|
|
|
// ValidationError is returned for bad user input; its message is safe to
|
|
// show to the user.
|
|
type ValidationError struct{ Msg string }
|
|
|
|
func (e ValidationError) Error() string { return e.Msg }
|
|
|
|
func invalid(format string, args ...any) error {
|
|
return ValidationError{Msg: fmt.Sprintf(format, args...)}
|
|
}
|
|
|
|
func (l *List) normalize() error {
|
|
l.Title = strings.TrimSpace(l.Title)
|
|
l.Description = strings.TrimSpace(l.Description)
|
|
if l.Title == "" {
|
|
return invalid("Title is required")
|
|
}
|
|
if len(l.Title) > 200 {
|
|
return invalid("Title is too long")
|
|
}
|
|
if _, ok := CategoryByKey(l.Category); !ok {
|
|
return invalid("Unknown category %q", l.Category)
|
|
}
|
|
if l.MaxItems < 1 || l.MaxItems > MaxListSize {
|
|
return invalid("List size must be between 1 and %d", MaxListSize)
|
|
}
|
|
if _, ok := StyleByKey(l.Layout); !ok {
|
|
l.Layout = DefaultStyle
|
|
}
|
|
if l.Art != "poster" && l.Art != "square" {
|
|
l.Art = ""
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
|
|
|
|
func slugify(s string) string {
|
|
s = slugStrip.ReplaceAllString(strings.ToLower(s), "-")
|
|
s = strings.Trim(s, "-")
|
|
if len(s) > 60 {
|
|
s = strings.Trim(s[:60], "-")
|
|
}
|
|
if s == "" {
|
|
s = "list"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *Store) uniqueSlug(ctx context.Context, base string, exceptID int64) (string, error) {
|
|
slug := base
|
|
for i := 2; ; i++ {
|
|
var n int
|
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM lists WHERE slug = ? AND id != ?`, slug, exceptID).Scan(&n); err != nil {
|
|
return "", err
|
|
}
|
|
if n == 0 {
|
|
return slug, nil
|
|
}
|
|
slug = fmt.Sprintf("%s-%d", base, i)
|
|
}
|
|
}
|
|
|
|
func (s *Store) CreateList(ctx context.Context, l List) (List, error) {
|
|
if err := l.normalize(); err != nil {
|
|
return l, err
|
|
}
|
|
// Default to the category name so the address stays sensible if the
|
|
// list's size (and so its "Top N" title) changes later.
|
|
base := slugify(l.Slug)
|
|
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, 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
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return s.ListByID(ctx, id)
|
|
}
|
|
|
|
func (s *Store) UpdateList(ctx context.Context, l List) (List, error) {
|
|
if err := l.normalize(); err != nil {
|
|
return l, err
|
|
}
|
|
cur, err := s.ListByID(ctx, l.ID)
|
|
if err != nil {
|
|
return l, err
|
|
}
|
|
if l.MaxItems < cur.ItemCount {
|
|
return l, invalid("This list already has %d items; remove some before shrinking it to %d", cur.ItemCount, l.MaxItems)
|
|
}
|
|
if last, err := s.MaxRank(ctx, l.ID); err != nil {
|
|
return l, err
|
|
} else if l.MaxItems < last {
|
|
return l, invalid("An entry sits at #%d; move it up or close the gaps before shrinking the list to %d", last, l.MaxItems)
|
|
}
|
|
if l.Category != cur.Category && cur.ItemCount > 0 {
|
|
return l, invalid("Can't change the category of a list that has items")
|
|
}
|
|
slug := cur.Slug
|
|
if strings.TrimSpace(l.Slug) != "" && slugify(l.Slug) != cur.Slug {
|
|
if slug, err = s.uniqueSlug(ctx, slugify(l.Slug), l.ID); err != nil {
|
|
return l, err
|
|
}
|
|
}
|
|
_, 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
|
|
}
|
|
return s.ListByID(ctx, l.ID)
|
|
}
|
|
|
|
func (s *Store) DeleteList(ctx context.Context, id int64) error {
|
|
res, err := s.db.ExecContext(ctx, `DELETE FROM lists WHERE id = ?`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReorderLists sets list positions to match the order of ids.
|
|
func (s *Store) ReorderLists(ctx context.Context, ids []int64) error {
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
for i, id := range ids {
|
|
if _, err := tx.ExecContext(ctx, `UPDATE lists SET position = ? WHERE id = ?`, i+1, id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ListCovers returns up to n image file names per list, from the top ranks.
|
|
func (s *Store) ListCovers(ctx context.Context, n int) (map[int64][]string, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT list_id, image FROM (
|
|
SELECT list_id, image, ROW_NUMBER() OVER (PARTITION BY list_id ORDER BY rank) AS rn
|
|
FROM items WHERE image != '') WHERE rn <= ? ORDER BY list_id, rn`, n)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := map[int64][]string{}
|
|
for rows.Next() {
|
|
var id int64
|
|
var img string
|
|
if err := rows.Scan(&id, &img); err != nil {
|
|
return nil, err
|
|
}
|
|
out[id] = append(out[id], "/images/"+img)
|
|
}
|
|
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
|
|
}
|
|
|
|
// ListStyle is a way of presenting a ranked list.
|
|
type ListStyle struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
Icon string `json:"icon"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
var ListStyles = []ListStyle{
|
|
{"podium", "Podium", "podium", "Top 3 on a medal podium, the rest behind “Show more”."},
|
|
{"spotlight", "Spotlight", "spotlight", "#1 as a big feature, the rest as a ranked list."},
|
|
{"grid", "Grid", "grid", "Every entry as artwork in a grid."},
|
|
{"rows", "List", "rows", "A compact numbered list with notes."},
|
|
}
|
|
|
|
const DefaultStyle = "podium"
|
|
|
|
func StyleByKey(key string) (ListStyle, bool) {
|
|
for _, s := range ListStyles {
|
|
if s.Key == key {
|
|
return s, true
|
|
}
|
|
}
|
|
return ListStyle{}, false
|
|
}
|