diff --git a/README.md b/README.md index 71909e2..79044b2 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ # TopDeck -A self-hosted page for **your** ranked favorites: movies, TV shows, albums, songs, video games and theater. One owner, as many lists as you like, each with its own size (Top 5, Top 100, … up to 65,535), with proper posters and cover art pulled from the best free metadata sources. +A self-hosted page for **your** ranked favorites: movies, TV shows, albums, songs, video games, theater, and anything else you care to rank in a custom list. One owner, as many lists as you like, each with its own size (Top 5, Top 100, … up to 65,535), with proper posters and cover art pulled from the best free metadata sources. Visitors just see your lists. There's no sign-up, no feed and no login button. You manage everything from `/login`. - **Ranked lists per category**: Movies, TV Shows, Albums, Songs, Video Games and Theater. Each list sets its own size, title, description and layout (artwork grid or ranked list). - **Search-to-add** from TMDB, TheTVDB, IGDB, Deezer/MusicBrainz and Wikipedia, with add-manually as a fallback for anything they miss. +- **Custom lists** for anything else, like sports teams, drinks or restaurants. You name the list and add entries by hand (with your own photo or logo), or pull a name and picture from Wikipedia. - **Theater by production**: record *which* staging you mean (Broadway, West End, North American Tour, a revival…) plus venue and cast, and use that production's own poster. - **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). - **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. @@ -44,6 +46,7 @@ Or from source (Go 1.26+): `go run ./cmd/topdeck` stores data in `./data`. | Video Games | [IGDB](https://www.igdb.com) | Yes (free, via Twitch) | Register an app at [dev.twitch.tv/console](https://dev.twitch.tv/console/apps) and use its Client ID + Secret ([guide](https://api-docs.igdb.com/#account-creation)). | | Albums & Songs | [Deezer](https://developers.deezer.com/api) (default) or [MusicBrainz](https://musicbrainz.org) + [Cover Art Archive](https://coverartarchive.org) | No | Nothing to set up. | | Theater | [Wikipedia](https://en.wikipedia.org) | No | Nothing to set up. | +| Custom | Manual entry, or [Wikipedia](https://en.wikipedia.org) | No | Nothing to set up. | **Why these for music and theater?** Neither needs a key. - **Music:** Deezer's public catalogue API has no key, generous limits (about 50 requests per 5 seconds) and 1000×1000 covers for both tracks and albums. MusicBrainz is the fully open alternative, but it's limited to one request per second and art coverage is patchier. Spotify, Apple Music and Last.fm all need keys or OAuth and have stricter terms. @@ -95,6 +98,15 @@ See [`web/static/css/themes.css`](web/static/css/themes.css) for every variable. A list can hold 1 to 65,535 entries. The editor recommends **100 or fewer** and warns past 1,000. Long lists work (public pages and the editor paginate at 100), but they're hard for visitors to take in. +## Exporting a list + +Every list can be downloaded as its titles only, in rank order, one per line. There are no ranks, header row or other fields. + +- **Export → Titles as CSV** (`/l//export?format=csv`): a single column. It starts with a UTF-8 BOM so Excel shows accented titles correctly. +- **Export → Titles as text** (`/l//export?format=txt`): plain lines. + +The Export menu appears in the list editor and, when you're signed in, on the list's public page. The links follow the list's visibility: anyone can download a public list, and only you can download a hidden one. + ## Backups Everything lives in the data volume: `topdeck.db` (SQLite) and `images/`. Copy the volume, or while running: diff --git a/internal/providers/providers.go b/internal/providers/providers.go index 525c41c..f0372fe 100644 --- a/internal/providers/providers.go +++ b/internal/providers/providers.go @@ -112,6 +112,8 @@ func (r *Registry) For(ctx context.Context, category string) (Searcher, error) { } return &igdb{r: r, id: c.IGDBClientID, secret: c.IGDBClientSecret}, nil case "theater": + return &wikipedia{r: r, stage: true}, nil + case "custom": return &wikipedia{r: r}, nil } return nil, fmt.Errorf("no provider for category %q", category) diff --git a/internal/providers/wikipedia.go b/internal/providers/wikipedia.go index af5c268..267f7f6 100644 --- a/internal/providers/wikipedia.go +++ b/internal/providers/wikipedia.go @@ -15,6 +15,9 @@ import ( // specific production (Broadway, a tour, …) is chosen by the owner. type wikipedia struct { r *Registry + // stage limits results to musicals, plays and operas. Custom lists + // search all of Wikipedia. + stage bool } func (w *wikipedia) Name() string { return "wikipedia" } @@ -23,6 +26,9 @@ func (w *wikipedia) Label() string { return "Wikipedia" } const theaterTemplates = `hastemplate:"Infobox musical|Infobox play|Infobox opera"` func (w *wikipedia) Search(ctx context.Context, query string) ([]Result, error) { + if !w.stage { + return w.search(ctx, query) + } res, err := w.search(ctx, query+" "+theaterTemplates) if err == nil && len(res) == 0 { res, err = w.search(ctx, query) diff --git a/internal/server/admin.go b/internal/server/admin.go index fb61c02..e95d2b4 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -60,7 +60,7 @@ func (s *Server) handleAdminList(w http.ResponseWriter, r *http.Request) { s.notFound(w, r) return } - cat, _ := store.CategoryByKey(l.Category) + cat := listCategory(l) provider := "" if p, err := s.providers.For(r.Context(), l.Category); err == nil { provider = p.Label() diff --git a/internal/server/api.go b/internal/server/api.go index 538bae3..4ada560 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -610,6 +610,7 @@ var testQueries = map[string]string{ "song": "Bohemian Rhapsody", "game": "Zelda", "theater": "Hamilton", + "custom": "Chicago Bulls", } func (s *Server) apiTestIntegration(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/export.go b/internal/server/export.go new file mode 100644 index 0000000..a29e4be --- /dev/null +++ b/internal/server/export.go @@ -0,0 +1,75 @@ +package server + +import ( + "encoding/csv" + "fmt" + "io" + "net/http" + "strings" +) + +// exportFormats maps ?format= values to file extension and content type. +var exportFormats = map[string]struct{ ext, contentType string }{ + "csv": {"csv", "text/csv; charset=utf-8"}, + "txt": {"txt", "text/plain; charset=utf-8"}, +} + +// handleExport downloads a list's titles in rank order, one per line, with +// nothing else: no ranks, header row or metadata. +func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { + format := r.URL.Query().Get("format") + if format == "" { + format = "txt" + } + f, ok := exportFormats[format] + if !ok { + http.Error(w, `format must be "csv" or "txt"`, http.StatusBadRequest) + return + } + l, ok := s.visibleList(w, r) + if !ok { + return + } + items, err := s.store.Items(r.Context(), l.ID, 0, 0) + if err != nil { + s.serverError(w, r, err) + return + } + titles := make([]string, len(items)) + for i, it := range items { + titles[i] = it.Title + } + w.Header().Set("Content-Type", f.contentType) + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.%s"`, l.Slug, f.ext)) + w.Header().Set("Cache-Control", "no-store") + if err := writeTitles(w, format, titles); err != nil { + s.log.Warn("export failed", "list", l.Slug, "err", err) + } +} + +// flatten keeps one title per line even if a title contains a newline. +var flatten = strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ") + +func writeTitles(w io.Writer, format string, titles []string) error { + if format == "csv" { + // The BOM makes Excel read accented titles (Les Misérables) as UTF-8. + if _, err := io.WriteString(w, "\xef\xbb\xbf"); err != nil { + return err + } + cw := csv.NewWriter(w) + cw.UseCRLF = true + for _, t := range titles { + if err := cw.Write([]string{flatten.Replace(t)}); err != nil { + return err + } + } + cw.Flush() + return cw.Error() + } + for _, t := range titles { + if _, err := io.WriteString(w, flatten.Replace(t)+"\n"); err != nil { + return err + } + } + return nil +} diff --git a/internal/server/export_test.go b/internal/server/export_test.go new file mode 100644 index 0000000..e8df275 --- /dev/null +++ b/internal/server/export_test.go @@ -0,0 +1,28 @@ +package server + +import ( + "strings" + "testing" +) + +func TestWriteTitles(t *testing.T) { + titles := []string{"Hamilton", `Les Misérables`, `Sweeney Todd: The "Demon" Barber, of Fleet Street`, "Two\nLines"} + + var txt strings.Builder + if err := writeTitles(&txt, "txt", titles); err != nil { + t.Fatal(err) + } + wantTxt := "Hamilton\nLes Misérables\nSweeney Todd: The \"Demon\" Barber, of Fleet Street\nTwo Lines\n" + if txt.String() != wantTxt { + t.Errorf("txt:\n%q\nwant\n%q", txt.String(), wantTxt) + } + + var csv strings.Builder + if err := writeTitles(&csv, "csv", titles); err != nil { + t.Fatal(err) + } + wantCSV := "\xef\xbb\xbfHamilton\r\nLes Misérables\r\n\"Sweeney Todd: The \"\"Demon\"\" Barber, of Fleet Street\"\r\nTwo Lines\r\n" + if csv.String() != wantCSV { + t.Errorf("csv:\n%q\nwant\n%q", csv.String(), wantCSV) + } +} diff --git a/internal/server/public.go b/internal/server/public.go index 286c518..d67bc7d 100644 --- a/internal/server/public.go +++ b/internal/server/public.go @@ -77,6 +77,14 @@ func itemView(it store.Item, showNotes bool) ItemView { return v } +// listCategory is the list's category with the list's own artwork shape +// applied. +func listCategory(l store.List) store.Category { + c, _ := store.CategoryByKey(l.Category) + c.Aspect = l.Aspect() + return c +} + type listSection struct { List store.List Category store.Category @@ -110,7 +118,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { return } sec := listSection{List: l, More: l.ItemCount - len(items)} - sec.Category, _ = store.CategoryByKey(l.Category) + sec.Category = listCategory(l) for _, it := range items { sec.Items = append(sec.Items, itemView(it, site.ShowNotes)) } @@ -138,26 +146,36 @@ type listPage struct { PageNums []int } -func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - l, err := s.store.ListBySlug(ctx, r.PathValue("slug")) +// visibleList loads the list named in the URL, treating hidden lists as +// missing for visitors. It writes the error response when ok is false. +func (s *Server) visibleList(w http.ResponseWriter, r *http.Request) (l store.List, ok bool) { + l, err := s.store.ListBySlug(r.Context(), r.PathValue("slug")) if errors.Is(err, store.ErrNotFound) { s.notFound(w, r) - return + return l, false } if err != nil { s.serverError(w, r, err) - return + return l, false } if !l.Visible { - if _, ok := s.currentUser(r); !ok { + if _, loggedIn := s.currentUser(r); !loggedIn { s.notFound(w, r) - return + return l, false } } + return l, true +} + +func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + l, ok := s.visibleList(w, r) + if !ok { + return + } site := s.site(ctx) p := listPage{List: l, Page: 1} - p.Category, _ = store.CategoryByKey(l.Category) + p.Category = listCategory(l) p.Pages = max(1, (l.ItemCount+listPageSize-1)/listPageSize) if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n > 1 { p.Page = min(n, p.Pages) diff --git a/internal/server/server.go b/internal/server/server.go index d5079ad..b7be7d4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -98,6 +98,7 @@ func (s *Server) Handler() http.Handler { // Public mux.HandleFunc("GET /{$}", s.handleHome) mux.HandleFunc("GET /l/{slug}", s.handleList) + mux.HandleFunc("GET /l/{slug}/export", s.handleExport) mux.HandleFunc("GET /images/{name}", s.handleImage) mux.HandleFunc("GET /custom.css", s.handleCustomCSS) mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler())) @@ -186,7 +187,7 @@ func (s *Server) recoverer(next http.Handler) http.Handler { func (s *Server) securityHeaders(next http.Handler) http.Handler { // Remote https images are allowed so search results can preview art // before it's cached locally. - const csp = "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; " + + const csp = "default-src 'self'; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; " + "object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'" return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() diff --git a/internal/store/categories.go b/internal/store/categories.go index 6872416..3fc06cb 100644 --- a/internal/store/categories.go +++ b/internal/store/categories.go @@ -19,6 +19,9 @@ var Categories = []Category{ {Key: "song", Label: "Songs", Singular: "Song", Icon: "music", Aspect: "square", SubtitleHint: "Artist"}, {Key: "game", Label: "Video Games", Singular: "Video Game", Icon: "gamepad", Aspect: "poster", SubtitleHint: "Developer"}, {Key: "theater", Label: "Theater", Singular: "Show", Icon: "masks", Aspect: "poster", SubtitleHint: "Creators"}, + // Custom lists hold anything else (sports teams, drinks…); the owner + // names the list and adds entries by hand or from Wikipedia. + {Key: "custom", Label: "Custom", Singular: "Favorite", Icon: "star", Aspect: "square", SubtitleHint: "Subtitle"}, } func CategoryByKey(key string) (Category, bool) { diff --git a/internal/store/lists.go b/internal/store/lists.go index e7df3aa..1e1699a 100644 --- a/internal/store/lists.go +++ b/internal/store/lists.go @@ -25,16 +25,17 @@ type List struct { Position int `json:"position"` Visible bool `json:"visible"` Layout string `json:"layout"` // "grid" or "rows" + Art string `json:"art"` // "", "poster" or "square"; "" = category default ItemCount int `json:"itemCount"` UpdatedAt int64 `json:"updatedAt"` } -const listColumns = `l.id, l.slug, l.title, l.category, l.description, l.max_items, l.position, l.visible, l.layout, l.updated_at, +const listColumns = `l.id, l.slug, l.title, l.category, l.description, l.max_items, l.position, l.visible, l.layout, l.art, l.updated_at, (SELECT COUNT(*) FROM items i WHERE i.list_id = l.id)` func scanList(row interface{ Scan(...any) error }) (List, error) { var l List - err := row.Scan(&l.ID, &l.Slug, &l.Title, &l.Category, &l.Description, &l.MaxItems, &l.Position, &l.Visible, &l.Layout, &l.UpdatedAt, &l.ItemCount) + err := row.Scan(&l.ID, &l.Slug, &l.Title, &l.Category, &l.Description, &l.MaxItems, &l.Position, &l.Visible, &l.Layout, &l.Art, &l.UpdatedAt, &l.ItemCount) if errors.Is(err, sql.ErrNoRows) { return l, ErrNotFound } @@ -99,6 +100,9 @@ func (l *List) normalize() error { if l.Layout != "rows" { l.Layout = "grid" } + if l.Art != "poster" && l.Art != "square" { + l.Art = "" + } return nil } @@ -140,14 +144,17 @@ func (s *Store) CreateList(ctx context.Context, l List) (List, error) { if strings.TrimSpace(l.Slug) == "" { c, _ := CategoryByKey(l.Category) base = slugify(c.Label) + if c.Key == "custom" { + base = slugify(l.Title) + } } slug, err := s.uniqueSlug(ctx, base, 0) if err != nil { return l, err } - res, err := s.db.ExecContext(ctx, `INSERT INTO lists (slug, title, category, description, max_items, position, visible, layout) - VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM lists), ?, ?)`, - slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout) + res, err := s.db.ExecContext(ctx, `INSERT INTO lists (slug, title, category, description, max_items, position, visible, layout, art) + VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM lists), ?, ?, ?)`, + slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout, l.Art) if err != nil { return l, err } @@ -175,8 +182,8 @@ func (s *Store) UpdateList(ctx context.Context, l List) (List, error) { return l, err } } - _, err = s.db.ExecContext(ctx, `UPDATE lists SET slug=?, title=?, category=?, description=?, max_items=?, visible=?, layout=?, updated_at=unixepoch() WHERE id=?`, - slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout, l.ID) + _, err = s.db.ExecContext(ctx, `UPDATE lists SET slug=?, title=?, category=?, description=?, max_items=?, visible=?, layout=?, art=?, updated_at=unixepoch() WHERE id=?`, + slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout, l.Art, l.ID) if err != nil { return l, err } @@ -229,3 +236,12 @@ func (s *Store) ListCovers(ctx context.Context, n int) (map[int64][]string, erro } return out, rows.Err() } + +// Aspect is the list's artwork shape: its own choice, or the category's. +func (l List) Aspect() string { + if l.Art != "" { + return l.Art + } + c, _ := CategoryByKey(l.Category) + return c.Aspect +} diff --git a/internal/store/store.go b/internal/store/store.go index 78948a9..979fa9a 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -86,6 +86,8 @@ CREATE TABLE items ( ); 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 '';`, } func (s *Store) migrate(ctx context.Context) error { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 571dafa..da364d1 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -151,3 +151,29 @@ func BenchmarkMoveInLargeList(b *testing.B) { s.MoveItem(ctx, first, 5000-(i%2)*4999) } } + +func TestCustomList(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + l, err := s.CreateList(ctx, List{Title: "Favorite Sports Teams", Category: "custom", MaxItems: 10, Art: "poster"}) + if err != nil { + t.Fatal(err) + } + if l.Slug != "favorite-sports-teams" { + t.Errorf("custom slug = %q, want it from the title", l.Slug) + } + if l.Aspect() != "poster" { + t.Errorf("aspect = %q, want the list's own choice", l.Aspect()) + } + l.Art = "sideways" + if l, err = s.UpdateList(ctx, l); err != nil { + t.Fatal(err) + } + if l.Art != "" || l.Aspect() != "square" { + t.Errorf("invalid art should fall back to the category default, got art=%q aspect=%q", l.Art, l.Aspect()) + } + m, _ := s.CreateList(ctx, List{Title: "Top 10 Movies", Category: "movie", MaxItems: 10}) + if m.Aspect() != "poster" { + t.Errorf("movie aspect = %q", m.Aspect()) + } +} diff --git a/web/static/css/app.css b/web/static/css/app.css index a0b98be..989caa9 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -277,6 +277,17 @@ code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; bac .pager-btn.is-current { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } .pager-gap { color: var(--faint); padding: 0 4px; } +/* ---------- dropdown menu ---------- */ +.menu { position: relative; } +.menu > summary { list-style: none; } +.menu > summary::-webkit-details-marker { display: none; } +.menu-pop { + position: absolute; right: 0; top: calc(100% + 6px); z-index: 30; min-width: 200px; padding: 6px; + background: var(--surface); border: 1px solid var(--border); border-radius: calc(var(--radius) + 2px); box-shadow: var(--shadow); +} +.menu-pop a { display: flex; justify-content: space-between; gap: 12px; padding: 8px 10px; border-radius: 8px; text-decoration: none; font-size: 14px; } +.menu-pop a:hover { background: var(--surface-2); } + /* ---------- empty / error ---------- */ .empty { text-align: center; padding-top: 80px; padding-bottom: 40px; display: flex; flex-direction: column; align-items: center; gap: 10px; } .empty-art { display: grid; place-items: center; width: 72px; height: 72px; border-radius: 20px; background: var(--accent-soft); color: var(--accent); margin-bottom: 10px; } @@ -445,6 +456,9 @@ input[type="checkbox"] { accent-color: var(--accent); width: 18px; height: 18px; } .cat-option .icon, .quick-cats .icon { width: 22px; height: 22px; color: var(--accent); } .cat-option span:hover, .quick-cats button:hover { border-color: var(--accent); } +.cat-option--wide { grid-column: 1 / -1; } +.cat-option--wide span { flex-direction: row; justify-content: center; flex-wrap: wrap; gap: 4px 8px; } +.cat-option--wide small { flex-basis: 100%; font-weight: 400; } .cat-option input:checked + span { border-color: var(--accent); background: var(--accent-soft); box-shadow: 0 0 0 1px var(--accent) inset; } .cat-option input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; } .cat-option input:disabled + span { opacity: 0.45; cursor: not-allowed; } diff --git a/web/static/img/icons.svg b/web/static/img/icons.svg index 5cb9622..4674468 100644 --- a/web/static/img/icons.svg +++ b/web/static/img/icons.svg @@ -6,6 +6,7 @@ + @@ -21,6 +22,7 @@ + diff --git a/web/static/js/admin-list.js b/web/static/js/admin-list.js index efcf8ac..2976859 100644 --- a/web/static/js/admin-list.js +++ b/web/static/js/admin-list.js @@ -42,6 +42,8 @@ function renderHeader() { document.querySelector("[data-bind=max]").textContent = fmt.format(list.maxItems); document.querySelector("[data-bind=hidden]").textContent = list.visible ? "" : " · Hidden"; document.querySelector("[data-bind=public]").href = `/l/${list.slug}`; + document.querySelector("[data-bind=export-csv]").href = `/l/${list.slug}/export?format=csv`; + document.querySelector("[data-bind=export-txt]").href = `/l/${list.slug}/export?format=txt`; document.querySelector("[data-bind=meter]").style.width = Math.min(100, (list.itemCount / list.maxItems) * 100) + "%"; document.querySelector("[data-bind=provider]").textContent = boot.provider ? `via ${boot.provider}` : ""; document.title = `${list.title} · ${document.title.split(" · ").pop()}`; @@ -53,6 +55,8 @@ document.querySelector("[data-action=edit-list]").addEventListener("click", () = openListForm({ categories, limits, list, onSaved: (saved) => { + // The artwork shape affects the whole editor, so redraw from scratch. + if (saved.art !== list.art) { location.reload(); return; } list = saved; renderHeader(); renderResults(lastResults); @@ -127,7 +131,9 @@ function renderItems(flashId) { if (!items.length) { rankList.replaceChildren(h("li", { class: "rank-empty" }, h("div", { class: "empty-art", style: { margin: "0 auto 12px" } }, icon(cat.icon)), - h("p", {}, `No ${cat.label.toLowerCase()} yet. Search on the left to add your first.`))); + h("p", {}, cat.key === "custom" + ? "Nothing here yet. Add your first favorite by hand, or search Wikipedia for one with a picture." + : `No ${cat.label.toLowerCase()} yet. Search on the left to add your first.`))); } else { rankList.replaceChildren(...items.map(row)); } @@ -359,8 +365,11 @@ function openItemForm({ item, result }) { type: "url", placeholder: "Paste an image URL", value: editing ? (item.imageSource && item.imageSource !== "upload" ? item.imageSource : "") : (src.imageUrl || ""), }); + // In add mode a chosen file waits here and is uploaded once the entry exists. + let pendingFile = null; imageUrl.addEventListener("change", () => { clearImage = false; + pendingFile = null; preview.replaceChildren(artBox(imageUrl.value.trim())); }); const fileInput = h("input", { type: "file", accept: "image/jpeg,image/png,image/webp,image/gif,image/avif", hidden: true }); @@ -369,7 +378,11 @@ function openItemForm({ item, result }) { const file = fileInput.files[0]; if (!file) return; if (!editing) { - toast("Save the entry first, then upload art for it.", { error: true }); + pendingFile = file; + clearImage = false; + imageUrl.value = ""; + preview.replaceChildren(artBox(URL.createObjectURL(file))); + fileInput.value = ""; return; } const fd = new FormData(); @@ -392,12 +405,14 @@ function openItemForm({ item, result }) { }); const removeArt = h("button", { type: "button", class: "btn btn--sm btn--ghost", - onclick: () => { clearImage = true; imageUrl.value = ""; preview.replaceChildren(artBox("")); }, + onclick: () => { clearImage = true; pendingFile = null; imageUrl.value = ""; preview.replaceChildren(artBox("")); }, }, icon("x"), "Remove"); const artHint = cat.key === "theater" ? "Tip: use a poster from the specific production (a tour's key art, a revival's Playbill…)." - : "Art is saved to your server, so it keeps working if the source changes."; + : cat.key === "custom" + ? "Paste an image URL or upload a photo or logo." + : "Art is saved to your server, so it keeps working if the source changes."; const error = h("p", { class: "alert alert--error", role: "alert", hidden: true }); const save = h("button", { type: "submit", class: "btn btn--primary" }, editing ? "Save" : `Add at #${list.itemCount + 1}`); @@ -413,7 +428,7 @@ function openItemForm({ item, result }) { h("div", { class: "art-edit-actions" }, h("span", { class: "field-label" }, "Artwork"), imageUrl, - h("div", { class: "row-btns" }, editing && uploadBtn, removeArt, fileInput), + h("div", { class: "row-btns" }, uploadBtn, removeArt, fileInput), h("span", { class: "field-hint" }, artHint), ), ), @@ -462,6 +477,16 @@ function openItemForm({ item, result }) { provider: result?.provider || "", providerId: result?.providerId || "", details: !!result, }); const created = await createItem(body); + if (created && pendingFile) { + const fd = new FormData(); + fd.append("file", pendingFile); + try { + await api("POST", `/api/items/${created.id}/image`, fd); + await load(page, created.id); + } catch (err) { + toast(`Added, but the artwork didn't upload: ${err.message}`, { error: true }); + } + } if (created) dlg.close(); } } catch (err) { diff --git a/web/static/js/admin-lists.js b/web/static/js/admin-lists.js index df43fc7..b42f19a 100644 --- a/web/static/js/admin-lists.js +++ b/web/static/js/admin-lists.js @@ -53,7 +53,7 @@ function card(list) { const pct = Math.min(100, Math.round((list.itemCount / list.maxItems) * 100)); const imgs = covers[list.id] || []; const art = imgs.length - ? h("span", { class: "list-card-covers" + (cat.aspect === "square" ? " is-square" : "") }, + ? h("span", { class: "list-card-covers" + ((list.art || cat.aspect) === "square" ? " is-square" : "") }, imgs.map((src) => h("span", { style: { backgroundImage: `url("${src}")` } }))) : h("span", { class: "shelf-icon" }, icon(cat.icon)); return h("li", { class: "list-card", "data-id": list.id }, diff --git a/web/static/js/admin-settings.js b/web/static/js/admin-settings.js index 3d16eab..cb8bc86 100644 --- a/web/static/js/admin-settings.js +++ b/web/static/js/admin-settings.js @@ -306,8 +306,8 @@ function integrationsSection() { ready: () => true, }), integration({ - title: "Theater · Wikipedia", iconName: "masks", category: "theater", - desc: "No key needed. Finds musicals, plays and operas on Wikipedia. You choose the production (Broadway, a tour…) and can swap in its own poster.", + title: "Theater & Custom · Wikipedia", iconName: "masks", category: "theater", + desc: "No key needed. Theater lists search musicals, plays and operas; you choose the production (Broadway, a tour…) and can swap in its own poster. Custom lists can search all of Wikipedia for a name and picture, or skip search and add entries by hand.", ready: () => true, }), ); diff --git a/web/static/js/common.js b/web/static/js/common.js index 073d69e..eb58d7b 100644 --- a/web/static/js/common.js +++ b/web/static/js/common.js @@ -158,3 +158,10 @@ export function storageGet(key) { export function storageSet(key, val) { try { localStorage.setItem(key, val); } catch { /* private mode */ } } + +// Close