Save source descriptions, add "Why it's #N", fix IGDB studio names

Descriptions
- Items keep the source's description (TMDB/TheTVDB overview, IGDB
  summary, Wikipedia intro) in a new items.description column
  (migration 3); editable in the item form
- Deezer/MusicBrainz track counts and album names are now display-only
  hints, not saved as descriptions

Why it's #N
- The owner's note is presented as "Why it's #N" in the editor and the
  public detail view, above an "About" section with the description

IGDB developers
- IGDB credits internal divisions ("Nintendo EAD", "Nintendo EPD
  Production Group No. 8"). A developer whose name shares its first word
  with its parent company is credited to the parent ("Nintendo");
  independently named studios keep their name (Retro Studios, Naughty
  Dog, Landfall Games)
- Fall back to the publisher when IGDB lists no developer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-24 18:05:20 -07:00
1 parent 3923444b3b
commit 7320e3f707
16 files changed
+206 -43

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ Visitors just see your lists. There's no sign-up, no feed and no login button. Y
- **Drag to rank**, or use the arrow buttons or type a new number. Paginated editing keeps huge lists usable. - **Drag to rank**, or use the arrow buttons or type a new number. Paginated editing keeps huge lists usable.
- **Art is cached on your server**, so pages never hotlink and keep working if a source changes. You can also paste an image URL or upload your own. - **Art is cached on your server**, so pages never hotlink and keep working if a source changes. You can also paste an image URL or upload your own.
- **Export** any list as a titles-only `.csv` or `.txt`. - **Export** any list as a titles-only `.csv` or `.txt`.
- **Personal notes** on any entry, shown in a detail view (toggleable). - **Descriptions and your reasons**: each entry keeps the source's description (TMDB overview, IGDB summary, Wikipedia intro), and you can add a "Why it's #N" note explaining its spot. Both show in the detail view.
- **12 theme presets** plus a custom CSS box. Every color is a CSS variable. - **12 theme presets** plus a custom CSS box. Every color is a CSS variable.
- **Single binary + SQLite**, no external database. Around 15 MB, and it runs anywhere Docker does. - **Single binary + SQLite**, no external database. Around 15 MB, and it runs anywhere Docker does.
+96 -15
View File
@@ -2,6 +2,7 @@ package providers
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
@@ -9,6 +10,7 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
) )
// igdb searches the Internet Game Database, authenticating through Twitch. // igdb searches the Internet Game Database, authenticating through Twitch.
@@ -59,7 +61,7 @@ func (g *igdb) token(ctx context.Context, fresh bool) (string, error) {
func (g *igdb) Search(ctx context.Context, query string) ([]Result, error) { func (g *igdb) Search(ctx context.Context, query string) ([]Result, error) {
q := strings.NewReplacer(`\`, ``, `"`, `\"`).Replace(query) q := strings.NewReplacer(`\`, ``, `"`, `\"`).Replace(query)
body := `search "` + q + `"; body := `search "` + q + `";
fields name,first_release_date,cover.image_id,summary,url,involved_companies.developer,involved_companies.company.name,platforms.abbreviation,platforms.name; fields name,first_release_date,cover.image_id,summary,url,involved_companies.developer,involved_companies.publisher,involved_companies.company.name,involved_companies.company.parent.name,involved_companies.company.parent.parent.name,involved_companies.company.parent.parent.parent.name,platforms.abbreviation,platforms.name;
limit 25;` limit 25;`
var games []struct { var games []struct {
ID int64 `json:"id"` ID int64 `json:"id"`
@@ -70,12 +72,7 @@ limit 25;`
Cover struct { Cover struct {
ImageID string `json:"image_id"` ImageID string `json:"image_id"`
} `json:"cover"` } `json:"cover"`
InvolvedCompanies []struct { InvolvedCompanies []igdbInvolved `json:"involved_companies"`
Developer bool `json:"developer"`
Company struct {
Name string `json:"name"`
} `json:"company"`
} `json:"involved_companies"`
Platforms []struct { Platforms []struct {
Abbreviation string `json:"abbreviation"` Abbreviation string `json:"abbreviation"`
Name string `json:"name"` Name string `json:"name"`
@@ -104,12 +101,7 @@ limit 25;`
} }
out := make([]Result, 0, len(games)) out := make([]Result, 0, len(games))
for _, gm := range games { for _, gm := range games {
var devs, plats []string var plats []string
for _, ic := range gm.InvolvedCompanies {
if ic.Developer && ic.Company.Name != "" {
devs = append(devs, ic.Company.Name)
}
}
for _, p := range gm.Platforms { for _, p := range gm.Platforms {
name := p.Abbreviation name := p.Abbreviation
if name == "" { if name == "" {
@@ -121,9 +113,9 @@ limit 25;`
Provider: "igdb", Provider: "igdb",
ProviderID: strconv.FormatInt(gm.ID, 10), ProviderID: strconv.FormatInt(gm.ID, 10),
Title: gm.Name, Title: gm.Name,
Subtitle: strings.Join(devs, ", "), Subtitle: strings.Join(igdbStudios(gm.InvolvedCompanies), ", "),
ExternalURL: gm.URL, ExternalURL: gm.URL,
Overview: truncate(gm.Summary, 600), Overview: truncate(gm.Summary, maxDescription),
Extra: map[string]string{}, Extra: map[string]string{},
} }
if gm.FirstReleaseDate > 0 { if gm.FirstReleaseDate > 0 {
@@ -139,3 +131,92 @@ limit 25;`
} }
return out, nil return out, nil
} }
type igdbInvolved struct {
Developer bool `json:"developer"`
Publisher bool `json:"publisher"`
Company igdbCompany `json:"company"`
}
// igdbStudios names who made a game: its developers, or its publishers when
// IGDB lists no developer, with internal divisions credited to their company.
func igdbStudios(involved []igdbInvolved) []string {
var devs, pubs []string
for _, ic := range involved {
if ic.Company.Name == "" {
continue
}
if ic.Developer {
devs = appendUnique(devs, studioName(&ic.Company))
}
if ic.Publisher {
pubs = appendUnique(pubs, studioName(&ic.Company))
}
}
if len(devs) == 0 {
return pubs
}
return devs
}
// igdbCompany is a company with as much of its parent chain as was
// requested. IGDB sends an unexpanded parent as a bare ID, which is ignored.
type igdbCompany struct {
Name string
Parent *igdbCompany
}
func (c *igdbCompany) UnmarshalJSON(b []byte) error {
if len(b) == 0 || b[0] != '{' {
return nil
}
var raw struct {
Name string `json:"name"`
Parent *igdbCompany `json:"parent"`
}
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
c.Name, c.Parent = raw.Name, raw.Parent
if c.Parent != nil && c.Parent.Name == "" {
c.Parent = nil
}
return nil
}
// studioName credits an internal division to the company it belongs to.
// IGDB lists the exact team, so Super Mario Sunshine's developer is
// "Nintendo EAD" and DK Bananza's is "Nintendo EPD Production Group No. 8".
// When a studio's name starts with the same word as its parent's, it's a
// division of that company and we show the parent ("Nintendo"). Studios
// with their own identity keep their name: Retro Studios (owned by
// Nintendo) or Naughty Dog (owned by Sony).
func studioName(c *igdbCompany) string {
for c.Parent != nil && sameFirstWord(c.Name, c.Parent.Name) {
c = c.Parent
}
return c.Name
}
func sameFirstWord(a, b string) bool {
first := func(s string) string {
f := strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
if len(f) == 0 {
return ""
}
return f[0]
}
fa := first(a)
return fa != "" && fa == first(b)
}
func appendUnique(list []string, s string) []string {
for _, x := range list {
if x == s {
return list
}
}
return append(list, s)
}
+55
View File
@@ -0,0 +1,55 @@
package providers
import (
"encoding/json"
"testing"
)
func TestStudioName(t *testing.T) {
cases := []struct{ json, want string }{
// Internal divisions roll up to the company.
{`{"name":"Nintendo EAD","parent":{"name":"Nintendo"}}`, "Nintendo"},
{`{"name":"Nintendo EPD Production Group No. 8","parent":{"name":"Nintendo EPD","parent":{"name":"Nintendo"}}}`, "Nintendo"},
{`{"name":"Ubisoft Montreal","parent":{"name":"Ubisoft Entertainment"}}`, "Ubisoft Entertainment"},
// Studios with their own name stay themselves.
{`{"name":"Retro Studios","parent":{"name":"Nintendo"}}`, "Retro Studios"},
{`{"name":"Naughty Dog","parent":{"name":"Sony Interactive Entertainment"}}`, "Naughty Dog"},
{`{"name":"Landfall Games"}`, "Landfall Games"},
// Stop climbing once the name changes.
{`{"name":"Rockstar North","parent":{"name":"Rockstar Games","parent":{"name":"Take-Two Interactive"}}}`, "Rockstar Games"},
// An unexpanded parent arrives as a bare ID.
{`{"name":"Nintendo EAD Tokyo","parent":1234}`, "Nintendo EAD Tokyo"},
}
for _, c := range cases {
var co igdbCompany
if err := json.Unmarshal([]byte(c.json), &co); err != nil {
t.Fatalf("%s: %v", c.json, err)
}
if got := studioName(&co); got != c.want {
t.Errorf("%s → %q, want %q", c.json, got, c.want)
}
}
}
// A whole search response decodes, and co-developers from the same company
// collapse to one name.
func TestIGDBDevelopers(t *testing.T) {
body := `[{"id":1,"name":"Super Mario Sunshine","involved_companies":[
{"developer":true,"publisher":false,"company":{"name":"Nintendo EAD","parent":{"name":"Nintendo"}}},
{"developer":true,"publisher":false,"company":{"name":"Nintendo EAD Tokyo","parent":{"name":"Nintendo EAD","parent":{"name":"Nintendo"}}}},
{"developer":false,"publisher":true,"company":{"name":"Nintendo"}}]},
{"id":2,"name":"Publisher only","involved_companies":[{"developer":false,"publisher":true,"company":{"name":"Devolver Digital"}}]}]`
var games []struct {
InvolvedCompanies []igdbInvolved `json:"involved_companies"`
}
if err := json.Unmarshal([]byte(body), &games); err != nil {
t.Fatal(err)
}
want := []string{"Nintendo", "Devolver Digital"}
for i, g := range games {
devs := igdbStudios(g.InvolvedCompanies)
if len(devs) != 1 || devs[0] != want[i] {
t.Errorf("game %d developers = %v, want [%s]", i, devs, want[i])
}
}
}
+3 -3
View File
@@ -105,7 +105,7 @@ func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
r.Extra["type"] = strings.ToUpper(a.RecordType[:1]) + a.RecordType[1:] r.Extra["type"] = strings.ToUpper(a.RecordType[:1]) + a.RecordType[1:]
} }
if a.NbTracks > 0 { if a.NbTracks > 0 {
r.Overview = fmt.Sprintf("%d tracks", a.NbTracks) r.Hint = fmt.Sprintf("%d tracks", a.NbTracks)
} }
out = append(out, r) out = append(out, r)
} }
@@ -126,7 +126,7 @@ func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) {
Subtitle: t.Artist.Name, Subtitle: t.Artist.Name,
ImageURL: cover(t.Album), ImageURL: cover(t.Album),
ExternalURL: t.Link, ExternalURL: t.Link,
Overview: "From " + t.Album.Title, Hint: "From " + t.Album.Title,
Extra: map[string]string{"album": t.Album.Title}, Extra: map[string]string{"album": t.Album.Title},
}) })
} }
@@ -267,7 +267,7 @@ func (m *musicbrainz) Search(ctx context.Context, query string) ([]Result, error
if i == 0 || rel.ReleaseGroup.PrimaryType == "Album" { if i == 0 || rel.ReleaseGroup.PrimaryType == "Album" {
r.ImageURL = "https://coverartarchive.org/release-group/" + rel.ReleaseGroup.ID + "/front-500" r.ImageURL = "https://coverartarchive.org/release-group/" + rel.ReleaseGroup.ID + "/front-500"
r.Extra["album"] = rel.Title r.Extra["album"] = rel.Title
r.Overview = "From " + rel.Title r.Hint = "From " + rel.Title
if rel.ReleaseGroup.PrimaryType == "Album" { if rel.ReleaseGroup.PrimaryType == "Album" {
break break
} }
+5 -1
View File
@@ -14,6 +14,9 @@ import (
"time" "time"
) )
// maxDescription caps how much of a source's description is kept.
const maxDescription = 2000
const UserAgent = "Canon/0.1 (self-hosted favorites list; https://git.iamchrisama.com/cdnutter/canon)" const UserAgent = "Canon/0.1 (self-hosted favorites list; https://git.iamchrisama.com/cdnutter/canon)"
// Result is one search hit, already shaped like a list item. // Result is one search hit, already shaped like a list item.
@@ -25,7 +28,8 @@ type Result struct {
Year string `json:"year"` Year string `json:"year"`
ImageURL string `json:"imageUrl"` ImageURL string `json:"imageUrl"`
ExternalURL string `json:"externalUrl"` ExternalURL string `json:"externalUrl"`
Overview string `json:"overview"` Overview string `json:"overview"` // the source's description, saved with the item
Hint string `json:"hint"` // extra context for search results only, e.g. "12 tracks"
Extra map[string]string `json:"extra,omitempty"` Extra map[string]string `json:"extra,omitempty"`
} }
+1 -1
View File
@@ -75,7 +75,7 @@ func (t *tmdb) Search(ctx context.Context, query string) ([]Result, error) {
Year: yearOf(date), Year: yearOf(date),
ImageURL: tmdbImage(h.PosterPath), ImageURL: tmdbImage(h.PosterPath),
ExternalURL: "https://www.themoviedb.org/" + t.kind + "/" + id, ExternalURL: "https://www.themoviedb.org/" + t.kind + "/" + id,
Overview: truncate(h.Overview, 600), Overview: truncate(h.Overview, maxDescription),
}) })
} }
return out, nil return out, nil
+1 -1
View File
@@ -123,7 +123,7 @@ func (t *tvdb) Search(ctx context.Context, query string) ([]Result, error) {
Year: year, Year: year,
ImageURL: img, ImageURL: img,
ExternalURL: ext, ExternalURL: ext,
Overview: truncate(overview, 600), Overview: truncate(overview, maxDescription),
}) })
} }
return out, nil return out, nil
+1 -1
View File
@@ -99,7 +99,7 @@ func (w *wikipedia) search(ctx context.Context, query string) ([]Result, error)
Year: yearOf(p.Description), Year: yearOf(p.Description),
ImageURL: img, ImageURL: img,
ExternalURL: p.FullURL, ExternalURL: p.FullURL,
Overview: truncate(p.Extract, 600), Overview: truncate(p.Extract, maxDescription),
}) })
} }
return out, nil return out, nil
+4 -1
View File
@@ -125,6 +125,7 @@ type itemInput struct {
Title string `json:"title"` Title string `json:"title"`
Subtitle string `json:"subtitle"` Subtitle string `json:"subtitle"`
Year string `json:"year"` Year string `json:"year"`
Description string `json:"description"`
Note string `json:"note"` Note string `json:"note"`
ExternalURL string `json:"externalUrl"` ExternalURL string `json:"externalUrl"`
Extra map[string]string `json:"extra"` Extra map[string]string `json:"extra"`
@@ -178,6 +179,7 @@ func (s *Server) apiAddItem(w http.ResponseWriter, r *http.Request) {
res := providers.Result{ res := providers.Result{
Provider: in.Provider, ProviderID: in.ProviderID, Title: in.Title, Subtitle: in.Subtitle, Provider: in.Provider, ProviderID: in.ProviderID, Title: in.Title, Subtitle: in.Subtitle,
Year: in.Year, ImageURL: in.ImageURL, ExternalURL: in.ExternalURL, Extra: in.Extra, Year: in.Year, ImageURL: in.ImageURL, ExternalURL: in.ExternalURL, Extra: in.Extra,
Overview: in.Description,
} }
if in.Details && in.Provider != "" { if in.Details && in.Provider != "" {
res = s.fillDetails(ctx, list.Category, res) res = s.fillDetails(ctx, list.Category, res)
@@ -186,7 +188,7 @@ func (s *Server) apiAddItem(w http.ResponseWriter, r *http.Request) {
it := store.Item{ it := store.Item{
ListID: listID, Title: res.Title, Subtitle: res.Subtitle, Year: res.Year, ListID: listID, Title: res.Title, Subtitle: res.Subtitle, Year: res.Year,
Provider: res.Provider, ProviderID: res.ProviderID, ExternalURL: res.ExternalURL, Provider: res.Provider, ProviderID: res.ProviderID, ExternalURL: res.ExternalURL,
Note: in.Note, Extra: res.Extra, Description: res.Overview, Note: in.Note, Extra: res.Extra,
} }
var warning string var warning string
if res.ImageURL != "" { if res.ImageURL != "" {
@@ -266,6 +268,7 @@ func (s *Server) apiUpdateItem(w http.ResponseWriter, r *http.Request) {
} }
it := cur it := cur
it.Title, it.Subtitle, it.Year, it.Note, it.ExternalURL = in.Title, in.Subtitle, in.Year, in.Note, in.ExternalURL it.Title, it.Subtitle, it.Year, it.Note, it.ExternalURL = in.Title, in.Subtitle, in.Year, in.Note, in.ExternalURL
it.Description = in.Description
if in.Extra != nil { if in.Extra != nil {
it.Extra = in.Extra it.Extra = in.Extra
} }
+2
View File
@@ -18,6 +18,7 @@ type ItemView struct {
Image string `json:"image"` Image string `json:"image"`
ExternalURL string `json:"link"` ExternalURL string `json:"link"`
Source string `json:"source"` Source string `json:"source"`
Description string `json:"description"`
Note string `json:"note"` Note string `json:"note"`
Badge string `json:"badge"` // e.g. theater production Badge string `json:"badge"` // e.g. theater production
Meta []MetaKV `json:"meta"` Meta []MetaKV `json:"meta"`
@@ -59,6 +60,7 @@ func itemView(it store.Item, showNotes bool) ItemView {
Subtitle: it.Subtitle, Subtitle: it.Subtitle,
Year: it.Year, Year: it.Year,
ExternalURL: it.ExternalURL, ExternalURL: it.ExternalURL,
Description: it.Description,
Source: providerLabels[it.Provider], Source: providerLabels[it.Provider],
Badge: it.Extra["production"], Badge: it.Extra["production"],
Meta: []MetaKV{}, Meta: []MetaKV{},
+11 -9
View File
@@ -20,17 +20,18 @@ type Item struct {
Provider string `json:"provider"` Provider string `json:"provider"`
ProviderID string `json:"providerId"` ProviderID string `json:"providerId"`
ExternalURL string `json:"externalUrl"` ExternalURL string `json:"externalUrl"`
Note string `json:"note"` Description string `json:"description"` // from the source, editable
Note string `json:"note"` // the owner's "why it's here"
Extra map[string]string `json:"extra"` Extra map[string]string `json:"extra"`
} }
const itemColumns = `id, list_id, rank, title, subtitle, year, image, image_source, provider, provider_id, external_url, note, extra` const itemColumns = `id, list_id, rank, title, subtitle, year, image, image_source, provider, provider_id, external_url, description, note, extra`
func scanItem(row interface{ Scan(...any) error }) (Item, error) { func scanItem(row interface{ Scan(...any) error }) (Item, error) {
var it Item var it Item
var extra string var extra string
err := row.Scan(&it.ID, &it.ListID, &it.Rank, &it.Title, &it.Subtitle, &it.Year, &it.Image, &it.ImageSource, err := row.Scan(&it.ID, &it.ListID, &it.Rank, &it.Title, &it.Subtitle, &it.Year, &it.Image, &it.ImageSource,
&it.Provider, &it.ProviderID, &it.ExternalURL, &it.Note, &extra) &it.Provider, &it.ProviderID, &it.ExternalURL, &it.Description, &it.Note, &extra)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return it, ErrNotFound return it, ErrNotFound
} }
@@ -82,11 +83,12 @@ func (it *Item) normalize() error {
it.Subtitle = strings.TrimSpace(it.Subtitle) it.Subtitle = strings.TrimSpace(it.Subtitle)
it.Year = strings.TrimSpace(it.Year) it.Year = strings.TrimSpace(it.Year)
it.Note = strings.TrimSpace(it.Note) it.Note = strings.TrimSpace(it.Note)
it.Description = strings.TrimSpace(it.Description)
it.ExternalURL = strings.TrimSpace(it.ExternalURL) it.ExternalURL = strings.TrimSpace(it.ExternalURL)
if it.Title == "" { if it.Title == "" {
return invalid("Title is required") return invalid("Title is required")
} }
if len(it.Title) > 500 || len(it.Subtitle) > 500 || len(it.Year) > 20 || len(it.Note) > 10000 || len(it.ExternalURL) > 2000 { if len(it.Title) > 500 || len(it.Subtitle) > 500 || len(it.Year) > 20 || len(it.Note) > 10000 || len(it.Description) > 10000 || len(it.ExternalURL) > 2000 {
return invalid("One of the fields is too long") return invalid("One of the fields is too long")
} }
if it.ExternalURL != "" && !strings.HasPrefix(it.ExternalURL, "https://") && !strings.HasPrefix(it.ExternalURL, "http://") { if it.ExternalURL != "" && !strings.HasPrefix(it.ExternalURL, "https://") && !strings.HasPrefix(it.ExternalURL, "http://") {
@@ -136,9 +138,9 @@ func (s *Store) AddItem(ctx context.Context, it Item, rank int) (Item, error) {
if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = rank + 1 WHERE list_id = ? AND rank >= ?`, it.ListID, rank); err != nil { if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = rank + 1 WHERE list_id = ? AND rank >= ?`, it.ListID, rank); err != nil {
return it, err return it, err
} }
res, err := tx.ExecContext(ctx, `INSERT INTO items (list_id, rank, title, subtitle, year, image, image_source, provider, provider_id, external_url, note, extra) res, err := tx.ExecContext(ctx, `INSERT INTO items (list_id, rank, title, subtitle, year, image, image_source, provider, provider_id, external_url, description, note, extra)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
it.ListID, rank, it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.Provider, it.ProviderID, it.ExternalURL, it.Note, extraJSON(it.Extra)) it.ListID, rank, it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.Provider, it.ProviderID, it.ExternalURL, it.Description, it.Note, extraJSON(it.Extra))
if err != nil { if err != nil {
return it, err return it, err
} }
@@ -157,8 +159,8 @@ func (s *Store) UpdateItem(ctx context.Context, it Item) (Item, error) {
if err := it.normalize(); err != nil { if err := it.normalize(); err != nil {
return it, err return it, err
} }
res, err := s.db.ExecContext(ctx, `UPDATE items SET title=?, subtitle=?, year=?, image=?, image_source=?, external_url=?, note=?, extra=?, updated_at=unixepoch() WHERE id=?`, res, err := s.db.ExecContext(ctx, `UPDATE items SET title=?, subtitle=?, year=?, image=?, image_source=?, external_url=?, description=?, note=?, extra=?, updated_at=unixepoch() WHERE id=?`,
it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.ExternalURL, it.Note, extraJSON(it.Extra), it.ID) it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.ExternalURL, it.Description, it.Note, extraJSON(it.Extra), it.ID)
if err != nil { if err != nil {
return it, err return it, err
} }
+2
View File
@@ -111,6 +111,8 @@ CREATE INDEX items_list_rank ON items(list_id, rank);
`, `,
// Per-list artwork shape; '' uses the category's default. // Per-list artwork shape; '' uses the category's default.
`ALTER TABLE lists ADD COLUMN art TEXT NOT NULL DEFAULT '';`, `ALTER TABLE lists ADD COLUMN art TEXT NOT NULL DEFAULT '';`,
// The source's description of the item (TMDB overview, IGDB summary…).
`ALTER TABLE items ADD COLUMN description TEXT NOT NULL DEFAULT '';`,
} }
func (s *Store) migrate(ctx context.Context) error { func (s *Store) migrate(ctx context.Context) error {
+8 -4
View File
@@ -120,13 +120,15 @@ code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; bac
/* ---------- profile / home ---------- */ /* ---------- profile / home ---------- */
.profile { display: flex; align-items: center; gap: 28px; padding-top: 56px; padding-bottom: 28px; } .profile { display: flex; align-items: center; gap: 28px; padding-top: 56px; padding-bottom: 28px; }
/* Profile pictures: the image as-is, just cropped to a circle. The flat
accent fill only shows behind the initial when there's no picture. */
.avatar { .avatar {
flex: none; display: grid; place-items: center; overflow: hidden; border-radius: 50%; flex: none; display: grid; place-items: center; overflow: hidden; border-radius: 50%;
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 45%, var(--bg))); background: var(--accent); color: var(--accent-text); font-weight: 700;
color: var(--accent-text); font-weight: 700;
} }
.avatar:has(img) { background: none; }
.avatar img { width: 100%; height: 100%; object-fit: cover; } .avatar img { width: 100%; height: 100%; object-fit: cover; }
.avatar--lg { width: 112px; height: 112px; font-size: 44px; box-shadow: 0 0 0 4px var(--bg), 0 0 0 5px var(--border); } .avatar--lg { width: 112px; height: 112px; font-size: 44px; }
.profile-name { font-size: clamp(30px, 5vw, 46px); font-weight: 780; } .profile-name { font-size: clamp(30px, 5vw, 46px); font-weight: 780; }
.profile-tagline { margin-top: 8px; color: var(--muted); font-size: 17px; max-width: 60ch; } .profile-tagline { margin-top: 8px; color: var(--muted); font-size: 17px; max-width: 60ch; }
.profile-stats { margin-top: 12px; color: var(--faint); font-size: 14px; } .profile-stats { margin-top: 12px; color: var(--faint); font-size: 14px; }
@@ -331,8 +333,10 @@ dialog[open] { animation: pop 0.18s cubic-bezier(.2,.8,.2,1); }
.dlg-meta { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; margin: 4px 0 0; font-size: 14px; } .dlg-meta { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; margin: 4px 0 0; font-size: 14px; }
.dlg-meta dt { color: var(--faint); } .dlg-meta dt { color: var(--faint); }
.dlg-meta dd { margin: 0; } .dlg-meta dd { margin: 0; }
.dlg-why h3, .dlg-about h3 { font: 700 11.5px/1.2 var(--font); text-transform: uppercase; letter-spacing: 0.08em; color: var(--faint); margin: 6px 0 8px; }
.dlg-desc { color: var(--muted); font-size: 14.5px; line-height: 1.6; white-space: pre-line; }
.dlg-note { .dlg-note {
margin: 4px 0 0; padding: 14px 16px; border-left: 3px solid var(--accent); margin: 0; padding: 14px 16px; border-left: 3px solid var(--accent);
background: var(--accent-soft); border-radius: 0 var(--radius) var(--radius) 0; background: var(--accent-soft); border-radius: 0 var(--radius) var(--radius) 0;
font-size: 15px; white-space: pre-line; font-size: 15px; white-space: pre-line;
} }
+9 -4
View File
@@ -271,7 +271,7 @@ function renderResults(res) {
h("div", { style: { minWidth: 0 } }, h("div", { style: { minWidth: 0 } },
h("div", { class: "result-title" }, r.title), h("div", { class: "result-title" }, r.title),
sub && h("div", { class: "result-sub" }, sub), sub && h("div", { class: "result-sub" }, sub),
r.overview && h("div", { class: "result-overview" }, r.overview), (r.overview || r.hint) && h("div", { class: "result-overview" }, r.overview || r.hint),
), ),
action, action,
); );
@@ -294,6 +294,7 @@ function fromResult(r) {
return { return {
title: r.title, subtitle: r.subtitle, year: r.year, externalUrl: r.externalUrl, title: r.title, subtitle: r.subtitle, year: r.year, externalUrl: r.externalUrl,
imageUrl: r.imageUrl, provider: r.provider, providerId: r.providerId, extra: { ...(r.extra || {}) }, imageUrl: r.imageUrl, provider: r.provider, providerId: r.providerId, extra: { ...(r.extra || {}) },
description: r.overview || "",
}; };
} }
@@ -344,7 +345,10 @@ function openItemForm({ item, result }) {
const title = h("input", { required: true, maxlength: 500, value: src.title || "" }); const title = h("input", { required: true, maxlength: 500, value: src.title || "" });
const subtitle = h("input", { maxlength: 500, value: src.subtitle || "" }); const subtitle = h("input", { maxlength: 500, value: src.subtitle || "" });
const year = h("input", { maxlength: 20, value: src.year || "", inputmode: "numeric", style: { maxWidth: "140px" } }); const year = h("input", { maxlength: 20, value: src.year || "", inputmode: "numeric", style: { maxWidth: "140px" } });
const note = h("textarea", { rows: 3, maxlength: 10000, value: item?.note || "", placeholder: "Why it's here. Shown to visitors." }); const description = h("textarea", { rows: 4, maxlength: 10000, value: src.description || "", placeholder: "What it is, in a few sentences." });
const note = h("textarea", { rows: 3, maxlength: 10000, value: item?.note || "", placeholder: "What earns it this spot? Shown to visitors." });
const source = { tmdb: "TMDB", tvdb: "TheTVDB", igdb: "IGDB", wikipedia: "Wikipedia" }[src.provider];
const rankLabel = editing ? `#${item.rank}` : `#${list.itemCount + 1}`;
const link = h("input", { type: "url", maxlength: 2000, value: src.externalUrl || "", placeholder: "https://" }); const link = h("input", { type: "url", maxlength: 2000, value: src.externalUrl || "", placeholder: "https://" });
// Theater production gets top billing with suggestions. // Theater production gets top billing with suggestions.
@@ -438,7 +442,8 @@ function openItemForm({ item, result }) {
field(cat.key === "theater" ? "Year (of this production)" : "Year", year), field(cat.key === "theater" ? "Year (of this production)" : "Year", year),
), ),
extraInputs.length > 0 && h("div", { class: "row" }, extraInputs.map((x) => field(x.label, x.input))), extraInputs.length > 0 && h("div", { class: "row" }, extraInputs.map((x) => field(x.label, x.input))),
field("Your note", note), field("Description", description, source ? `From ${source}. Edit it if you like.` : "Optional. Shown to visitors."),
field(`Why it's ${rankLabel}`, note, "Your take on why it earns its place in the ranking."),
field("Link", link, "Where the title links to on your page."), field("Link", link, "Where the title links to on your page."),
error, error,
), ),
@@ -461,7 +466,7 @@ function openItemForm({ item, result }) {
if (production) newExtra.production = production.value.trim(); if (production) newExtra.production = production.value.trim();
for (const x of extraInputs) newExtra[x.key] = x.input.value.trim(); for (const x of extraInputs) newExtra[x.key] = x.input.value.trim();
const body = { const body = {
title: title.value, subtitle: subtitle.value, year: year.value, note: note.value, title: title.value, subtitle: subtitle.value, year: year.value, note: note.value, description: description.value,
externalUrl: link.value.trim(), extra: newExtra, externalUrl: link.value.trim(), extra: newExtra,
imageUrl: clearImage ? "" : imageUrl.value.trim(), clearImage, imageUrl: clearImage ? "" : imageUrl.value.trim(), clearImage,
}; };
+1 -1
View File
@@ -84,7 +84,7 @@ function profileSection() {
h("div", { class: "row" }, field("Your name", ownerName, "Shown as the page heading."), field("Site title", siteTitle, "Shown in the header and browser tab. Leave blank to use “Your name's Canon”.")), h("div", { class: "row" }, field("Your name", ownerName, "Shown as the page heading."), field("Site title", siteTitle, "Shown in the header and browser tab. Leave blank to use “Your name's Canon”.")),
field("Tagline", tagline), field("Tagline", tagline),
field("Entries per list on the home page", preview, "Visitors see the full list on each list's page. 0 shows only the heading."), field("Entries per list on the home page", preview, "Visitors see the full list on each list's page. 0 shows only the heading."),
h("label", { class: "check" }, notes, "Show my notes to visitors"), h("label", { class: "check" }, notes, "Show why each entry is ranked where it is"),
h("div", { class: "settings-actions" }, save), h("div", { class: "settings-actions" }, save),
); );
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
+6 -1
View File
@@ -38,7 +38,12 @@ function renderDialog(list, idx) {
sub && h("p", { class: "dlg-sub" }, sub), sub && h("p", { class: "dlg-sub" }, sub),
it.badge && h("span", { class: "badge" }, it.badge), it.badge && h("span", { class: "badge" }, it.badge),
meta.length > 0 && h("dl", { class: "dlg-meta" }, meta.map((m) => [h("dt", {}, m.label), h("dd", {}, m.value)])), meta.length > 0 && h("dl", { class: "dlg-meta" }, meta.map((m) => [h("dt", {}, m.label), h("dd", {}, m.value)])),
it.note && h("blockquote", { class: "dlg-note" }, it.note), it.note && h("section", { class: "dlg-why" },
h("h3", {}, `Why it's #${it.rank}`),
h("blockquote", { class: "dlg-note" }, it.note)),
it.description && h("section", { class: "dlg-about" },
h("h3", {}, "About"),
h("p", { class: "dlg-desc" }, it.description)),
h("div", { class: "dlg-actions" }, h("div", { class: "dlg-actions" },
it.link && h("a", { class: "btn btn--sm", href: it.link, target: "_blank", rel: "noopener noreferrer" }, it.link && h("a", { class: "btn btn--sm", href: it.link, target: "_blank", rel: "noopener noreferrer" },
icon("external"), it.source ? `View on ${it.source}` : "Learn more"), icon("external"), it.source ? `View on ${it.source}` : "Learn more"),