Allow empty places in rankings; rebuild the list editor

Entries now sit at places 1…max with gaps allowed (#28, nothing at #29,
#30). Moving onto an empty place just puts the entry there; onto a taken
place, the entries in between shift one step, stopping at the first
empty place, so lists without gaps reorder exactly as before. Inserts
push down only to the next gap; deleting can close up or leave the place
empty. New endpoint POST /api/lists/{id}/places inserts or removes empty
places or closes every gap; move and delete reply with every entry's
place. The podium shows an empty step for an empty #2 or #3, and a list
can't shrink below its last used place.

The editor loads every entry (no paging) and applies edits instantly
with the same rules (places.js mirrors places.go), queueing saves in the
background and reconciling with the server's reply. Type a place and
press Enter (↑/↓ move between boxes, Alt+↑/↓ moves the entry), drag, or
use the row menu. Empty runs show as rows with Add here and Remove, and
a toolbar shows the gap count, Close gaps and a Go to # box. Clicking an
entry opens it for editing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-08 14:24:00 -07:00
1 parent bf915c04db
commit 88373176f2
15 files changed
+1090 -233

No files matched your search

+11
View File
@@ -131,6 +131,17 @@ A list can hold 1 to 65,535 entries. The editor recommends **100 or fewer** and
To share with friends and family without being fully public, set a password under **Settings → Privacy → Site password**. Visitors then see a password prompt over a blurred placeholder; none of your lists, images or profile are sent until they enter it. Once they do, that browser stays unlocked for 90 days. Changing the password asks everyone for the new one, and you never need it while signed in. A protected site also tells search engines not to index it. To share with friends and family without being fully public, set a password under **Settings → Privacy → Site password**. Visitors then see a password prompt over a blurred placeholder; none of your lists, images or profile are sent until they enter it. Once they do, that browser stays unlocked for 90 days. Changing the password asks everyone for the new one, and you never need it while signed in. A protected site also tells search engines not to index it.
## Ranking
Entries sit at places from #1 to the list's size, and a place can be left empty: a game can be #28 with nothing at #29 and the next at #30. In the list editor:
- **Type a place** in an entry's number box and press Enter (↑/↓ jump between boxes, Alt+↑/↓ moves the entry one place). Or drag it.
- Moving onto an **empty place** just puts it there. Moving onto a **taken place** shifts the entries in between one step, stopping at the first empty place, so entries you placed further down stay put.
- An entry's **⋯ menu** can insert an empty place above it, move it to #1 or the end, or remove it (you choose whether the entries below move up or the place stays empty).
- Empty places show as their own rows: **Add here** makes the next entry you add go there, and **Remove** closes them up. **Close gaps** renumbers everything 1, 2, 3….
Edits show instantly and save in the background.
## Importing a list ## Importing a list
**Import** in the list editor adds entries from a file, after the ones already there and in the file's order: **Import** in the list editor adds entries from a file, after the ones already there and in the file's order:
+46 -4
View File
@@ -366,11 +366,47 @@ func (s *Server) apiMoveItem(w http.ResponseWriter, r *http.Request) {
s.writeErr(w, err) s.writeErr(w, err)
return return
} }
if err := s.store.MoveItem(r.Context(), id, body.Rank); err != nil { places, err := s.store.MoveItem(r.Context(), id, body.Rank)
if err != nil {
s.writeErr(w, err) s.writeErr(w, err)
return return
} }
w.WriteHeader(http.StatusNoContent) writeJSON(w, http.StatusOK, map[string]any{"places": places})
}
// apiPlaces changes a list's empty places: insert one, remove one, or close
// every gap. It replies with where each entry now sits.
func (s *Server) apiPlaces(w http.ResponseWriter, r *http.Request) {
listID, err := pathID(r)
if err != nil {
s.writeErr(w, err)
return
}
var body struct {
Action string `json:"action"`
At int `json:"at"`
Count int `json:"count"` // remove: how many empty places
}
if err := decodeJSON(r, &body); err != nil {
s.writeErr(w, err)
return
}
var places []store.Place
switch body.Action {
case "insert":
places, err = s.store.InsertPlace(r.Context(), listID, body.At)
case "remove":
places, err = s.store.RemovePlace(r.Context(), listID, body.At, body.Count)
case "compact":
places, err = s.store.CloseGaps(r.Context(), listID)
default:
err = store.ValidationError{Msg: `action must be "insert", "remove" or "compact"`}
}
if err != nil {
s.writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"places": places})
} }
func (s *Server) apiDeleteItem(w http.ResponseWriter, r *http.Request) { func (s *Server) apiDeleteItem(w http.ResponseWriter, r *http.Request) {
@@ -379,13 +415,19 @@ func (s *Server) apiDeleteItem(w http.ResponseWriter, r *http.Request) {
s.writeErr(w, err) s.writeErr(w, err)
return return
} }
it, err := s.store.DeleteItem(r.Context(), id) // ?keep=1 leaves the place empty instead of moving the entries below up.
it, err := s.store.DeleteItem(r.Context(), id, r.URL.Query().Get("keep") != "1")
if err != nil { if err != nil {
s.writeErr(w, err) s.writeErr(w, err)
return return
} }
s.releaseImage(r.Context(), it.Image) s.releaseImage(r.Context(), it.Image)
w.WriteHeader(http.StatusNoContent) places, err := s.store.Places(r.Context(), it.ListID)
if err != nil {
s.writeErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"places": places})
} }
// ---- search ---- // ---- search ----
+24
View File
@@ -108,6 +108,30 @@ var funcs = template.FuncMap{
// everything below them. // everything below them.
"first": func(n int, items []ItemView) []ItemView { return items[:min(n, len(items))] }, "first": func(n int, items []ItemView) []ItemView { return items[:min(n, len(items))] },
"after": func(n int, items []ItemView) []ItemView { return items[min(n, len(items)):] }, "after": func(n int, items []ItemView) []ItemView { return items[min(n, len(items)):] },
// podium returns places 1–3 for the podium, nil for an empty one, up to
// the last of them that's taken.
"podium": func(items []ItemView) []*ItemView {
spots := make([]*ItemView, 0, 3)
for i := range items {
if items[i].Rank > 3 {
break
}
for len(spots) < items[i].Rank-1 {
spots = append(spots, nil)
}
spots = append(spots, &items[i])
}
return spots
},
// belowPlace returns the entries placed after #n.
"belowPlace": func(n int, items []ItemView) []ItemView {
for i, it := range items {
if it.Rank > n {
return items[i:]
}
}
return nil
},
// flatLayout is how a style shows entries that aren't featured. // flatLayout is how a style shows entries that aren't featured.
"flatLayout": func(style string) string { "flatLayout": func(style string) string {
if style == "grid" { if style == "grid" {
+1
View File
@@ -143,6 +143,7 @@ func (s *Server) Handler() http.Handler {
api("PUT /api/items/{id}", s.apiUpdateItem) api("PUT /api/items/{id}", s.apiUpdateItem)
api("POST /api/items/{id}/image", s.apiUploadItemImage) api("POST /api/items/{id}/image", s.apiUploadItemImage)
api("POST /api/items/{id}/move", s.apiMoveItem) api("POST /api/items/{id}/move", s.apiMoveItem)
api("POST /api/lists/{id}/places", s.apiPlaces)
api("POST /api/items/{id}/refresh", s.apiRefreshItem) api("POST /api/items/{id}/refresh", s.apiRefreshItem)
api("DELETE /api/items/{id}", s.apiDeleteItem) api("DELETE /api/items/{id}", s.apiDeleteItem)
api("GET /api/search", s.apiSearch) api("GET /api/search", s.apiSearch)
+29 -73
View File
@@ -110,45 +110,38 @@ func extraJSON(m map[string]string) string {
return string(b) return string(b)
} }
// AddItem inserts it into the list at rank (1-based); rank <= 0 or beyond the // AddItem puts it at place rank: an empty place takes it as-is, a taken one
// end appends. Items at or below the rank shift down by one. // pushes entries down to the next empty place. rank <= 0 adds it after the
// last entry.
func (s *Store) AddItem(ctx context.Context, it Item, rank int) (Item, error) { func (s *Store) AddItem(ctx context.Context, it Item, rank int) (Item, error) {
if err := it.normalize(); err != nil { if err := it.normalize(); err != nil {
return it, err return it, err
} }
tx, err := s.db.BeginTx(ctx, nil) pt, err := s.beginPlaces(ctx, it.ListID)
if err != nil { if err != nil {
return it, err return it, err
} }
defer tx.Rollback() defer pt.tx.Rollback()
if count := len(pt.p); count >= pt.limit {
var maxItems, count int return it, invalid("This list is full (%d of %d). Increase its size or remove an item first.", count, pt.limit)
err = tx.QueryRowContext(ctx, `SELECT max_items, (SELECT COUNT(*) FROM items WHERE list_id = lists.id) FROM lists WHERE id = ?`, it.ListID).Scan(&maxItems, &count)
if errors.Is(err, sql.ErrNoRows) {
return it, ErrNotFound
} }
if err != nil { if rank <= 0 {
rank = pt.p.appendPlace(pt.limit)
} else if err := pt.checkPlace(rank); err != nil {
return it, err
} else if err := pt.p.makeRoom(rank, pt.limit); err != nil {
return it, err return it, err
} }
if count >= maxItems { if err := pt.write(ctx); err != nil {
return it, invalid("This list is full (%d of %d). Increase its size or remove an item first.", count, maxItems)
}
if rank <= 0 || rank > count+1 {
rank = count + 1
}
if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = rank + 1 WHERE list_id = ? AND rank >= ?`, it.ListID, rank); err != nil {
return it, err return it, err
} }
res, err := tx.ExecContext(ctx, `INSERT INTO items (list_id, rank, title, subtitle, year, image, image_source, image_custom, provider, provider_id, external_url, description, note, extra) res, err := pt.tx.ExecContext(ctx, `INSERT INTO items (list_id, rank, title, subtitle, year, image, image_source, image_custom, provider, provider_id, external_url, description, note, extra)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
it.ListID, rank, it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.CustomArt, it.Provider, it.ProviderID, it.ExternalURL, it.Description, it.Note, extraJSON(it.Extra)) it.ListID, rank, it.Title, it.Subtitle, it.Year, it.Image, it.ImageSource, it.CustomArt, it.Provider, it.ProviderID, it.ExternalURL, it.Description, it.Note, extraJSON(it.Extra))
if err != nil { if err != nil {
return it, err return it, err
} }
if err := touchList(ctx, tx, it.ListID); err != nil { if err := pt.tx.Commit(); err != nil {
return it, err
}
if err := tx.Commit(); err != nil {
return it, err return it, err
} }
id, _ := res.LastInsertId() id, _ := res.LastInsertId()
@@ -175,68 +168,31 @@ func (s *Store) UpdateItem(ctx context.Context, it Item) (Item, error) {
return cur, err return cur, err
} }
// MoveItem moves an item to a new 1-based rank, shifting the items between. // DeleteItem removes an entry. With closeUp, the entries right below it
func (s *Store) MoveItem(ctx context.Context, id int64, newRank int) error { // move up one place, as far as the next empty place; otherwise its place is
tx, err := s.db.BeginTx(ctx, nil) // left empty.
if err != nil { func (s *Store) DeleteItem(ctx context.Context, id int64, closeUp bool) (Item, error) {
return err
}
defer tx.Rollback()
var listID int64
var oldRank, count int
err = tx.QueryRowContext(ctx, `SELECT list_id, rank, (SELECT COUNT(*) FROM items x WHERE x.list_id = items.list_id) FROM items WHERE id = ?`, id).Scan(&listID, &oldRank, &count)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if newRank < 1 {
newRank = 1
}
if newRank > count {
newRank = count
}
if newRank == oldRank {
return nil
}
if newRank < oldRank {
_, err = tx.ExecContext(ctx, `UPDATE items SET rank = rank + 1 WHERE list_id = ? AND rank >= ? AND rank < ?`, listID, newRank, oldRank)
} else {
_, err = tx.ExecContext(ctx, `UPDATE items SET rank = rank - 1 WHERE list_id = ? AND rank > ? AND rank <= ?`, listID, oldRank, newRank)
}
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = ? WHERE id = ?`, newRank, id); err != nil {
return err
}
if err := touchList(ctx, tx, listID); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) DeleteItem(ctx context.Context, id int64) (Item, error) {
it, err := s.ItemByID(ctx, id) it, err := s.ItemByID(ctx, id)
if err != nil { if err != nil {
return it, err return it, err
} }
tx, err := s.db.BeginTx(ctx, nil) pt, err := s.beginPlaces(ctx, it.ListID)
if err != nil { if err != nil {
return it, err return it, err
} }
defer tx.Rollback() defer pt.tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id); err != nil { if _, err := pt.tx.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id); err != nil {
return it, err return it, err
} }
if _, err := tx.ExecContext(ctx, `UPDATE items SET rank = rank - 1 WHERE list_id = ? AND rank > ?`, it.ListID, it.Rank); err != nil { delete(pt.before, id)
delete(pt.p, it.Rank)
if closeUp {
pt.p.closeUp(it.Rank)
}
if err := pt.write(ctx); err != nil {
return it, err return it, err
} }
if err := touchList(ctx, tx, it.ListID); err != nil { return it, pt.tx.Commit()
return it, err
}
return it, tx.Commit()
} }
// ImageInUse reports whether any item or setting still references a cached // ImageInUse reports whether any item or setting still references a cached
+5
View File
@@ -173,6 +173,11 @@ func (s *Store) UpdateList(ctx context.Context, l List) (List, error) {
if l.MaxItems < cur.ItemCount { if l.MaxItems < cur.ItemCount {
return l, invalid("This list already has %d items; remove some before shrinking it to %d", cur.ItemCount, l.MaxItems) return l, invalid("This list already has %d items; remove some before shrinking it to %d", cur.ItemCount, l.MaxItems)
} }
if last, err := s.MaxRank(ctx, l.ID); err != nil {
return l, err
} else if l.MaxItems < last {
return l, invalid("An entry sits at #%d; move it up or close the gaps before shrinking the list to %d", last, l.MaxItems)
}
if l.Category != cur.Category && cur.ItemCount > 0 { if l.Category != cur.Category && cur.ItemCount > 0 {
return l, invalid("Can't change the category of a list that has items") return l, invalid("Can't change the category of a list that has items")
} }
+338
View File
@@ -0,0 +1,338 @@
package store
import (
"context"
"database/sql"
"errors"
"sort"
)
// A list's entries sit at places 1…max_items, and places can be empty: an
// entry can be #28 with nothing at #29 and the next at #30. The functions
// here decide where entries go; every change is written in one transaction.
//
// The rules, so that lists without gaps behave like an ordinary ranking:
// - Moving onto an empty place just puts the entry there. Its old place
// becomes empty.
// - Moving onto a taken place shifts the entries in between one step
// toward where it came from, stopping at the first empty place.
// - Inserting (a new entry, or an empty place) pushes entries down only as
// far as the next empty place, which absorbs the shift. Entries placed
// further down stay put.
// places maps place → entry id.
type places map[int]int64
func (p places) last() int {
n := 0
for r := range p {
n = max(n, r)
}
return n
}
// shift moves the entries at places [from, to] by d (±1), walking in the
// direction that never overwrites an entry before it's moved.
func (p places) shift(from, to, d int) {
if from > to {
return
}
if d > 0 {
for r := to; r >= from; r-- {
if id, ok := p[r]; ok {
delete(p, r)
p[r+d] = id
}
}
return
}
for r := from; r <= to; r++ {
if id, ok := p[r]; ok {
delete(p, r)
p[r+d] = id
}
}
}
// makeRoom frees place at by pushing entries down to the next empty place.
func (p places) makeRoom(at, limit int) error {
if _, taken := p[at]; !taken {
return nil
}
gap := at
for {
if _, taken := p[gap]; !taken {
break
}
gap++
}
if gap > limit {
return invalid("No room: this list ends at #%d. Raise its size in List settings, or remove an empty place first.", limit)
}
p.shift(at, gap-1, +1)
return nil
}
// move takes the entry at from to place to.
func (p places) move(from, to int) {
id := p[from]
delete(p, from)
if _, taken := p[to]; taken {
if to < from {
gap := to + 1 // the nearest empty place below to; from is one
for {
if _, t := p[gap]; !t {
break
}
gap++
}
p.shift(to, gap-1, +1)
} else {
gap := to - 1 // the nearest empty place above to
for {
if _, t := p[gap]; !t {
break
}
gap--
}
p.shift(gap+1, to, -1)
}
}
p[to] = id
}
// closeUp pulls the entries right below an emptied place up by one, as far
// as the next empty place.
func (p places) closeUp(at int) {
end := at + 1
for {
if _, t := p[end]; !t {
break
}
end++
}
p.shift(at+1, end-1, -1)
}
// compact numbers the entries 1, 2, 3… in order, removing every gap.
func (p places) compact() {
ranks := make([]int, 0, len(p))
for r := range p {
ranks = append(ranks, r)
}
sort.Ints(ranks)
ids := make([]int64, len(ranks))
for i, r := range ranks {
ids[i] = p[r]
delete(p, r)
}
for i, id := range ids {
p[i+1] = id
}
}
// Place is an entry's id and place, as returned after any change.
type Place struct {
ID int64 `json:"id"`
Rank int `json:"rank"`
}
// placeTx loads a list's places inside a transaction.
type placeTx struct {
tx *sql.Tx
listID int64
limit int // max_items
before map[int64]int
p places
}
func (s *Store) beginPlaces(ctx context.Context, listID int64) (*placeTx, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
pt := &placeTx{tx: tx, listID: listID, before: map[int64]int{}, p: places{}}
err = tx.QueryRowContext(ctx, `SELECT max_items FROM lists WHERE id = ?`, listID).Scan(&pt.limit)
if errors.Is(err, sql.ErrNoRows) {
tx.Rollback()
return nil, ErrNotFound
}
if err != nil {
tx.Rollback()
return nil, err
}
rows, err := tx.QueryContext(ctx, `SELECT id, rank FROM items WHERE list_id = ?`, listID)
if err != nil {
tx.Rollback()
return nil, err
}
defer rows.Close()
for rows.Next() {
var id int64
var r int
if err := rows.Scan(&id, &r); err != nil {
tx.Rollback()
return nil, err
}
pt.before[id] = r
pt.p[r] = id
}
if err := rows.Err(); err != nil {
tx.Rollback()
return nil, err
}
return pt, nil
}
// write saves the places that changed, without committing.
func (pt *placeTx) write(ctx context.Context) error {
if last := pt.p.last(); last > pt.limit {
return invalid("No room: this list ends at #%d. Raise its size in List settings.", pt.limit)
}
stmt, err := pt.tx.PrepareContext(ctx, `UPDATE items SET rank = ? WHERE id = ?`)
if err != nil {
return err
}
defer stmt.Close()
for r, id := range pt.p {
if old, ok := pt.before[id]; ok && old != r {
if _, err := stmt.ExecContext(ctx, r, id); err != nil {
return err
}
}
}
return touchList(ctx, pt.tx, pt.listID)
}
// commit writes the places that changed and returns them all.
func (pt *placeTx) commit(ctx context.Context) ([]Place, error) {
defer pt.tx.Rollback()
if err := pt.write(ctx); err != nil {
return nil, err
}
if err := pt.tx.Commit(); err != nil {
return nil, err
}
return pt.list(), nil
}
// appendPlace is where a new entry goes when no place is asked for: after
// the last one, or the first empty place if the list ends there.
func (p places) appendPlace(limit int) int {
if last := p.last(); last < limit {
return last + 1
}
for r := 1; r <= limit; r++ {
if _, t := p[r]; !t {
return r
}
}
return limit + 1
}
func (pt *placeTx) list() []Place {
out := make([]Place, 0, len(pt.p))
for r, id := range pt.p {
out = append(out, Place{ID: id, Rank: r})
}
sort.Slice(out, func(i, j int) bool { return out[i].Rank < out[j].Rank })
return out
}
func (pt *placeTx) checkPlace(at int) error {
if at < 1 || at > pt.limit {
return invalid("Pick a place from #1 to #%d.", pt.limit)
}
return nil
}
// Places returns where each of a list's entries sits.
func (s *Store) Places(ctx context.Context, listID int64) ([]Place, error) {
pt, err := s.beginPlaces(ctx, listID)
if err != nil {
return nil, err
}
defer pt.tx.Rollback()
return pt.list(), nil
}
// MoveItem puts an entry at place to (see the rules above) and returns the
// list's places.
func (s *Store) MoveItem(ctx context.Context, id int64, to int) ([]Place, error) {
var listID int64
if err := s.db.QueryRowContext(ctx, `SELECT list_id FROM items WHERE id = ?`, id).Scan(&listID); errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
} else if err != nil {
return nil, err
}
pt, err := s.beginPlaces(ctx, listID)
if err != nil {
return nil, err
}
defer pt.tx.Rollback()
if err := pt.checkPlace(to); err != nil {
return nil, err
}
if from := pt.before[id]; from != to {
pt.p.move(from, to)
}
return pt.commit(ctx)
}
// InsertPlace opens an empty place at at, pushing entries down to the next
// empty place.
func (s *Store) InsertPlace(ctx context.Context, listID int64, at int) ([]Place, error) {
pt, err := s.beginPlaces(ctx, listID)
if err != nil {
return nil, err
}
defer pt.tx.Rollback()
if err := pt.checkPlace(at); err != nil {
return nil, err
}
if err := pt.p.makeRoom(at, pt.limit); err != nil {
return nil, err
}
return pt.commit(ctx)
}
// RemovePlace deletes count empty places starting at at: everything below
// moves up by count.
func (s *Store) RemovePlace(ctx context.Context, listID int64, at, count int) ([]Place, error) {
pt, err := s.beginPlaces(ctx, listID)
if err != nil {
return nil, err
}
defer pt.tx.Rollback()
if err := pt.checkPlace(at); err != nil {
return nil, err
}
count = max(count, 1)
for r := at; r < at+count; r++ {
if _, taken := pt.p[r]; taken {
return nil, invalid("#%d isn't empty.", r)
}
}
for range count {
pt.p.shift(at+1, pt.p.last(), -1)
}
return pt.commit(ctx)
}
// CloseGaps renumbers a list's entries 1, 2, 3… keeping their order.
func (s *Store) CloseGaps(ctx context.Context, listID int64) ([]Place, error) {
pt, err := s.beginPlaces(ctx, listID)
if err != nil {
return nil, err
}
defer pt.tx.Rollback()
pt.p.compact()
return pt.commit(ctx)
}
// MaxRank is the highest place used in a list, 0 when it's empty.
func (s *Store) MaxRank(ctx context.Context, listID int64) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(rank), 0) FROM items WHERE list_id = ?`, listID).Scan(&n)
return n, err
}
+131 -4
View File
@@ -79,11 +79,11 @@ func TestRanking(t *testing.T) {
}{ }{
{"A", 5, "E,B,C,D,A"}, {"A", 5, "E,B,C,D,A"},
{"D", 1, "D,E,B,C,A"}, {"D", 1, "D,E,B,C,A"},
{"B", 99, "D,E,C,A,B"}, // clamped to the end {"B", 5, "D,E,C,A,B"},
{"C", 0, "C,D,E,A,B"}, // clamped to the top {"C", 1, "C,D,E,A,B"},
} }
for _, st := range steps { for _, st := range steps {
if err := s.MoveItem(ctx, ids[st.id], st.rank); err != nil { if _, err := s.MoveItem(ctx, ids[st.id], st.rank); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := titles(t, s, l.ID); got != st.want { if got := titles(t, s, l.ID); got != st.want {
@@ -91,7 +91,12 @@ func TestRanking(t *testing.T) {
} }
} }
if _, err := s.DeleteItem(ctx, ids["E"]); err != nil { for _, bad := range []int{0, 6} {
if _, err := s.MoveItem(ctx, ids["A"], bad); !errors.As(err, &ve) {
t.Fatalf("move to %d: expected an error, got %v", bad, err)
}
}
if _, err := s.DeleteItem(ctx, ids["E"], true); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := titles(t, s, l.ID); got != "C,D,A,B" { if got := titles(t, s, l.ID); got != "C,D,A,B" {
@@ -207,3 +212,125 @@ func TestAdoptLegacyDB(t *testing.T) {
t.Errorf("topdeck.db should have been renamed") t.Errorf("topdeck.db should have been renamed")
} }
} }
// layout shows a list's places, "-" for an empty one: "A,-,B".
func layout(t *testing.T, s *Store, listID int64) string {
t.Helper()
items, err := s.Items(context.Background(), listID, 0, 0)
if err != nil {
t.Fatal(err)
}
var out []string
next := 1
for _, it := range items {
for ; next < it.Rank; next++ {
out = append(out, "-")
}
out = append(out, it.Title)
next = it.Rank + 1
}
return strings.Join(out, ",")
}
func TestPlaces(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
l, err := s.CreateList(ctx, List{Title: "Games", Category: "game", MaxItems: 8})
if err != nil {
t.Fatal(err)
}
ids := map[string]int64{}
add := func(title string, rank int) {
t.Helper()
it, err := s.AddItem(ctx, Item{ListID: l.ID, Title: title}, rank)
if err != nil {
t.Fatal(err)
}
ids[title] = it.ID
}
check := func(what, want string) {
t.Helper()
if got := layout(t, s, l.ID); got != want {
t.Fatalf("%s: got %s, want %s", what, got, want)
}
}
add("A", 0)
add("B", 0)
add("C", 4) // an empty place
check("add at an empty place", "A,B,-,C")
add("D", 0)
check("append goes after the last", "A,B,-,C,D")
add("E", 1)
check("insert pushes down to the gap only", "E,A,B,C,D")
mustMove := func(title string, to int, want string) {
t.Helper()
if _, err := s.MoveItem(ctx, ids[title], to); err != nil {
t.Fatal(err)
}
check(fmt.Sprintf("move %s to %d", title, to), want)
}
mustMove("D", 7, "E,A,B,C,-,-,D") // onto an empty place
mustMove("E", 2, "A,E,B,C,-,-,D") // ordinary move down
mustMove("C", 1, "C,A,E,B,-,-,D") // ordinary move up
mustMove("B", 7, "C,A,E,-,-,D,B") // onto a taken place: D steps up into the gap
mustMove("D", 2, "C,D,A,E,-,-,B") // move up: shifts stop where D left
mustMove("A", 6, "C,D,-,E,-,A,B") // empty place: A's old place stays empty
if _, err := s.InsertPlace(ctx, l.ID, 2); err != nil {
t.Fatal(err)
}
check("insert an empty place", "C,-,D,E,-,A,B") // D moves into the gap; E stays
if _, err := s.RemovePlace(ctx, l.ID, 2, 1); err != nil {
t.Fatal(err)
}
check("remove an empty place", "C,D,E,-,A,B")
var ve ValidationError
if _, err := s.RemovePlace(ctx, l.ID, 1, 1); !errors.As(err, &ve) {
t.Fatalf("removing a taken place: %v", err)
}
if _, err := s.DeleteItem(ctx, ids["E"], false); err != nil {
t.Fatal(err)
}
check("delete, keep the place", "C,D,-,-,A,B")
if _, err := s.DeleteItem(ctx, ids["C"], true); err != nil {
t.Fatal(err)
}
check("delete, close up to the gap", "D,-,-,-,A,B")
if _, err := s.RemovePlace(ctx, l.ID, 2, 2); err != nil {
t.Fatal(err)
}
check("remove two empty places", "D,-,A,B")
if _, err := s.InsertPlace(ctx, l.ID, 2); err != nil {
t.Fatal(err)
}
if _, err := s.InsertPlace(ctx, l.ID, 2); err != nil {
t.Fatal(err)
}
check("insert into a gap does nothing to the entries", "D,-,A,B")
if _, err := s.InsertPlace(ctx, l.ID, 3); err != nil { // above A at #3
t.Fatal(err)
}
if _, err := s.InsertPlace(ctx, l.ID, 4); err != nil { // above A, now at #4
t.Fatal(err)
}
check("insert above an entry", "D,-,-,-,A,B")
if _, err := s.UpdateList(ctx, List{ID: l.ID, Title: "Games", Category: "game", MaxItems: 5}); !errors.As(err, &ve) {
t.Fatalf("shrinking below the last place: %v", err)
}
add("F", 6)
add("G", 0)
check("fill to the end", "D,-,-,-,A,F,B,G")
add("H", 0)
check("append into the first gap once the end is reached", "D,H,-,-,A,F,B,G")
if _, err := s.AddItem(ctx, Item{ListID: l.ID, Title: "J"}, 5); !errors.As(err, &ve) {
t.Fatalf("no room to push down: %v", err)
}
if _, err := s.CloseGaps(ctx, l.ID); err != nil {
t.Fatal(err)
}
check("close gaps", "D,H,A,F,B,G")
}
+55 -13
View File
@@ -334,6 +334,8 @@ code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; bac
.podium-spot--1 .podium-step { height: 86px; font-size: 40px; } .podium-spot--1 .podium-step { height: 86px; font-size: 40px; }
.podium-spot--2 .podium-step { height: 60px; } .podium-spot--2 .podium-step { height: 60px; }
.podium-spot--3 .podium-step { height: 42px; font-size: 26px; } .podium-spot--3 .podium-step { height: 42px; font-size: 26px; }
/* A podium place left empty: just a faint step. */
.podium-spot--empty .podium-step { opacity: 0.35; border-top-style: dashed; }
.podium + .more, .podium + .more-all { margin-top: 0; border-top: 1px solid var(--border); } .podium + .more, .podium + .more-all { margin-top: 0; border-top: 1px solid var(--border); }
@media (max-width: 640px) { @media (max-width: 640px) {
/* #1 on its own row, #2 and #3 side by side beneath it. */ /* #1 on its own row, #2 and #3 side by side beneath it. */
@@ -720,35 +722,75 @@ input[type="checkbox"] { accent-color: var(--accent); width: 18px; height: 18px;
.rank-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; } .rank-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.rank-row { .rank-row {
display: grid; grid-template-columns: 28px 64px 44px 1fr auto; align-items: center; gap: 10px; display: grid; grid-template-columns: 26px 56px 40px 1fr auto; align-items: center; gap: 8px;
padding: 8px 6px; border-radius: var(--radius); background: var(--surface); padding: 5px 6px; border-radius: var(--radius); background: var(--surface);
border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
} }
.rank-row:hover { background: var(--surface-2); } .rank-row:hover { background: var(--surface-2); }
.rank-row:focus-within { background: var(--surface-2); }
.rank-row.is-dragging { position: relative; z-index: 5; box-shadow: var(--shadow); background: var(--surface-2); border-color: var(--accent); opacity: 0.96; } .rank-row.is-dragging { position: relative; z-index: 5; box-shadow: var(--shadow); background: var(--surface-2); border-color: var(--accent); opacity: 0.96; }
.rank-row.is-placeholder { background: var(--accent-soft); border: 1px dashed var(--accent); } .rank-row.is-placeholder, .rank-gap.is-placeholder { background: var(--accent-soft); border: 1px dashed var(--accent); }
.rank-row.is-placeholder > * { visibility: hidden; } .rank-row.is-placeholder > * { visibility: hidden; }
.rank-row.is-flash { animation: flash 1.2s ease-out; } .rank-row.is-flash { animation: flash 1.2s ease-out; }
@keyframes flash { from { background: var(--accent-soft); } } @keyframes flash { from { background: var(--accent-soft); } }
.drag-handle { display: grid; place-items: center; width: 28px; height: 36px; color: var(--faint); cursor: grab; touch-action: none; border: 0; background: none; border-radius: 6px; } .drag-handle { display: grid; place-items: center; width: 26px; height: 34px; color: var(--faint); cursor: grab; touch-action: none; border: 0; background: none; border-radius: 6px; }
.drag-handle:hover { color: var(--text); background: var(--surface-2); } .drag-handle:hover { color: var(--text); background: var(--surface-2); }
.dragging-active, .dragging-active * { cursor: grabbing !important; user-select: none; } .dragging-active, .dragging-active * { cursor: grabbing !important; user-select: none; }
.rank-input { .rank-input {
width: 64px !important; height: 34px !important; padding: 0 6px !important; text-align: center; width: 56px !important; height: 32px !important; padding: 0 4px !important; text-align: center;
font-weight: 750; font-variant-numeric: tabular-nums; background: transparent !important; border-color: transparent !important; font-weight: 750; font-variant-numeric: tabular-nums; background: transparent !important; border-color: transparent !important;
} }
.rank-input:hover { border-color: var(--border) !important; } .rank-input:hover { border-color: var(--border) !important; }
.rank-input:focus { background: var(--bg) !important; border-color: var(--accent) !important; } .rank-input:focus { background: var(--bg) !important; border-color: var(--accent) !important; }
.rank-row .result-art { width: 44px; } .rank-row.is-top .rank-input { color: var(--accent); }
.rank-row-body { min-width: 0; } .rank-row .result-art { width: 40px; }
.rank-row-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .rank-row-body {
.rank-row-sub { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; gap: 8px; align-items: center; } all: unset; box-sizing: border-box; display: flex; flex-direction: column; gap: 1px; min-width: 0;
.rank-row-actions { display: flex; gap: 0; } cursor: pointer; border-radius: 6px; padding: 2px 4px;
}
.rank-row-body:focus-visible { outline: 2px solid var(--accent); }
.rank-row-title { font-weight: 600; font-size: 14.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rank-row-sub { color: var(--muted); font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; gap: 8px; align-items: center; }
.rank-row-sub .icon { width: 13px; height: 13px; }
.rank-row-actions { display: flex; gap: 0; align-items: center; }
.row-menu .menu-pop { right: 0; left: auto; min-width: 230px; z-index: 30; }
.menu-pop button {
display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: 0; border-radius: 8px;
background: none; color: var(--text); font-size: 14px; text-align: left; cursor: pointer;
}
.menu-pop button:hover { background: var(--surface-2); }
.menu-pop button .icon { width: 16px; height: 16px; color: var(--muted); flex: none; }
.menu-pop button.is-danger, .menu-pop button.is-danger .icon { color: var(--danger); }
/* A run of empty places. */
.rank-gap {
display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap;
margin: 3px 0; padding: 4px 8px 4px 40px; min-height: 40px;
border: 1px dashed var(--border); border-radius: var(--radius); color: var(--faint); font-size: 13px;
}
.rank-gap b { color: var(--muted); font-variant-numeric: tabular-nums; margin-right: 4px; }
.rank-gap.is-target { border-color: var(--accent); background: var(--accent-soft); color: var(--text); }
.rank-gap-actions { display: flex; gap: 2px; }
.rank-gap-actions .btn { height: 28px; }
.rank-tools { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
.rank-tools .spacer { flex: 1; }
.rank-tools .rank-jump { width: 96px; height: 30px; font-size: 13px; }
/* "Adding at #29" above the search box. */
.add-target {
display: flex; align-items: center; gap: 8px; padding: 6px 6px 6px 12px; margin-bottom: 8px;
border-radius: var(--radius); background: var(--accent-soft); color: var(--text); font-size: 14px;
}
.add-target > .icon { width: 16px; height: 16px; color: var(--accent); }
.add-target span { flex: 1; }
@media (max-width: 640px) { @media (max-width: 640px) {
.rank-row { grid-template-columns: 24px 50px 38px 1fr auto; gap: 6px; } .rank-row { grid-template-columns: 22px 46px 36px 1fr auto; gap: 5px; }
.rank-row .result-art { width: 38px; } .rank-row .result-art { width: 36px; }
.rank-input { width: 50px !important; } .rank-input { width: 46px !important; }
.rank-row-actions .hide-sm { display: none; } .rank-row-actions .hide-sm { display: none; }
.rank-gap { padding-left: 10px; }
} }
.rank-empty { text-align: center; padding: 40px 16px; color: var(--muted); } .rank-empty { text-align: center; padding: 40px 16px; color: var(--muted); }
+349 -132
View File
@@ -1,6 +1,7 @@
// List editor: search providers, add entries, rank and edit them. // List editor: search providers, add entries, rank and edit them.
import { h, icon, api, readBoot, toast, confirmDialog, chooseDialog, openDialog, dialogHead, debounce, busy, fmt } from "./common.js"; import { h, icon, api, readBoot, toast, confirmDialog, chooseDialog, openDialog, dialogHead, debounce, busy, fmt } from "./common.js";
import { sortable } from "./sortable.js"; import { sortable } from "./sortable.js";
import { Places } from "./places.js";
import { openListForm } from "./list-form.js"; import { openListForm } from "./list-form.js";
import { previewButton } from "./player.js"; import { previewButton } from "./player.js";
import { openImport } from "./import.js"; import { openImport } from "./import.js";
@@ -8,15 +9,12 @@ import { openImport } from "./import.js";
const boot = readBoot(); const boot = readBoot();
const { categories, limits, productions } = boot; const { categories, limits, productions } = boot;
const cat = boot.category; const cat = boot.category;
const pageSize = boot.pageSize;
let list = boot.list; let list = boot.list;
let items = []; let items = [];
let page = 1;
const square = cat.aspect === "square"; const square = cat.aspect === "square";
const $ = (sel) => document.querySelector(sel); const $ = (sel) => document.querySelector(sel);
const rankList = $("[data-rank-list]"); const rankList = $("[data-rank-list]");
const pager = $("[data-pager]");
const results = $("[data-results]"); const results = $("[data-results]");
const searchForm = $("[data-search]"); const searchForm = $("[data-search]");
const searchInput = searchForm.querySelector("input"); const searchInput = searchForm.querySelector("input");
@@ -67,21 +65,143 @@ document.querySelector("[data-action=edit-list]").addEventListener("click", () =
}); });
}); });
// ---------- ranked items ---------- // ---------- ranking ----------
//
// Every entry is loaded at once. Edits change the page immediately (see
// places.js for the rules), then go to the server one at a time; once
// they're all done the server's answer is applied, in case it differs.
const pages = () => Math.max(1, Math.ceil(list.itemCount / pageSize)); const byId = new Map(); // id → item
const rowEls = new Map(); // id → <li>, reused so artwork doesn't reload
let places = new Places([], list.maxItems);
let addAt = 0; // place the next added entry goes to; 0 = after the last
let rendering = false;
async function load(p = page, flashId) { async function loadAll(flashId) {
page = Math.min(Math.max(1, p), pages());
try { try {
items = await api("GET", `/api/lists/${list.id}/items?offset=${(page - 1) * pageSize}&limit=${pageSize}`); items = await allItems();
} catch (err) { } catch (err) {
toast(err.message, { error: true }); toast(err.message, { error: true });
return; return;
} }
renderItems(flashId); byId.clear();
for (const it of items) byId.set(it.id, it);
for (const id of [...rowEls.keys()]) if (!byId.has(id)) rowEls.delete(id);
places = new Places(items, list.maxItems);
render(flashId);
} }
// Redraws one entry after its details changed; its place stays.
function replaceItem(fresh) {
const it = byId.get(fresh.id);
if (!it) return;
Object.assign(it, fresh, { rank: places.rankOf(fresh.id) || fresh.rank });
rowEls.delete(it.id);
render(it.id);
}
// ---- server queue ----
let queue = Promise.resolve();
let pending = 0;
function send(method, path, body) {
pending++;
queue = queue.then(() => api(method, path, body)).then((res) => {
if (--pending === 0 && res?.places) applyServer(res.places);
}, (err) => {
pending--;
toast(err.message, { error: true });
// Something didn't save: show what the server has.
loadAll();
});
}
function applyServer(serverPlaces) {
const fresh = new Places(serverPlaces, list.maxItems);
const same = fresh.at.size === places.at.size && [...fresh.at].every(([r, id]) => places.at.get(r) === id);
if (!same) {
places = fresh;
render();
}
}
// ---- edits ----
function syncRanks() {
for (const [r, id] of places.at) {
const it = byId.get(id);
if (it) it.rank = r;
}
}
function moveTo(it, to, flash = true) {
to = Math.round(to);
if (!Number.isFinite(to) || to < 1 || to > list.maxItems) {
toast(`Pick a place from #1 to #${fmt.format(list.maxItems)}.`, { error: true });
render();
return;
}
const from = places.rankOf(it.id);
if (to === from) { render(); return; }
places.move(from, to);
render(flash ? it.id : 0);
send("POST", `/api/items/${it.id}/move`, { rank: to });
}
function insertPlace(at) {
if (!places.makeRoom(at)) {
toast(`No room: this list ends at #${fmt.format(list.maxItems)}. Raise its size in List settings.`, { error: true });
return;
}
render();
send("POST", `/api/lists/${list.id}/places`, { action: "insert", at });
}
function removePlaces(at, count) {
places.removePlaces(at, count);
if (addAt >= at) addAt = 0;
render();
send("POST", `/api/lists/${list.id}/places`, { action: "remove", at, count });
}
function closeGaps() {
places.compact();
addAt = 0;
render();
send("POST", `/api/lists/${list.id}/places`, { action: "compact" });
toast("Gaps closed");
}
async function removeItem(it) {
const r = places.rankOf(it.id);
// Only ask when there's something right below that could move up.
let keep = false;
if (places.at.has(r + 1)) {
const choice = await chooseDialog({
title: `Remove “${it.title}”?`,
message: `It's #${r}. Should the entries below it move up, or should #${r} stay empty?`,
choices: [{ label: `Leave #${r} empty`, value: "keep" }, { label: "Move them up", value: "close", primary: true }],
});
if (!choice) return;
keep = choice === "keep";
} else if (!await confirmDialog({ title: "Remove from list?", message: `“${it.title}” will be removed from #${r}.`, confirm: "Remove", danger: true })) {
return;
}
places.remove(it.id, !keep);
byId.delete(it.id);
rowEls.delete(it.id);
items = items.filter((x) => x.id !== it.id);
list.itemCount--;
renderHeader();
render();
markResult(it, 0);
toast(`Removed “${it.title}”`);
send("DELETE", `/api/items/${it.id}${keep ? "?keep=1" : ""}`);
}
// ---- drawing ----
function artBox(src, alt = "") { function artBox(src, alt = "") {
const box = h("span", { class: "result-art" }); const box = h("span", { class: "result-art" });
if (src) { if (src) {
@@ -94,27 +214,73 @@ function artBox(src, alt = "") {
return box; return box;
} }
function row(it) { function itemRow(it) {
const rankInput = h("input", { let li = rowEls.get(it.id);
class: "rank-input", type: "number", min: 1, max: list.itemCount, value: it.rank, if (!li) {
"aria-label": `Rank of ${it.title}`, inputmode: "numeric", li = buildRow(it);
rowEls.set(it.id, li);
}
li.dataset.place = it.rank;
const input = li.querySelector(".rank-input");
if (!input.dataset.dirty) input.value = it.rank; // don't overwrite typing
li.classList.toggle("is-top", it.rank <= 3);
return li;
}
function buildRow(it) {
const input = h("input", {
class: "rank-input", type: "text", inputmode: "numeric", autocomplete: "off", value: it.rank,
"aria-label": `Place of ${it.title}`, title: "Type a place and press Enter. ↑/↓ go to the next entry; Alt+↑/↓ moves this one.",
}); });
const commit = () => { const commit = () => {
const n = Number(rankInput.value); // A redraw detaching the box fires blur; that isn't the owner leaving it.
if (!Number.isInteger(n) || n === it.rank) { rankInput.value = it.rank; return; } if (rendering) return;
moveTo(it, Math.min(Math.max(1, n), list.itemCount)); delete input.dataset.dirty;
const n = Number(input.value.replace(/[^0-9]/g, ""));
if (!n || n === it.rank) { input.value = it.rank; return; }
moveTo(it, n);
}; };
rankInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); rankInput.blur(); } }); input.addEventListener("input", () => { input.dataset.dirty = "1"; });
rankInput.addEventListener("change", commit); input.addEventListener("focus", () => input.select());
input.addEventListener("blur", commit);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
const next = neighbourInput(input, +1);
commit();
(next || input).focus();
} else if (e.key === "Escape") {
delete input.dataset.dirty;
input.value = it.rank;
input.blur();
} else if ((e.key === "ArrowUp" || e.key === "ArrowDown") && e.altKey) {
e.preventDefault();
delete input.dataset.dirty;
moveTo(it, it.rank + (e.key === "ArrowUp" ? -1 : 1));
input.focus();
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
neighbourInput(input, e.key === "ArrowUp" ? -1 : 1)?.focus();
}
});
const sub = [it.subtitle, it.year].filter(Boolean).join(" · "); const sub = [it.subtitle, it.year].filter(Boolean).join(" · ");
const menu = h("details", { class: "menu row-menu" },
h("summary", { class: "icon-btn", "aria-label": `More for ${it.title}`, title: "More" }, icon("chevron-down")),
h("div", { class: "menu-pop" },
h("button", { type: "button", onclick: () => insertPlace(it.rank) }, icon("plus"), "Insert an empty place above"),
h("button", { type: "button", onclick: () => moveTo(it, 1) }, icon("chevron-up"), "Move to #1"),
h("button", { type: "button", onclick: () => moveTo(it, places.last()) }, icon("chevron-down"), "Move to the end"),
canRefresh(it) && h("button", { type: "button", onclick: (e) => refreshOne(it, e.currentTarget) }, icon("refresh"), `Refresh from ${sourceName(it)}`),
h("button", { type: "button", class: "is-danger", onclick: () => removeItem(it) }, icon("trash"), "Remove"),
));
return h("li", { class: "rank-row", "data-id": it.id }, return h("li", { class: "rank-row", "data-id": it.id },
h("button", { type: "button", class: "drag-handle", "aria-label": `Drag to reorder ${it.title}, or use arrow keys` }, icon("grip")), h("button", { type: "button", class: "drag-handle", "aria-label": `Drag to reorder ${it.title}, or use the arrow keys` }, icon("grip")),
rankInput, input,
artBox(it.image ? `/images/${it.image}` : ""), artBox(it.image ? `/images/${it.image}` : ""),
h("div", { class: "rank-row-body" }, h("button", { type: "button", class: "rank-row-body", onclick: () => openItemForm({ item: it }), title: "Edit" },
h("div", { class: "rank-row-title" }, it.title), h("span", { class: "rank-row-title" }, it.title),
h("div", { class: "rank-row-sub" }, h("span", { class: "rank-row-sub" },
it.extra?.production && h("span", { class: "badge" }, it.extra.production), it.extra?.production && h("span", { class: "badge" }, it.extra.production),
sub && h("span", {}, sub), sub && h("span", {}, sub),
it.note && h("span", { title: "Has a note" }, icon("pencil")), it.note && h("span", { title: "Has a note" }, icon("pencil")),
@@ -122,15 +288,145 @@ function row(it) {
), ),
h("div", { class: "rank-row-actions" }, h("div", { class: "rank-row-actions" },
canPreview(it) && previewButton(`/preview/${it.id}`, `Play a preview of ${it.title}`, "play-btn play-btn--inline hide-sm"), canPreview(it) && previewButton(`/preview/${it.id}`, `Play a preview of ${it.title}`, "play-btn play-btn--inline hide-sm"),
h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move up", title: "Move up", disabled: it.rank === 1, onclick: () => moveTo(it, it.rank - 1) }, icon("chevron-up")),
h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move down", title: "Move down", disabled: it.rank === list.itemCount, onclick: () => moveTo(it, it.rank + 1) }, icon("chevron-down")),
canRefresh(it) && h("button", { type: "button", class: "icon-btn", "aria-label": `Refresh ${it.title} from ${sourceName(it)}`, title: `Refresh from ${sourceName(it)}`, onclick: (e) => refreshOne(it, e.currentTarget) }, icon("refresh")),
h("button", { type: "button", class: "icon-btn", "aria-label": `Edit ${it.title}`, title: "Edit", onclick: () => openItemForm({ item: it }) }, icon("pencil")), h("button", { type: "button", class: "icon-btn", "aria-label": `Edit ${it.title}`, title: "Edit", onclick: () => openItemForm({ item: it }) }, icon("pencil")),
h("button", { type: "button", class: "icon-btn icon-btn--danger", "aria-label": `Remove ${it.title}`, title: "Remove", onclick: () => removeItem(it) }, icon("trash")), menu,
), ),
); );
} }
function neighbourInput(input, dir) {
const all = [...rankList.querySelectorAll(".rank-input")];
return all[all.indexOf(input) + dir] || null;
}
// A run of empty places shows as one row.
function emptyRow(from, to) {
const n = to - from + 1;
const label = n === 1 ? `#${fmt.format(from)}` : `#${fmt.format(from)}–${fmt.format(to)}`;
const targeted = addAt >= from && addAt <= to;
return h("li", { class: "rank-gap" + (targeted ? " is-target" : ""), "data-place": from, "data-place-end": to },
h("span", { class: "rank-gap-label" }, h("b", {}, label), n === 1 ? " Empty place" : ` ${fmt.format(n)} empty places`),
h("span", { class: "rank-gap-actions" },
h("button", { type: "button", class: "btn btn--sm btn--ghost", onclick: () => setAddAt(targeted ? 0 : from) },
icon(targeted ? "x" : "plus"), targeted ? "Cancel" : "Add here"),
h("button", { type: "button", class: "btn btn--sm btn--ghost", title: "Remove these places; everything below moves up", onclick: () => removePlaces(from, n) },
icon("trash"), n === 1 ? "Remove place" : "Remove places"),
),
);
}
function render(flashId) {
syncRanks();
const gaps = places.last() - places.at.size;
rankTools.hidden = items.length === 0;
gapInfo.textContent = gaps ? `${fmt.format(gaps)} empty ${gaps === 1 ? "place" : "places"}` : "No gaps";
closeGapsBtn.hidden = gaps === 0;
renderAddTarget();
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", {}, 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.`)));
return;
}
const rows = [];
const ranks = [...places.at.keys()].sort((a, b) => a - b);
let next = 1;
for (const r of ranks) {
if (r > next) rows.push(emptyRow(next, r - 1));
rows.push(itemRow(byId.get(places.at.get(r))));
next = r + 1;
}
// Re-ordering detaches the rows; keep the cursor where it was.
const focused = document.activeElement;
const sel = focused?.classList?.contains("rank-input") ? [focused.selectionStart, focused.selectionEnd] : null;
rendering = true;
try {
rankList.replaceChildren(...rows);
if (focused && focused !== document.activeElement && rankList.contains(focused)) {
focused.focus({ preventScroll: true });
if (sel) focused.setSelectionRange(...sel);
}
} finally {
rendering = false;
}
if (flashId) {
const el = rowEls.get(flashId);
if (el) {
el.classList.remove("is-flash");
void el.offsetWidth; // restart the animation
el.classList.add("is-flash");
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}
}
// ---- the "add here" target ----
const addTarget = h("div", { class: "add-target", hidden: true });
document.querySelector(".editor-search .search-bar").before(addTarget);
function setAddAt(place) {
addAt = place;
render();
if (place) searchInput.focus();
}
function renderAddTarget() {
addTarget.hidden = !addAt;
if (addAt) {
addTarget.replaceChildren(icon("plus"), h("span", {}, "Adding at ", h("b", {}, `#${fmt.format(addAt)}`)),
h("button", { type: "button", class: "icon-btn", "aria-label": "Add after the last entry instead", title: "Cancel", onclick: () => setAddAt(0) }, icon("x")));
}
}
const nextPlace = () => addAt || places.appendPlace();
// After adding into a gap, aim at the next place in it, if it's empty too.
function advanceAddAt(added) {
if (!addAt) return;
addAt = places.at.has(added + 1) || added + 1 > list.maxItems ? 0 : added + 1;
}
// ---- toolbar ----
const rankTools = h("div", { class: "rank-tools", hidden: true });
const gapInfo = h("span", { class: "muted small" });
const closeGapsBtn = h("button", { type: "button", class: "btn btn--sm btn--ghost", title: "Number the entries 1, 2, 3… keeping their order", onclick: closeGaps }, "Close gaps");
const jump = h("input", { type: "text", inputmode: "numeric", placeholder: "Go to #", "aria-label": "Go to place", class: "rank-jump" });
jump.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
e.preventDefault();
const n = Number(jump.value.replace(/[^0-9]/g, ""));
const ranks = [...places.at.keys()].sort((a, b) => a - b);
const r = ranks.find((x) => x >= n) ?? ranks[ranks.length - 1];
const el = r && rowEls.get(places.at.get(r));
if (el) {
el.scrollIntoView({ block: "center", behavior: "smooth" });
el.querySelector(".rank-input").focus();
}
jump.value = "";
});
rankTools.append(gapInfo, closeGapsBtn, h("span", { class: "spacer" }), jump);
rankList.before(rankTools);
// ---- drag and drop ----
sortable(rankList, {
onMove: (el, from, to) => {
const it = byId.get(Number(el.dataset.id));
// Where it landed decides the place: moving down, it takes the place of
// whatever is now right above it; moving up, whatever is right below.
const near = to > from ? el.previousElementSibling : el.nextElementSibling;
if (!it || !near) { render(); return; }
const target = to > from
? Number(near.dataset.placeEnd || near.dataset.place)
: Number(near.dataset.place);
moveTo(it, target);
},
});
const SOURCES = { tmdb: "TMDB", tvdb: "TheTVDB", igdb: "IGDB", deezer: "Deezer", musicbrainz: "MusicBrainz", wikipedia: "Wikipedia" }; const SOURCES = { tmdb: "TMDB", tvdb: "TheTVDB", igdb: "IGDB", deezer: "Deezer", musicbrainz: "MusicBrainz", wikipedia: "Wikipedia" };
const sourceName = (it) => SOURCES[it.provider] || "its source"; const sourceName = (it) => SOURCES[it.provider] || "its source";
@@ -158,7 +454,7 @@ async function refreshOne(it, button) {
const res = await api("POST", `/api/items/${it.id}/refresh`, { replaceCustomArt }); const res = await api("POST", `/api/items/${it.id}/refresh`, { replaceCustomArt });
toast(res.changed.length ? `Updated ${res.changed.join(", ")} for “${res.item.title}”` : `“${res.item.title}” is already up to date`); toast(res.changed.length ? `Updated ${res.changed.join(", ")} for “${res.item.title}”` : `“${res.item.title}” is already up to date`);
if (res.warning) toast(res.warning, { error: true }); if (res.warning) toast(res.warning, { error: true });
await load(page, it.id); await loadAll(it.id);
} catch (err) { } catch (err) {
toast(err.message, { error: true }); toast(err.message, { error: true });
busy(button, false); busy(button, false);
@@ -221,7 +517,7 @@ async function runRefreshAll(targets, replaceCustomArt) {
const stop = h("button", { type: "button", class: "btn", onclick: () => { stopped = true; stop.disabled = true; stop.textContent = "Stopping…"; } }, "Stop"); const stop = h("button", { type: "button", class: "btn", onclick: () => { stopped = true; stop.disabled = true; stop.textContent = "Stopping…"; } }, "Stop");
const foot = h("div", { class: "dialog-foot" }, stop); const foot = h("div", { class: "dialog-foot" }, stop);
const dlg = openDialog(h("div", {}, h("div", { class: "dialog-head" }, h("h2", {}, "Refreshing…")), body, foot), { const dlg = openDialog(h("div", {}, h("div", { class: "dialog-head" }, h("h2", {}, "Refreshing…")), body, foot), {
onClose: () => { stopped = true; if (finished) load(page); }, onClose: () => { stopped = true; if (finished) loadAll(); },
}); });
dlg.style.maxWidth = "520px"; dlg.style.maxWidth = "520px";
@@ -257,7 +553,7 @@ async function runRefreshAll(targets, replaceCustomArt) {
failed.length > 12 && h("li", { class: "muted" }, `…and ${failed.length - 12} more`)), failed.length > 12 && h("li", { class: "muted" }, `…and ${failed.length - 12} more`)),
].filter(Boolean)); ].filter(Boolean));
foot.replaceChildren(h("button", { type: "button", class: "btn btn--primary", onclick: () => dlg.close() }, "Done")); foot.replaceChildren(h("button", { type: "button", class: "btn btn--primary", onclick: () => dlg.close() }, "Done"));
await load(page); await loadAll();
} }
document.querySelector("[data-action=refresh-all]")?.addEventListener("click", refreshAll); document.querySelector("[data-action=refresh-all]")?.addEventListener("click", refreshAll);
@@ -265,7 +561,7 @@ document.querySelector("[data-action=refresh-all]")?.addEventListener("click", r
document.querySelector("[data-action=import]").addEventListener("click", () => openImport({ document.querySelector("[data-action=import]").addEventListener("click", () => openImport({
list, cat, list, cat,
onListChange: (l) => { list = l; renderHeader(); }, onListChange: (l) => { list = l; renderHeader(); },
onDone: () => load(pages()), onDone: () => loadAll(),
})); }));
// Songs and albums from Deezer, or matched to Deezer when saved, can play. // Songs and albums from Deezer, or matched to Deezer when saved, can play.
@@ -273,94 +569,6 @@ function canPreview(it) {
return (cat.key === "song" || cat.key === "album") && (it.provider === "deezer" || !!it.extra?.deezer); return (cat.key === "song" || cat.key === "album") && (it.provider === "deezer" || !!it.extra?.deezer);
} }
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", {}, 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));
}
if (flashId) {
const el = rankList.querySelector(`[data-id="${flashId}"]`);
if (el) {
el.classList.add("is-flash");
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}
renderPager();
}
function renderPager() {
const total = pages();
if (total <= 1) { pager.replaceChildren(); return; }
const btn = (p, label = String(p), aria) => h("button", {
type: "button", class: "pager-btn" + (p === page ? " is-current" : ""), "aria-label": aria,
"aria-current": p === page ? "page" : null, onclick: () => load(p),
}, label);
const nums = [];
let last = 0;
for (let i = 1; i <= total; i++) {
if (i === 1 || i === total || Math.abs(i - page) <= 2) {
if (last && i - last > 1) nums.push(h("span", { class: "pager-gap" }, "…"));
nums.push(btn(i, `${fmt.format((i - 1) * pageSize + 1)}–${fmt.format(Math.min(i * pageSize, list.itemCount))}`));
last = i;
}
}
pager.replaceChildren(...nums);
}
async function moveTo(it, rank) {
try {
await api("POST", `/api/items/${it.id}/move`, { rank });
} catch (err) {
toast(err.message, { error: true });
}
const targetPage = Math.ceil(rank / pageSize);
await load(targetPage, it.id);
}
sortable(rankList, {
onMove: async (el, from, to) => {
const id = Number(el.dataset.id);
const it = items.find((x) => x.id === id);
const rank = (page - 1) * pageSize + to + 1;
// Renumber right away so the UI feels instant; the reload confirms.
[...rankList.children].forEach((li, i) => {
const input = li.querySelector(".rank-input");
if (input) input.value = (page - 1) * pageSize + i + 1;
});
try {
await api("POST", `/api/items/${id}/move`, { rank });
} catch (err) {
toast(err.message, { error: true });
}
await load(page, it?.id);
},
});
async function removeItem(it) {
const ok = await confirmDialog({
title: "Remove from list?",
message: `“${it.title}” will be removed from #${it.rank}, and everything below moves up one spot.`,
confirm: "Remove",
danger: true,
});
if (!ok) return;
try {
await api("DELETE", `/api/items/${it.id}`);
list.itemCount--;
renderHeader();
toast(`Removed “${it.title}”`);
await load();
markResult(it, 0);
} catch (err) {
toast(err.message, { error: true });
}
}
// ---------- search ---------- // ---------- search ----------
let lastResults = null; let lastResults = null;
@@ -410,7 +618,7 @@ function renderResults(res) {
? h("span", { class: "result-added" }, icon("check"), `#${r.existingRank}`) ? h("span", { class: "result-added" }, icon("check"), `#${r.existingRank}`)
: h("button", { : h("button", {
type: "button", class: "btn btn--sm btn--primary", disabled: full, type: "button", class: "btn btn--sm btn--primary", disabled: full,
title: full ? "This list is full" : `Add to #${list.itemCount + 1}`, title: full ? "This list is full" : `Add at #${nextPlace()}`,
onclick: (e) => addResult(r, e.currentTarget), onclick: (e) => addResult(r, e.currentTarget),
}, icon("plus"), "Add"); }, icon("plus"), "Add");
const art = artBox(r.imageUrl, ""); const art = artBox(r.imageUrl, "");
@@ -450,6 +658,9 @@ function fromResult(r) {
async function createItem(body, done) { async function createItem(body, done) {
try { try {
// Let earlier moves reach the server first, so places line up.
await queue;
body = { ...body, rank: addAt };
let res; let res;
try { try {
res = await api("POST", `/api/lists/${list.id}/items`, body); res = await api("POST", `/api/lists/${list.id}/items`, body);
@@ -459,13 +670,19 @@ async function createItem(body, done) {
if (!again) return null; if (!again) return null;
res = await api("POST", `/api/lists/${list.id}/items`, { ...body, force: true }); res = await api("POST", `/api/lists/${list.id}/items`, { ...body, force: true });
} }
const it = res.item;
places.makeRoom(it.rank);
places.at.set(it.rank, it.id);
items.push(it);
byId.set(it.id, it);
list.itemCount++; list.itemCount++;
advanceAddAt(it.rank);
renderHeader(); renderHeader();
toast(`Added “${res.item.title}” at #${res.item.rank}`); render(it.id);
toast(`Added “${it.title}” at #${it.rank}`);
if (res.warning) toast(res.warning, { error: true }); if (res.warning) toast(res.warning, { error: true });
markResult(res.item, res.item.rank); markResult(it, it.rank);
await load(Math.ceil(res.item.rank / pageSize), res.item.id); return it;
return res.item;
} catch (err) { } catch (err) {
toast(err.message, { error: true }); toast(err.message, { error: true });
return null; return null;
@@ -498,7 +715,7 @@ function openItemForm({ item, result }) {
const description = h("textarea", { rows: 4, maxlength: 10000, value: src.description || "", placeholder: "What it is, in a few sentences." }); 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 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 source = { tmdb: "TMDB", tvdb: "TheTVDB", igdb: "IGDB", wikipedia: "Wikipedia" }[src.provider];
const rankLabel = editing ? `#${item.rank}` : `#${list.itemCount + 1}`; const rankLabel = editing ? `#${item.rank}` : `#${nextPlace()}`;
const link = h("input", { type: "url", maxlength: 2000, value: src.externalUrl || "", placeholder: "https://" }); const link = h("input", { type: "url", maxlength: 2000, value: src.externalUrl || "", placeholder: "https://" });
// Theater production gets top billing with suggestions. // Theater production gets top billing with suggestions.
@@ -549,7 +766,7 @@ function openItemForm({ item, result }) {
clearImage = false; clearImage = false;
preview.replaceChildren(artBox(`/images/${res.item.image}`)); preview.replaceChildren(artBox(`/images/${res.item.image}`));
toast("Artwork updated"); toast("Artwork updated");
await load(page); replaceItem(res.item);
} catch (err) { } catch (err) {
toast(err.message, { error: true }); toast(err.message, { error: true });
} finally { } finally {
@@ -569,7 +786,7 @@ function openItemForm({ item, result }) {
: "Art is saved to your server, so it keeps working if the source changes."; : "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 #${nextPlace()}`);
const heading = editing ? `Edit #${item.rank}` : result ? `Add “${result.title}”` : `Add a ${cat.singular.toLowerCase()}`; const heading = editing ? `Edit #${item.rank}` : result ? `Add “${result.title}”` : `Add a ${cat.singular.toLowerCase()}`;
const form = h("form", { novalidate: true }, const form = h("form", { novalidate: true },
@@ -623,10 +840,10 @@ function openItemForm({ item, result }) {
busy(save, true); busy(save, true);
try { try {
if (editing) { if (editing) {
await api("PUT", `/api/items/${item.id}`, body); const res = await api("PUT", `/api/items/${item.id}`, body);
dlg.close(); dlg.close();
toast("Saved"); toast("Saved");
await load(page, item.id); replaceItem(res.item);
} else { } else {
Object.assign(body, { Object.assign(body, {
provider: result?.provider || "", providerId: result?.providerId || "", details: !!result, provider: result?.provider || "", providerId: result?.providerId || "", details: !!result,
@@ -637,8 +854,8 @@ function openItemForm({ item, result }) {
const fd = new FormData(); const fd = new FormData();
fd.append("file", pendingFile); fd.append("file", pendingFile);
try { try {
await api("POST", `/api/items/${created.id}/image`, fd); const res = await api("POST", `/api/items/${created.id}/image`, fd);
await load(page, created.id); replaceItem(res.item);
} catch (err) { } catch (err) {
toast(`Added, but the artwork didn't upload: ${err.message}`, { error: true }); toast(`Added, but the artwork didn't upload: ${err.message}`, { error: true });
} }
@@ -658,4 +875,4 @@ function openItemForm({ item, result }) {
} }
renderHeader(); renderHeader();
load(1); loadAll();
+1 -1
View File
@@ -186,7 +186,7 @@ export function storageSet(key, val) {
// Close <details class="menu"> dropdowns when clicking elsewhere or picking an item. // Close <details class="menu"> dropdowns when clicking elsewhere or picking an item.
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
for (const d of document.querySelectorAll("details.menu[open]")) { for (const d of document.querySelectorAll("details.menu[open]")) {
if (!d.contains(e.target) || e.target.closest(".menu-pop a")) d.open = false; if (!d.contains(e.target) || e.target.closest(".menu-pop a, .menu-pop button")) d.open = false;
} }
}); });
+1 -1
View File
@@ -32,7 +32,7 @@ export function openImport({ list, cat, onListChange, onDone }) {
dialogHead("Import a list", () => dlg), dialogHead("Import a list", () => dlg),
h("div", { class: "dialog-body stack" }, h("div", { class: "dialog-body stack" },
h("p", { class: "muted" }, h("p", { class: "muted" },
`Entries are added after #${fmt.format(list.itemCount)} in the file's order. Use one title per line, or a CSV with columns like Title, Year, ${artistWord(cat)} and Note. `, `Entries are added after your last one, in the file's order. Use one title per line, or a CSV with columns like Title, Year, ${artistWord(cat)} and Note. `,
"Each one is looked up and matched for you; anything Canon isn't sure about is left for you to check."), "Each one is looked up and matched for you; anything Canon isn't sure about is left for you to check."),
drop, drop,
text, text,
+87
View File
@@ -0,0 +1,87 @@
// A copy of the place rules in internal/store/places.go, so the editor can
// show a change the moment it's made. The server's reply is the final word.
export class Places {
/** @param {{id: number, rank: number}[]} items @param {number} limit */
constructor(items, limit) {
this.at = new Map(items.map((it) => [it.rank, it.id]));
this.limit = limit;
}
last() {
let n = 0;
for (const r of this.at.keys()) n = Math.max(n, r);
return n;
}
rankOf(id) {
for (const [r, x] of this.at) if (x === id) return r;
return 0;
}
shift(from, to, d) {
if (from > to) return;
const order = [];
for (let r = from; r <= to; r++) order.push(r);
if (d > 0) order.reverse();
for (const r of order) {
if (this.at.has(r)) {
this.at.set(r + d, this.at.get(r));
this.at.delete(r);
}
}
}
nextEmpty(from) {
let r = from;
while (this.at.has(r)) r++;
return r;
}
/** Frees place at by pushing entries down to the next empty place. */
makeRoom(at) {
if (!this.at.has(at)) return true;
const gap = this.nextEmpty(at);
if (gap > this.limit) return false;
this.shift(at, gap - 1, +1);
return true;
}
move(from, to) {
const id = this.at.get(from);
this.at.delete(from);
if (this.at.has(to)) {
if (to < from) {
this.shift(to, this.nextEmpty(to + 1) - 1, +1);
} else {
let gap = to - 1;
while (this.at.has(gap)) gap--;
this.shift(gap + 1, to, -1);
}
}
this.at.set(to, id);
}
remove(id, closeUp) {
const r = this.rankOf(id);
this.at.delete(r);
if (closeUp) this.shift(r + 1, this.nextEmpty(r + 1) - 1, -1);
}
removePlaces(at, count) {
for (let i = 0; i < count; i++) this.shift(at + 1, this.last(), -1);
}
compact() {
const ids = [...this.at.entries()].sort((a, b) => a[0] - b[0]).map((e) => e[1]);
this.at = new Map(ids.map((id, i) => [i + 1, id]));
}
/** Where a new entry goes when no place is asked for. */
appendPlace() {
const last = this.last();
if (last < this.limit) return last + 1;
for (let r = 1; r <= this.limit; r++) if (!this.at.has(r)) return r;
return this.limit + 1;
}
}
+1 -2
View File
@@ -39,10 +39,9 @@
<section class="panel editor-items" aria-labelledby="items-h"> <section class="panel editor-items" aria-labelledby="items-h">
<div class="panel-head"> <div class="panel-head">
<h2 id="items-h">Ranking</h2> <h2 id="items-h">Ranking</h2>
<span class="muted small">Drag, use the arrows, or type a new rank.</span> <span class="muted small">Drag, or type a place and press Enter. Places can be left empty.</span>
</div> </div>
<ol class="rank-list" data-rank-list></ol> <ol class="rank-list" data-rank-list></ol>
<nav class="pager" data-pager aria-label="Pages"></nav>
</section> </section>
</div> </div>
</div> </div>
+11 -3
View File
@@ -17,9 +17,15 @@
{{define "podium"}} {{define "podium"}}
{{- $c := .Category}} {{- $c := .Category}}
{{- $top := first 3 .Items}} {{- $top := podium .Items}}
<ol class="podium podium--{{len $top}}"> <ol class="podium podium--{{len $top}}">
{{- range $top}} {{- range $i, $spot := $top}}
{{- if not $spot}}
<li class="podium-spot podium-spot--{{add $i 1}} podium-spot--empty" aria-hidden="true">
<span class="podium-step"><span>{{add $i 1}}</span></span>
</li>
{{- else}}
{{- with $spot}}
<li class="podium-spot podium-spot--{{.Rank}}"> <li class="podium-spot podium-spot--{{.Rank}}">
<button type="button" class="item-btn podium-card" data-item="{{.ID}}"> <button type="button" class="item-btn podium-card" data-item="{{.ID}}">
<span class="item-art">{{template "art" (dict "Item" . "Icon" $c.Icon)}}</span> <span class="item-art">{{template "art" (dict "Item" . "Icon" $c.Icon)}}</span>
@@ -32,8 +38,10 @@
<span class="podium-step" aria-hidden="true"><span>{{.Rank}}</span></span> <span class="podium-step" aria-hidden="true"><span>{{.Rank}}</span></span>
</li> </li>
{{- end}} {{- end}}
{{- end}}
{{- end}}
</ol> </ol>
{{- $rest := after 3 .Items}} {{- $rest := belowPlace 3 .Items}}
{{- if $rest}} {{- if $rest}}
<details class="more"> <details class="more">
<summary class="more-btn"><span class="more-closed">Show {{len $rest}} more</span><span class="more-open">Show less</span>{{icon "chevron-down"}}</summary> <summary class="more-btn"><span class="more-closed">Show {{len $rest}} more</span><span class="more-open">Show less</span>{{icon "chevron-down"}}</summary>