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:
1 parent
3923444b3b
commit
7320e3f707
16 files changed
+207
-44
No files matched your search
@@ -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.
|
||||||
|
|
||||||
|
|||||||
+97
-16
@@ -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,13 +72,8 @@ 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"`
|
Platforms []struct {
|
||||||
Company struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"company"`
|
|
||||||
} `json:"involved_companies"`
|
|
||||||
Platforms []struct {
|
|
||||||
Abbreviation string `json:"abbreviation"`
|
Abbreviation string `json:"abbreviation"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"platforms"`
|
} `json:"platforms"`
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
Reference in new issue
Block a user