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.
- **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.
+96 -15
View File
@@ -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,12 +72,7 @@ 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"`
InvolvedCompanies []igdbInvolved `json:"involved_companies"`
Platforms []struct {
Abbreviation string `json:"abbreviation"`
Name string `json:"name"`
@@ -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)
}
+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:]
}
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
}
+5 -1
View File
@@ -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"`
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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
}
+2
View File
@@ -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{},
+11 -9
View File
@@ -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
}
+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.
`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 {
+8 -4
View File
@@ -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;
}
+9 -4
View File
@@ -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,
};
+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”.")),
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) => {
+6 -1
View File
@@ -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"),