Add titles-only list export and Custom lists
Export - GET /l/<list>/export?format=csv|txt downloads a list's titles in rank order, one per line, with no header or other fields (CSV gets a UTF-8 BOM for Excel) - Export menu in the list editor and, for the owner, on the public list page; hidden lists stay owner-only Custom lists - New "Custom" category for anything not built in (sports teams, drinks…): owner-named, slug from the title, entries added by hand or from an unfiltered Wikipedia search - Per-list artwork shape (square or tall), stored in a new lists.art column (migration 2) - Art can be uploaded while adding an entry, not only after Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e9a75a968a
commit
58a29b7339
23 files changed
+314
-37
No files matched your search
@@ -1,14 +1,16 @@
|
|||||||
# TopDeck
|
# 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`.
|
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).
|
- **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.
|
- **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.
|
- **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.
|
- **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`.
|
||||||
- **Personal notes** on any entry, shown in a detail view (toggleable).
|
- **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.
|
- **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.
|
||||||
@@ -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)). |
|
| 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. |
|
| 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. |
|
| 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.
|
**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.
|
- **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.
|
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/<list>/export?format=csv`): a single column. It starts with a UTF-8 BOM so Excel shows accented titles correctly.
|
||||||
|
- **Export → Titles as text** (`/l/<list>/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
|
## Backups
|
||||||
|
|
||||||
Everything lives in the data volume: `topdeck.db` (SQLite) and `images/`. Copy the volume, or while running:
|
Everything lives in the data volume: `topdeck.db` (SQLite) and `images/`. Copy the volume, or while running:
|
||||||
|
|||||||
@@ -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
|
return &igdb{r: r, id: c.IGDBClientID, secret: c.IGDBClientSecret}, nil
|
||||||
case "theater":
|
case "theater":
|
||||||
|
return &wikipedia{r: r, stage: true}, nil
|
||||||
|
case "custom":
|
||||||
return &wikipedia{r: r}, nil
|
return &wikipedia{r: r}, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("no provider for category %q", category)
|
return nil, fmt.Errorf("no provider for category %q", category)
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import (
|
|||||||
// specific production (Broadway, a tour, …) is chosen by the owner.
|
// specific production (Broadway, a tour, …) is chosen by the owner.
|
||||||
type wikipedia struct {
|
type wikipedia struct {
|
||||||
r *Registry
|
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" }
|
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"`
|
const theaterTemplates = `hastemplate:"Infobox musical|Infobox play|Infobox opera"`
|
||||||
|
|
||||||
func (w *wikipedia) Search(ctx context.Context, query string) ([]Result, error) {
|
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)
|
res, err := w.search(ctx, query+" "+theaterTemplates)
|
||||||
if err == nil && len(res) == 0 {
|
if err == nil && len(res) == 0 {
|
||||||
res, err = w.search(ctx, query)
|
res, err = w.search(ctx, query)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func (s *Server) handleAdminList(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.notFound(w, r)
|
s.notFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cat, _ := store.CategoryByKey(l.Category)
|
cat := listCategory(l)
|
||||||
provider := ""
|
provider := ""
|
||||||
if p, err := s.providers.For(r.Context(), l.Category); err == nil {
|
if p, err := s.providers.For(r.Context(), l.Category); err == nil {
|
||||||
provider = p.Label()
|
provider = p.Label()
|
||||||
|
|||||||
@@ -610,6 +610,7 @@ var testQueries = map[string]string{
|
|||||||
"song": "Bohemian Rhapsody",
|
"song": "Bohemian Rhapsody",
|
||||||
"game": "Zelda",
|
"game": "Zelda",
|
||||||
"theater": "Hamilton",
|
"theater": "Hamilton",
|
||||||
|
"custom": "Chicago Bulls",
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) apiTestIntegration(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) apiTestIntegration(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,14 @@ func itemView(it store.Item, showNotes bool) ItemView {
|
|||||||
return v
|
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 {
|
type listSection struct {
|
||||||
List store.List
|
List store.List
|
||||||
Category store.Category
|
Category store.Category
|
||||||
@@ -110,7 +118,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
sec := listSection{List: l, More: l.ItemCount - len(items)}
|
sec := listSection{List: l, More: l.ItemCount - len(items)}
|
||||||
sec.Category, _ = store.CategoryByKey(l.Category)
|
sec.Category = listCategory(l)
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
sec.Items = append(sec.Items, itemView(it, site.ShowNotes))
|
sec.Items = append(sec.Items, itemView(it, site.ShowNotes))
|
||||||
}
|
}
|
||||||
@@ -138,26 +146,36 @@ type listPage struct {
|
|||||||
PageNums []int
|
PageNums []int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
|
// visibleList loads the list named in the URL, treating hidden lists as
|
||||||
ctx := r.Context()
|
// missing for visitors. It writes the error response when ok is false.
|
||||||
l, err := s.store.ListBySlug(ctx, r.PathValue("slug"))
|
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) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
s.notFound(w, r)
|
s.notFound(w, r)
|
||||||
return
|
return l, false
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.serverError(w, r, err)
|
s.serverError(w, r, err)
|
||||||
return
|
return l, false
|
||||||
}
|
}
|
||||||
if !l.Visible {
|
if !l.Visible {
|
||||||
if _, ok := s.currentUser(r); !ok {
|
if _, loggedIn := s.currentUser(r); !loggedIn {
|
||||||
s.notFound(w, r)
|
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)
|
site := s.site(ctx)
|
||||||
p := listPage{List: l, Page: 1}
|
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)
|
p.Pages = max(1, (l.ItemCount+listPageSize-1)/listPageSize)
|
||||||
if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n > 1 {
|
if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n > 1 {
|
||||||
p.Page = min(n, p.Pages)
|
p.Page = min(n, p.Pages)
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
// Public
|
// Public
|
||||||
mux.HandleFunc("GET /{$}", s.handleHome)
|
mux.HandleFunc("GET /{$}", s.handleHome)
|
||||||
mux.HandleFunc("GET /l/{slug}", s.handleList)
|
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 /images/{name}", s.handleImage)
|
||||||
mux.HandleFunc("GET /custom.css", s.handleCustomCSS)
|
mux.HandleFunc("GET /custom.css", s.handleCustomCSS)
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", s.staticHandler()))
|
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 {
|
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||||
// Remote https images are allowed so search results can preview art
|
// Remote https images are allowed so search results can preview art
|
||||||
// before it's cached locally.
|
// 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'"
|
"object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'"
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
h := w.Header()
|
h := w.Header()
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ var Categories = []Category{
|
|||||||
{Key: "song", Label: "Songs", Singular: "Song", Icon: "music", Aspect: "square", SubtitleHint: "Artist"},
|
{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: "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"},
|
{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) {
|
func CategoryByKey(key string) (Category, bool) {
|
||||||
|
|||||||
+23
-7
@@ -25,16 +25,17 @@ type List struct {
|
|||||||
Position int `json:"position"`
|
Position int `json:"position"`
|
||||||
Visible bool `json:"visible"`
|
Visible bool `json:"visible"`
|
||||||
Layout string `json:"layout"` // "grid" or "rows"
|
Layout string `json:"layout"` // "grid" or "rows"
|
||||||
|
Art string `json:"art"` // "", "poster" or "square"; "" = category default
|
||||||
ItemCount int `json:"itemCount"`
|
ItemCount int `json:"itemCount"`
|
||||||
UpdatedAt int64 `json:"updatedAt"`
|
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)`
|
(SELECT COUNT(*) FROM items i WHERE i.list_id = l.id)`
|
||||||
|
|
||||||
func scanList(row interface{ Scan(...any) error }) (List, error) {
|
func scanList(row interface{ Scan(...any) error }) (List, error) {
|
||||||
var l List
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return l, ErrNotFound
|
return l, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -99,6 +100,9 @@ func (l *List) normalize() error {
|
|||||||
if l.Layout != "rows" {
|
if l.Layout != "rows" {
|
||||||
l.Layout = "grid"
|
l.Layout = "grid"
|
||||||
}
|
}
|
||||||
|
if l.Art != "poster" && l.Art != "square" {
|
||||||
|
l.Art = ""
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,14 +144,17 @@ func (s *Store) CreateList(ctx context.Context, l List) (List, error) {
|
|||||||
if strings.TrimSpace(l.Slug) == "" {
|
if strings.TrimSpace(l.Slug) == "" {
|
||||||
c, _ := CategoryByKey(l.Category)
|
c, _ := CategoryByKey(l.Category)
|
||||||
base = slugify(c.Label)
|
base = slugify(c.Label)
|
||||||
|
if c.Key == "custom" {
|
||||||
|
base = slugify(l.Title)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
slug, err := s.uniqueSlug(ctx, base, 0)
|
slug, err := s.uniqueSlug(ctx, base, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return l, err
|
return l, err
|
||||||
}
|
}
|
||||||
res, err := s.db.ExecContext(ctx, `INSERT INTO lists (slug, title, category, description, max_items, position, visible, 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), ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM lists), ?, ?, ?)`,
|
||||||
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout)
|
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout, l.Art)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return l, err
|
return l, err
|
||||||
}
|
}
|
||||||
@@ -175,8 +182,8 @@ func (s *Store) UpdateList(ctx context.Context, l List) (List, error) {
|
|||||||
return l, err
|
return l, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_, err = s.db.ExecContext(ctx, `UPDATE lists SET slug=?, title=?, category=?, description=?, max_items=?, visible=?, layout=?, updated_at=unixepoch() WHERE 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.ID)
|
slug, l.Title, l.Category, l.Description, l.MaxItems, l.Visible, l.Layout, l.Art, l.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return l, err
|
return l, err
|
||||||
}
|
}
|
||||||
@@ -229,3 +236,12 @@ func (s *Store) ListCovers(ctx context.Context, n int) (map[int64][]string, erro
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
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
|
||||||
|
}
|
||||||
@@ -86,6 +86,8 @@ CREATE TABLE items (
|
|||||||
);
|
);
|
||||||
CREATE INDEX items_list_rank ON items(list_id, rank);
|
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 {
|
func (s *Store) migrate(ctx context.Context) error {
|
||||||
|
|||||||
@@ -151,3 +151,29 @@ func BenchmarkMoveInLargeList(b *testing.B) {
|
|||||||
s.MoveItem(ctx, first, 5000-(i%2)*4999)
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-btn.is-current { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
|
||||||
.pager-gap { color: var(--faint); padding: 0 4px; }
|
.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 / error ---------- */
|
||||||
.empty { text-align: center; padding-top: 80px; padding-bottom: 40px; display: flex; flex-direction: column; align-items: center; gap: 10px; }
|
.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; }
|
.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 .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 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: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:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
.cat-option input:disabled + span { opacity: 0.45; cursor: not-allowed; }
|
.cat-option input:disabled + span { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<symbol id="music" viewBox="0 0 24 24"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></symbol>
|
<symbol id="music" viewBox="0 0 24 24"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></symbol>
|
||||||
<symbol id="gamepad" viewBox="0 0 24 24"><path d="M6 12h4M8 10v4M15 13h.01M18 11h.01"/><rect width="20" height="12" x="2" y="6" rx="4"/></symbol>
|
<symbol id="gamepad" viewBox="0 0 24 24"><path d="M6 12h4M8 10v4M15 13h.01M18 11h.01"/><rect width="20" height="12" x="2" y="6" rx="4"/></symbol>
|
||||||
<symbol id="masks" viewBox="0 0 24 24"><path d="M2 4.5c0-.8.6-1.4 1.4-1.3C5 3.4 7 3.4 8.6 3.1c.8-.1 1.4.5 1.4 1.3V9c0 3.3-1.8 6-4 6s-4-2.7-4-6z"/><path d="M4.5 7.5h.01M7.5 7.5h.01M4.8 11.2c.7-.5 1.7-.5 2.4 0"/><path d="M14 9.6c0-.8.6-1.4 1.4-1.3 1.6.3 3.6.3 5.2 0 .8-.1 1.4.5 1.4 1.3V14c0 3.3-1.8 6-4 6s-4-2.7-4-6z"/><path d="M16.5 12.5h.01M19.5 12.5h.01M16.8 15.6c.7.5 1.7.5 2.4 0"/><path d="M10 12.5c.9.7 2.3.8 4 .3"/></symbol>
|
<symbol id="masks" viewBox="0 0 24 24"><path d="M2 4.5c0-.8.6-1.4 1.4-1.3C5 3.4 7 3.4 8.6 3.1c.8-.1 1.4.5 1.4 1.3V9c0 3.3-1.8 6-4 6s-4-2.7-4-6z"/><path d="M4.5 7.5h.01M7.5 7.5h.01M4.8 11.2c.7-.5 1.7-.5 2.4 0"/><path d="M14 9.6c0-.8.6-1.4 1.4-1.3 1.6.3 3.6.3 5.2 0 .8-.1 1.4.5 1.4 1.3V14c0 3.3-1.8 6-4 6s-4-2.7-4-6z"/><path d="M16.5 12.5h.01M19.5 12.5h.01M16.8 15.6c.7.5 1.7.5 2.4 0"/><path d="M10 12.5c.9.7 2.3.8 4 .3"/></symbol>
|
||||||
|
<symbol id="star" viewBox="0 0 24 24"><path d="M11.5 2.9a.5.5 0 0 1 1 0l2.3 4.7a2 2 0 0 0 1.5 1.1l5.2.8a.5.5 0 0 1 .3.9l-3.8 3.6a2 2 0 0 0-.6 1.8l.9 5.2a.5.5 0 0 1-.8.5l-4.6-2.4a2 2 0 0 0-1.9 0l-4.6 2.4a.5.5 0 0 1-.8-.5l.9-5.2a2 2 0 0 0-.6-1.8L2.3 10.4a.5.5 0 0 1 .3-.9l5.2-.8a2 2 0 0 0 1.5-1.1z"/></symbol>
|
||||||
<symbol id="grip" viewBox="0 0 24 24"><circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/></symbol>
|
<symbol id="grip" viewBox="0 0 24 24"><circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/></symbol>
|
||||||
<symbol id="pencil" viewBox="0 0 24 24"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></symbol>
|
<symbol id="pencil" viewBox="0 0 24 24"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></symbol>
|
||||||
<symbol id="trash" viewBox="0 0 24 24"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/></symbol>
|
<symbol id="trash" viewBox="0 0 24 24"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/></symbol>
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
<symbol id="rows" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="6" rx="1.5"/><rect x="3" y="14" width="18" height="6" rx="1.5"/></symbol>
|
<symbol id="rows" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="6" rx="1.5"/><rect x="3" y="14" width="18" height="6" rx="1.5"/></symbol>
|
||||||
<symbol id="grid" viewBox="0 0 24 24"><rect width="7" height="7" x="3" y="3" rx="1.5"/><rect width="7" height="7" x="14" y="3" rx="1.5"/><rect width="7" height="7" x="14" y="14" rx="1.5"/><rect width="7" height="7" x="3" y="14" rx="1.5"/></symbol>
|
<symbol id="grid" viewBox="0 0 24 24"><rect width="7" height="7" x="3" y="3" rx="1.5"/><rect width="7" height="7" x="14" y="3" rx="1.5"/><rect width="7" height="7" x="14" y="14" rx="1.5"/><rect width="7" height="7" x="3" y="14" rx="1.5"/></symbol>
|
||||||
<symbol id="logout" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></symbol>
|
<symbol id="logout" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></symbol>
|
||||||
|
<symbol id="download" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></symbol>
|
||||||
<symbol id="upload" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></symbol>
|
<symbol id="upload" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></symbol>
|
||||||
<symbol id="image" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/></symbol>
|
<symbol id="image" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/></symbol>
|
||||||
<symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7M19 12H5"/></symbol>
|
<symbol id="arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7M19 12H5"/></symbol>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.1 KiB |
@@ -42,6 +42,8 @@ function renderHeader() {
|
|||||||
document.querySelector("[data-bind=max]").textContent = fmt.format(list.maxItems);
|
document.querySelector("[data-bind=max]").textContent = fmt.format(list.maxItems);
|
||||||
document.querySelector("[data-bind=hidden]").textContent = list.visible ? "" : " · Hidden";
|
document.querySelector("[data-bind=hidden]").textContent = list.visible ? "" : " · Hidden";
|
||||||
document.querySelector("[data-bind=public]").href = `/l/${list.slug}`;
|
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=meter]").style.width = Math.min(100, (list.itemCount / list.maxItems) * 100) + "%";
|
||||||
document.querySelector("[data-bind=provider]").textContent = boot.provider ? `via ${boot.provider}` : "";
|
document.querySelector("[data-bind=provider]").textContent = boot.provider ? `via ${boot.provider}` : "";
|
||||||
document.title = `${list.title} · ${document.title.split(" · ").pop()}`;
|
document.title = `${list.title} · ${document.title.split(" · ").pop()}`;
|
||||||
@@ -53,6 +55,8 @@ document.querySelector("[data-action=edit-list]").addEventListener("click", () =
|
|||||||
openListForm({
|
openListForm({
|
||||||
categories, limits, list,
|
categories, limits, list,
|
||||||
onSaved: (saved) => {
|
onSaved: (saved) => {
|
||||||
|
// The artwork shape affects the whole editor, so redraw from scratch.
|
||||||
|
if (saved.art !== list.art) { location.reload(); return; }
|
||||||
list = saved;
|
list = saved;
|
||||||
renderHeader();
|
renderHeader();
|
||||||
renderResults(lastResults);
|
renderResults(lastResults);
|
||||||
@@ -127,7 +131,9 @@ function renderItems(flashId) {
|
|||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
rankList.replaceChildren(h("li", { class: "rank-empty" },
|
rankList.replaceChildren(h("li", { class: "rank-empty" },
|
||||||
h("div", { class: "empty-art", style: { margin: "0 auto 12px" } }, icon(cat.icon)),
|
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 {
|
} else {
|
||||||
rankList.replaceChildren(...items.map(row));
|
rankList.replaceChildren(...items.map(row));
|
||||||
}
|
}
|
||||||
@@ -359,8 +365,11 @@ function openItemForm({ item, result }) {
|
|||||||
type: "url", placeholder: "Paste an image URL",
|
type: "url", placeholder: "Paste an image URL",
|
||||||
value: editing ? (item.imageSource && item.imageSource !== "upload" ? item.imageSource : "") : (src.imageUrl || ""),
|
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", () => {
|
imageUrl.addEventListener("change", () => {
|
||||||
clearImage = false;
|
clearImage = false;
|
||||||
|
pendingFile = null;
|
||||||
preview.replaceChildren(artBox(imageUrl.value.trim()));
|
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 });
|
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];
|
const file = fileInput.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (!editing) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
@@ -392,12 +405,14 @@ function openItemForm({ item, result }) {
|
|||||||
});
|
});
|
||||||
const removeArt = h("button", {
|
const removeArt = h("button", {
|
||||||
type: "button", class: "btn btn--sm btn--ghost",
|
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");
|
}, icon("x"), "Remove");
|
||||||
|
|
||||||
const artHint = cat.key === "theater"
|
const artHint = cat.key === "theater"
|
||||||
? "Tip: use a poster from the specific production (a tour's key art, a revival's Playbill…)."
|
? "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 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}`);
|
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("div", { class: "art-edit-actions" },
|
||||||
h("span", { class: "field-label" }, "Artwork"),
|
h("span", { class: "field-label" }, "Artwork"),
|
||||||
imageUrl,
|
imageUrl,
|
||||||
h("div", { class: "row-btns" }, editing && uploadBtn, removeArt, fileInput),
|
h("div", { class: "row-btns" }, uploadBtn, removeArt, fileInput),
|
||||||
h("span", { class: "field-hint" }, artHint),
|
h("span", { class: "field-hint" }, artHint),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -462,6 +477,16 @@ function openItemForm({ item, result }) {
|
|||||||
provider: result?.provider || "", providerId: result?.providerId || "", details: !!result,
|
provider: result?.provider || "", providerId: result?.providerId || "", details: !!result,
|
||||||
});
|
});
|
||||||
const created = await createItem(body);
|
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();
|
if (created) dlg.close();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function card(list) {
|
|||||||
const pct = Math.min(100, Math.round((list.itemCount / list.maxItems) * 100));
|
const pct = Math.min(100, Math.round((list.itemCount / list.maxItems) * 100));
|
||||||
const imgs = covers[list.id] || [];
|
const imgs = covers[list.id] || [];
|
||||||
const art = imgs.length
|
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}")` } })))
|
imgs.map((src) => h("span", { style: { backgroundImage: `url("${src}")` } })))
|
||||||
: h("span", { class: "shelf-icon" }, icon(cat.icon));
|
: h("span", { class: "shelf-icon" }, icon(cat.icon));
|
||||||
return h("li", { class: "list-card", "data-id": list.id },
|
return h("li", { class: "list-card", "data-id": list.id },
|
||||||
|
|||||||
@@ -306,8 +306,8 @@ function integrationsSection() {
|
|||||||
ready: () => true,
|
ready: () => true,
|
||||||
}),
|
}),
|
||||||
integration({
|
integration({
|
||||||
title: "Theater · Wikipedia", iconName: "masks", category: "theater",
|
title: "Theater & Custom · 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.",
|
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,
|
ready: () => true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -158,3 +158,10 @@ export function storageGet(key) {
|
|||||||
export function storageSet(key, val) {
|
export function storageSet(key, val) {
|
||||||
try { localStorage.setItem(key, val); } catch { /* private mode */ }
|
try { localStorage.setItem(key, val); } catch { /* private mode */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close <details class="menu"> dropdowns when clicking elsewhere or picking an item.
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
for (const d of document.querySelectorAll("details.menu[open]")) {
|
||||||
|
if (!d.contains(e.target) || e.target.closest(".menu-pop a")) d.open = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -11,7 +11,8 @@ function sizeHint(n, limits) {
|
|||||||
return { text: `Up to ${fmt.format(limits.max)} allowed; ${limits.recommended} or fewer recommended.`, cls: "" };
|
return { text: `Up to ${fmt.format(limits.max)} allowed; ${limits.recommended} or fewer recommended.`, cls: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoTitle = (n, cat) => `Top ${n} ${cat.label}`;
|
// Custom lists have no sensible default name; the owner names them.
|
||||||
|
const autoTitle = (n, cat) => (cat.key === "custom" ? "" : `Top ${n} ${cat.label}`);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {object} o
|
* @param {object} o
|
||||||
@@ -32,18 +33,29 @@ export function openListForm({ categories, limits, list, category, onSaved }) {
|
|||||||
let titleTouched = editing && list.title !== autoTitle(list.maxItems, catByKey[list.category]);
|
let titleTouched = editing && list.title !== autoTitle(list.maxItems, catByKey[list.category]);
|
||||||
|
|
||||||
const title = h("input", { name: "title", required: true, maxlength: 200, value: list?.title || autoTitle(state.size, catByKey[state.category]) });
|
const title = h("input", { name: "title", required: true, maxlength: 200, value: list?.title || autoTitle(state.size, catByKey[state.category]) });
|
||||||
title.addEventListener("input", () => { titleTouched = true; });
|
title.addEventListener("input", () => { titleTouched = title.value !== ""; });
|
||||||
const syncTitle = () => { if (!titleTouched) title.value = autoTitle(state.size, catByKey[state.category]); };
|
const isCustom = () => state.category === "custom";
|
||||||
|
const syncTitle = () => {
|
||||||
|
title.placeholder = isCustom() ? "e.g. Favorite Sports Teams" : "";
|
||||||
|
if (!titleTouched) title.value = autoTitle(state.size, catByKey[state.category]);
|
||||||
|
};
|
||||||
|
|
||||||
const lockedCat = editing && list.itemCount > 0;
|
const lockedCat = editing && list.itemCount > 0;
|
||||||
const catPicker = h("div", { class: "cat-picker", role: "radiogroup", "aria-label": "Category" },
|
const catPicker = h("div", { class: "cat-picker", role: "radiogroup", "aria-label": "Category" },
|
||||||
categories.map((c) => h("label", { class: "cat-option" },
|
categories.map((c) => h("label", { class: "cat-option" + (c.key === "custom" ? " cat-option--wide" : "") },
|
||||||
h("input", {
|
h("input", {
|
||||||
type: "radio", name: "category", value: c.key, checked: c.key === state.category,
|
type: "radio", name: "category", value: c.key, checked: c.key === state.category,
|
||||||
disabled: lockedCat && c.key !== state.category,
|
disabled: lockedCat && c.key !== state.category,
|
||||||
onchange: () => { state.category = c.key; syncTitle(); },
|
onchange: () => {
|
||||||
|
state.category = c.key;
|
||||||
|
syncTitle();
|
||||||
|
artField.hidden = !isCustom();
|
||||||
|
if (isCustom() && !title.value) title.focus();
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
h("span", {}, icon(c.icon), c.label),
|
c.key === "custom"
|
||||||
|
? h("span", {}, icon(c.icon), h("b", {}, c.label), h("small", { class: "muted" }, "Anything else: sports teams, drinks, restaurants…"))
|
||||||
|
: h("span", {}, icon(c.icon), c.label),
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -61,6 +73,7 @@ export function openListForm({ categories, limits, list, category, onSaved }) {
|
|||||||
};
|
};
|
||||||
sizeInput.addEventListener("input", () => { state.size = Number(sizeInput.value); renderSize(); syncTitle(); });
|
sizeInput.addEventListener("input", () => { state.size = Number(sizeInput.value); renderSize(); syncTitle(); });
|
||||||
renderSize();
|
renderSize();
|
||||||
|
syncTitle();
|
||||||
|
|
||||||
const desc = h("textarea", { name: "description", rows: 2, maxlength: 1000, placeholder: "Optional: a line about what makes this list", value: list?.description || "" });
|
const desc = h("textarea", { name: "description", rows: 2, maxlength: 1000, placeholder: "Optional: a line about what makes this list", value: list?.description || "" });
|
||||||
const slug = editing && h("input", { name: "slug", value: list.slug, maxlength: 60, pattern: "[a-z0-9-]+", spellcheck: false });
|
const slug = editing && h("input", { name: "slug", value: list.slug, maxlength: 60, pattern: "[a-z0-9-]+", spellcheck: false });
|
||||||
@@ -69,6 +82,14 @@ export function openListForm({ categories, limits, list, category, onSaved }) {
|
|||||||
h("label", {}, h("input", { type: "radio", name: "layout", value: "grid", checked: layout === "grid" }), icon("grid"), "Artwork grid"),
|
h("label", {}, h("input", { type: "radio", name: "layout", value: "grid", checked: layout === "grid" }), icon("grid"), "Artwork grid"),
|
||||||
h("label", {}, h("input", { type: "radio", name: "layout", value: "rows", checked: layout === "rows" }), icon("rows"), "Ranked list"),
|
h("label", {}, h("input", { type: "radio", name: "layout", value: "rows", checked: layout === "rows" }), icon("rows"), "Ranked list"),
|
||||||
);
|
);
|
||||||
|
// Custom lists choose their artwork shape; built-in categories have one.
|
||||||
|
const art = list?.art || "square";
|
||||||
|
const artField = h("div", { class: "field", hidden: !isCustom() },
|
||||||
|
h("span", { class: "field-label" }, "Artwork shape"),
|
||||||
|
h("div", { class: "radio-cards" },
|
||||||
|
h("label", {}, h("input", { type: "radio", name: "art", value: "square", checked: art === "square" }), "Square (logos, covers)"),
|
||||||
|
h("label", {}, h("input", { type: "radio", name: "art", value: "poster", checked: art === "poster" }), "Tall (posters, photos)"),
|
||||||
|
));
|
||||||
const visible = h("input", { type: "checkbox", name: "visible", checked: list ? list.visible : true });
|
const visible = h("input", { type: "checkbox", name: "visible", checked: list ? list.visible : true });
|
||||||
const error = h("p", { class: "alert alert--error", role: "alert", hidden: true });
|
const error = h("p", { class: "alert alert--error", role: "alert", hidden: true });
|
||||||
const save = h("button", { type: "submit", class: "btn btn--primary" }, editing ? "Save changes" : "Create list");
|
const save = h("button", { type: "submit", class: "btn btn--primary" }, editing ? "Save changes" : "Create list");
|
||||||
@@ -86,6 +107,7 @@ export function openListForm({ categories, limits, list, category, onSaved }) {
|
|||||||
editing && h("label", { class: "field" }, h("span", { class: "field-label" }, "Address"),
|
editing && h("label", { class: "field" }, h("span", { class: "field-label" }, "Address"),
|
||||||
h("div", { class: "input-group", style: { alignItems: "center" } }, h("span", { class: "muted" }, "/l/"), slug)),
|
h("div", { class: "input-group", style: { alignItems: "center" } }, h("span", { class: "muted" }, "/l/"), slug)),
|
||||||
h("div", { class: "field" }, h("span", { class: "field-label" }, "Layout"), layoutGroup),
|
h("div", { class: "field" }, h("span", { class: "field-label" }, "Layout"), layoutGroup),
|
||||||
|
artField,
|
||||||
h("label", { class: "check" }, visible, "Show this list on my public page"),
|
h("label", { class: "check" }, visible, "Show this list on my public page"),
|
||||||
error,
|
error,
|
||||||
),
|
),
|
||||||
@@ -98,6 +120,12 @@ export function openListForm({ categories, limits, list, category, onSaved }) {
|
|||||||
form.addEventListener("submit", async (e) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
error.hidden = true;
|
error.hidden = true;
|
||||||
|
if (!title.value.trim()) {
|
||||||
|
error.hidden = false;
|
||||||
|
error.textContent = "Give your list a name.";
|
||||||
|
title.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const n = Number(sizeInput.value);
|
const n = Number(sizeInput.value);
|
||||||
if (!Number.isInteger(n) || n < 1 || n > limits.max) {
|
if (!Number.isInteger(n) || n < 1 || n > limits.max) {
|
||||||
error.hidden = false;
|
error.hidden = false;
|
||||||
@@ -111,6 +139,7 @@ export function openListForm({ categories, limits, list, category, onSaved }) {
|
|||||||
description: desc.value,
|
description: desc.value,
|
||||||
layout: form.querySelector("input[name=layout]:checked").value,
|
layout: form.querySelector("input[name=layout]:checked").value,
|
||||||
visible: visible.checked,
|
visible: visible.checked,
|
||||||
|
art: isCustom() ? form.querySelector("input[name=art]:checked").value : "",
|
||||||
};
|
};
|
||||||
if (editing) body.slug = slug.value;
|
if (editing) body.slug = slug.value;
|
||||||
busy(save, true);
|
busy(save, true);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="admin-head-actions">
|
<div class="admin-head-actions">
|
||||||
<a class="btn btn--ghost" data-bind="public" href="/l/{{.Data.List.Slug}}">{{icon "external"}}View</a>
|
<a class="btn btn--ghost" data-bind="public" href="/l/{{.Data.List.Slug}}">{{icon "external"}}View</a>
|
||||||
|
{{template "export" .Data.List.Slug}}
|
||||||
<button class="btn" type="button" data-action="edit-list">{{icon "settings"}}List settings</button>
|
<button class="btn" type="button" data-action="edit-list">{{icon "settings"}}List settings</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -21,12 +22,12 @@
|
|||||||
<div class="editor">
|
<div class="editor">
|
||||||
<section class="panel editor-search" aria-labelledby="add-h">
|
<section class="panel editor-search" aria-labelledby="add-h">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2 id="add-h">Add {{$c.Label}}</h2>
|
<h2 id="add-h">{{if eq $c.Key "custom"}}Add favorites{{else}}Add {{$c.Label}}{{end}}</h2>
|
||||||
<span class="muted small" data-bind="provider"></span>
|
<span class="muted small" data-bind="provider"></span>
|
||||||
</div>
|
</div>
|
||||||
<form class="search-bar" data-search role="search">
|
<form class="search-bar" data-search role="search">
|
||||||
{{icon "search"}}
|
{{icon "search"}}
|
||||||
<input type="search" name="q" placeholder="Search for a {{$c.Singular}}…" autocomplete="off" aria-label="Search">
|
<input type="search" name="q" placeholder="{{if eq $c.Key "custom"}}Search Wikipedia (optional)…{{else}}Search for a {{$c.Singular}}…{{end}}" autocomplete="off" aria-label="Search">
|
||||||
<span class="spinner" hidden></span>
|
<span class="spinner" hidden></span>
|
||||||
</form>
|
</form>
|
||||||
<div class="search-results" data-results aria-live="polite"></div>
|
<div class="search-results" data-results aria-live="polite"></div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<a class="back-link" href="/">{{icon "arrow-left"}}All lists</a>
|
<a class="back-link" href="/">{{icon "arrow-left"}}All lists</a>
|
||||||
<div class="list-hero-row">
|
<div class="list-hero-row">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{icon .Category.Icon}}{{.Category.Label}}</p>
|
{{if ne .Category.Key "custom"}}<p class="eyebrow">{{icon .Category.Icon}}{{.Category.Label}}</p>{{end}}
|
||||||
<h1 class="list-title">{{.List.Title}}</h1>
|
<h1 class="list-title">{{.List.Title}}</h1>
|
||||||
{{with .List.Description}}<p class="list-desc">{{.}}</p>{{end}}
|
{{with .List.Description}}<p class="list-desc">{{.}}</p>{{end}}
|
||||||
<p class="list-meta">
|
<p class="list-meta">
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-actions">
|
<div class="list-actions">
|
||||||
{{if $.LoggedIn}}<a class="btn btn--ghost" href="/admin/lists/{{.List.ID}}">{{icon "pencil"}}Edit</a>{{end}}
|
{{if $.LoggedIn}}<a class="btn btn--ghost" href="/admin/lists/{{.List.ID}}">{{icon "pencil"}}Edit</a>{{if .Items}}{{template "export" .List.Slug}}{{end}}{{end}}
|
||||||
{{if .Items}}
|
{{if .Items}}
|
||||||
<div class="segmented" role="group" aria-label="Layout" data-layout-toggle data-default="{{.List.Layout}}" data-key="{{.List.Slug}}">
|
<div class="segmented" role="group" aria-label="Layout" data-layout-toggle data-default="{{.List.Layout}}" data-key="{{.List.Slug}}">
|
||||||
<button type="button" data-layout="grid" title="Grid">{{icon "grid"}}<span class="sr-only">Grid</span></button>
|
<button type="button" data-layout="grid" title="Grid">{{icon "grid"}}<span class="sr-only">Grid</span></button>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{{define "export"}}
|
||||||
|
<details class="menu">
|
||||||
|
<summary class="btn btn--ghost">{{icon "download"}}Export</summary>
|
||||||
|
<div class="menu-pop" role="menu">
|
||||||
|
<a role="menuitem" href="/l/{{.}}/export?format=csv" data-bind="export-csv" download>Titles as CSV <span class="muted">.csv</span></a>
|
||||||
|
<a role="menuitem" href="/l/{{.}}/export?format=txt" data-bind="export-txt" download>Titles as text <span class="muted">.txt</span></a>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
Reference in new issue
Block a user