diff --git a/README.md b/README.md index bcbbf99..6cb1fc0 100644 --- a/README.md +++ b/README.md @@ -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. - **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`. -- **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. - **Single binary + SQLite**, no external database. Around 15 MB, and it runs anywhere Docker does. diff --git a/internal/providers/igdb.go b/internal/providers/igdb.go index 70c6651..f810703 100644 --- a/internal/providers/igdb.go +++ b/internal/providers/igdb.go @@ -2,6 +2,7 @@ package providers import ( "context" + "encoding/json" "fmt" "net/http" "net/url" @@ -9,6 +10,7 @@ import ( "strings" "sync" "time" + "unicode" ) // 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) { q := strings.NewReplacer(`\`, ``, `"`, `\"`).Replace(query) 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;` var games []struct { ID int64 `json:"id"` @@ -70,13 +72,8 @@ limit 25;` Cover struct { ImageID string `json:"image_id"` } `json:"cover"` - InvolvedCompanies []struct { - Developer bool `json:"developer"` - Company struct { - Name string `json:"name"` - } `json:"company"` - } `json:"involved_companies"` - Platforms []struct { + InvolvedCompanies []igdbInvolved `json:"involved_companies"` + Platforms []struct { Abbreviation string `json:"abbreviation"` Name string `json:"name"` } `json:"platforms"` @@ -104,12 +101,7 @@ limit 25;` } out := make([]Result, 0, len(games)) for _, gm := range games { - var devs, plats []string - for _, ic := range gm.InvolvedCompanies { - if ic.Developer && ic.Company.Name != "" { - devs = append(devs, ic.Company.Name) - } - } + var plats []string for _, p := range gm.Platforms { name := p.Abbreviation if name == "" { @@ -121,9 +113,9 @@ limit 25;` Provider: "igdb", ProviderID: strconv.FormatInt(gm.ID, 10), Title: gm.Name, - Subtitle: strings.Join(devs, ", "), + Subtitle: strings.Join(igdbStudios(gm.InvolvedCompanies), ", "), ExternalURL: gm.URL, - Overview: truncate(gm.Summary, 600), + Overview: truncate(gm.Summary, maxDescription), Extra: map[string]string{}, } if gm.FirstReleaseDate > 0 { @@ -139,3 +131,92 @@ limit 25;` } 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) +} diff --git a/internal/providers/igdb_test.go b/internal/providers/igdb_test.go new file mode 100644 index 0000000..bd88930 --- /dev/null +++ b/internal/providers/igdb_test.go @@ -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]) + } + } +} diff --git a/internal/providers/music.go b/internal/providers/music.go index 14ab561..1fb7700 100644 --- a/internal/providers/music.go +++ b/internal/providers/music.go @@ -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:] } if a.NbTracks > 0 { - r.Overview = fmt.Sprintf("%d tracks", a.NbTracks) + r.Hint = fmt.Sprintf("%d tracks", a.NbTracks) } out = append(out, r) } @@ -126,7 +126,7 @@ func (d *deezer) Search(ctx context.Context, query string) ([]Result, error) { Subtitle: t.Artist.Name, ImageURL: cover(t.Album), ExternalURL: t.Link, - Overview: "From " + t.Album.Title, + Hint: "From " + 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" { r.ImageURL = "https://coverartarchive.org/release-group/" + rel.ReleaseGroup.ID + "/front-500" r.Extra["album"] = rel.Title - r.Overview = "From " + rel.Title + r.Hint = "From " + rel.Title if rel.ReleaseGroup.PrimaryType == "Album" { break } diff --git a/internal/providers/providers.go b/internal/providers/providers.go index a9cc44a..b586ca6 100644 --- a/internal/providers/providers.go +++ b/internal/providers/providers.go @@ -14,6 +14,9 @@ import ( "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)" // Result is one search hit, already shaped like a list item. @@ -25,7 +28,8 @@ type Result struct { Year string `json:"year"` ImageURL string `json:"imageUrl"` 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"` } diff --git a/internal/providers/tmdb.go b/internal/providers/tmdb.go index a9ce026..1fbae08 100644 --- a/internal/providers/tmdb.go +++ b/internal/providers/tmdb.go @@ -75,7 +75,7 @@ func (t *tmdb) Search(ctx context.Context, query string) ([]Result, error) { Year: yearOf(date), ImageURL: tmdbImage(h.PosterPath), ExternalURL: "https://www.themoviedb.org/" + t.kind + "/" + id, - Overview: truncate(h.Overview, 600), + Overview: truncate(h.Overview, maxDescription), }) } return out, nil diff --git a/internal/providers/tvdb.go b/internal/providers/tvdb.go index d96fb5e..ca06378 100644 --- a/internal/providers/tvdb.go +++ b/internal/providers/tvdb.go @@ -123,7 +123,7 @@ func (t *tvdb) Search(ctx context.Context, query string) ([]Result, error) { Year: year, ImageURL: img, ExternalURL: ext, - Overview: truncate(overview, 600), + Overview: truncate(overview, maxDescription), }) } return out, nil diff --git a/internal/providers/wikipedia.go b/internal/providers/wikipedia.go index 267f7f6..c53cece 100644 --- a/internal/providers/wikipedia.go +++ b/internal/providers/wikipedia.go @@ -99,7 +99,7 @@ func (w *wikipedia) search(ctx context.Context, query string) ([]Result, error) Year: yearOf(p.Description), ImageURL: img, ExternalURL: p.FullURL, - Overview: truncate(p.Extract, 600), + Overview: truncate(p.Extract, maxDescription), }) } return out, nil diff --git a/internal/server/api.go b/internal/server/api.go index b1505e8..d7dc1c2 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -125,6 +125,7 @@ type itemInput struct { Title string `json:"title"` Subtitle string `json:"subtitle"` Year string `json:"year"` + Description string `json:"description"` Note string `json:"note"` ExternalURL string `json:"externalUrl"` Extra map[string]string `json:"extra"` @@ -178,6 +179,7 @@ func (s *Server) apiAddItem(w http.ResponseWriter, r *http.Request) { res := providers.Result{ Provider: in.Provider, ProviderID: in.ProviderID, Title: in.Title, Subtitle: in.Subtitle, Year: in.Year, ImageURL: in.ImageURL, ExternalURL: in.ExternalURL, Extra: in.Extra, + Overview: in.Description, } if in.Details && in.Provider != "" { res = s.fillDetails(ctx, list.Category, res) @@ -186,7 +188,7 @@ func (s *Server) apiAddItem(w http.ResponseWriter, r *http.Request) { it := store.Item{ ListID: listID, Title: res.Title, Subtitle: res.Subtitle, Year: res.Year, 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 if res.ImageURL != "" { @@ -266,6 +268,7 @@ func (s *Server) apiUpdateItem(w http.ResponseWriter, r *http.Request) { } it := cur 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 { it.Extra = in.Extra } diff --git a/internal/server/public.go b/internal/server/public.go index 6c9bd79..1c63edc 100644 --- a/internal/server/public.go +++ b/internal/server/public.go @@ -18,6 +18,7 @@ type ItemView struct { Image string `json:"image"` ExternalURL string `json:"link"` Source string `json:"source"` + Description string `json:"description"` Note string `json:"note"` Badge string `json:"badge"` // e.g. theater production Meta []MetaKV `json:"meta"` @@ -59,6 +60,7 @@ func itemView(it store.Item, showNotes bool) ItemView { Subtitle: it.Subtitle, Year: it.Year, ExternalURL: it.ExternalURL, + Description: it.Description, Source: providerLabels[it.Provider], Badge: it.Extra["production"], Meta: []MetaKV{}, diff --git a/internal/store/items.go b/internal/store/items.go index ca179f1..8caec8a 100644 --- a/internal/store/items.go +++ b/internal/store/items.go @@ -20,17 +20,18 @@ type Item struct { Provider string `json:"provider"` ProviderID string `json:"providerId"` 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"` } -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) { var it Item var extra string 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) { return it, ErrNotFound } @@ -82,11 +83,12 @@ func (it *Item) normalize() error { it.Subtitle = strings.TrimSpace(it.Subtitle) it.Year = strings.TrimSpace(it.Year) it.Note = strings.TrimSpace(it.Note) + it.Description = strings.TrimSpace(it.Description) it.ExternalURL = strings.TrimSpace(it.ExternalURL) if it.Title == "" { 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") } 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 { 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) - 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)) + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + 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 { return it, err } @@ -157,8 +159,8 @@ func (s *Store) UpdateItem(ctx context.Context, it Item) (Item, error) { if err := it.normalize(); err != nil { 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=?`, - it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.ExternalURL, it.Note, extraJSON(it.Extra), it.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.Description, it.Note, extraJSON(it.Extra), it.ID) if err != nil { return it, err } diff --git a/internal/store/store.go b/internal/store/store.go index 403fb70..51e8572 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -111,6 +111,8 @@ 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 '';`, + // 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 { diff --git a/web/static/css/app.css b/web/static/css/app.css index e6f5433..51816ef 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -120,13 +120,15 @@ code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; bac /* ---------- profile / home ---------- */ .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 { 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))); - color: var(--accent-text); font-weight: 700; + background: var(--accent); color: var(--accent-text); font-weight: 700; } +.avatar:has(img) { background: none; } .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-tagline { margin-top: 8px; color: var(--muted); font-size: 17px; max-width: 60ch; } .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 dt { color: var(--faint); } .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 { - 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; font-size: 15px; white-space: pre-line; } diff --git a/web/static/js/admin-list.js b/web/static/js/admin-list.js index 2976859..8b37228 100644 --- a/web/static/js/admin-list.js +++ b/web/static/js/admin-list.js @@ -271,7 +271,7 @@ function renderResults(res) { h("div", { style: { minWidth: 0 } }, h("div", { class: "result-title" }, r.title), 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, ); @@ -294,6 +294,7 @@ function fromResult(r) { return { title: r.title, subtitle: r.subtitle, year: r.year, externalUrl: r.externalUrl, 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 subtitle = h("input", { maxlength: 500, value: src.subtitle || "" }); 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://" }); // 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), ), 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."), error, ), @@ -461,7 +466,7 @@ function openItemForm({ item, result }) { if (production) newExtra.production = production.value.trim(); for (const x of extraInputs) newExtra[x.key] = x.input.value.trim(); 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, imageUrl: clearImage ? "" : imageUrl.value.trim(), clearImage, }; diff --git a/web/static/js/admin-settings.js b/web/static/js/admin-settings.js index b4f16d1..b19160b 100644 --- a/web/static/js/admin-settings.js +++ b/web/static/js/admin-settings.js @@ -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”.")), 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."), - 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), ); form.addEventListener("submit", async (e) => { diff --git a/web/static/js/public.js b/web/static/js/public.js index 32f71a4..d0bc5c0 100644 --- a/web/static/js/public.js +++ b/web/static/js/public.js @@ -38,7 +38,12 @@ function renderDialog(list, idx) { sub && h("p", { class: "dlg-sub" }, sub), 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)])), - 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" }, 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"),