Initial TopDeck: self-hosted ranked favorites
A single Go binary with SQLite that serves a public page of the owner's ranked lists (movies, TV, albums, songs, video games, theater) and a login-gated editor. - Lists of 1–65,535 entries (100 recommended), several per category - Search-to-add via TMDB, TheTVDB, IGDB, Deezer/MusicBrainz, Wikipedia; manual entry as a fallback - Theater entries carry a production (Broadway, tour, …), venue and cast - Artwork cached locally; URL override and uploads - Drag, arrow and type-a-rank reordering; paginated for long lists - 12 theme presets plus custom CSS - First-run setup code or env-provided admin; bcrypt, rate-limited login, CSRF-guarded JSON API - Dockerfile and docker-compose.yml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e9a75a968a
53 files changed
+7078
No files matched your search
@@ -0,0 +1,231 @@
|
||||
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"` // "grid" or "rows"
|
||||
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,
|
||||
(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)
|
||||
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 l.Layout != "rows" {
|
||||
l.Layout = "grid"
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
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 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=?, updated_at=unixepoch() WHERE id=?`,
|
||||
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout, 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()
|
||||
}
|
||||
Reference in new issue
Block a user