diff --git a/README.md b/README.md index ca55fda..992ac72 100644 --- a/README.md +++ b/README.md @@ -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. +## 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 **Import** in the list editor adds entries from a file, after the ones already there and in the file's order: diff --git a/internal/server/api.go b/internal/server/api.go index 3e97489..2cfe16d 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -366,11 +366,47 @@ func (s *Server) apiMoveItem(w http.ResponseWriter, r *http.Request) { s.writeErr(w, err) 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) 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) { @@ -379,13 +415,19 @@ func (s *Server) apiDeleteItem(w http.ResponseWriter, r *http.Request) { s.writeErr(w, err) 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 { s.writeErr(w, err) return } 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 ---- diff --git a/internal/server/render.go b/internal/server/render.go index b1f60f4..ff2dea6 100644 --- a/internal/server/render.go +++ b/internal/server/render.go @@ -108,6 +108,30 @@ var funcs = template.FuncMap{ // everything below them. "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)):] }, + // 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": func(style string) string { if style == "grid" { diff --git a/internal/server/server.go b/internal/server/server.go index ff76eef..c66fb64 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -143,6 +143,7 @@ func (s *Server) Handler() http.Handler { api("PUT /api/items/{id}", s.apiUpdateItem) api("POST /api/items/{id}/image", s.apiUploadItemImage) 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("DELETE /api/items/{id}", s.apiDeleteItem) api("GET /api/search", s.apiSearch) diff --git a/internal/store/items.go b/internal/store/items.go index df0b26d..c6fedad 100644 --- a/internal/store/items.go +++ b/internal/store/items.go @@ -110,45 +110,38 @@ func extraJSON(m map[string]string) string { return string(b) } -// AddItem inserts it into the list at rank (1-based); rank <= 0 or beyond the -// end appends. Items at or below the rank shift down by one. +// AddItem puts it at place rank: an empty place takes it as-is, a taken 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) { if err := it.normalize(); err != nil { return it, err } - tx, err := s.db.BeginTx(ctx, nil) + pt, err := s.beginPlaces(ctx, it.ListID) if err != nil { return it, err } - defer tx.Rollback() - - var maxItems, count int - 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 + defer pt.tx.Rollback() + if count := len(pt.p); count >= pt.limit { + return it, invalid("This list is full (%d of %d). Increase its size or remove an item first.", count, pt.limit) } - 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 } - if count >= maxItems { - 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 { + if err := pt.write(ctx); err != nil { 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 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 { return it, err } - if err := touchList(ctx, tx, it.ListID); err != nil { - return it, err - } - if err := tx.Commit(); err != nil { + if err := pt.tx.Commit(); err != nil { return it, err } id, _ := res.LastInsertId() @@ -175,68 +168,31 @@ func (s *Store) UpdateItem(ctx context.Context, it Item) (Item, error) { return cur, err } -// MoveItem moves an item to a new 1-based rank, shifting the items between. -func (s *Store) MoveItem(ctx context.Context, id int64, newRank int) error { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - 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) { +// DeleteItem removes an entry. With closeUp, the entries right below it +// move up one place, as far as the next empty place; otherwise its place is +// left empty. +func (s *Store) DeleteItem(ctx context.Context, id int64, closeUp bool) (Item, error) { it, err := s.ItemByID(ctx, id) if err != nil { return it, err } - tx, err := s.db.BeginTx(ctx, nil) + pt, err := s.beginPlaces(ctx, it.ListID) if err != nil { return it, err } - defer tx.Rollback() - if _, err := tx.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id); err != nil { + defer pt.tx.Rollback() + if _, err := pt.tx.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id); err != nil { 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 } - if err := touchList(ctx, tx, it.ListID); err != nil { - return it, err - } - return it, tx.Commit() + return it, pt.tx.Commit() } // ImageInUse reports whether any item or setting still references a cached diff --git a/internal/store/lists.go b/internal/store/lists.go index 84cfeb9..17a45ff 100644 --- a/internal/store/lists.go +++ b/internal/store/lists.go @@ -173,6 +173,11 @@ func (s *Store) UpdateList(ctx context.Context, l List) (List, error) { 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) } + 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 { return l, invalid("Can't change the category of a list that has items") } diff --git a/internal/store/places.go b/internal/store/places.go new file mode 100644 index 0000000..8179f1c --- /dev/null +++ b/internal/store/places.go @@ -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 +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 9f501f7..1ca90af 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -79,11 +79,11 @@ func TestRanking(t *testing.T) { }{ {"A", 5, "E,B,C,D,A"}, {"D", 1, "D,E,B,C,A"}, - {"B", 99, "D,E,C,A,B"}, // clamped to the end - {"C", 0, "C,D,E,A,B"}, // clamped to the top + {"B", 5, "D,E,C,A,B"}, + {"C", 1, "C,D,E,A,B"}, } 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) } 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) } 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") } } + +// 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") +} diff --git a/web/static/css/app.css b/web/static/css/app.css index 0da40d6..2da24e5 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -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--2 .podium-step { height: 60px; } .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); } @media (max-width: 640px) { /* #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-row { - display: grid; grid-template-columns: 28px 64px 44px 1fr auto; align-items: center; gap: 10px; - padding: 8px 6px; border-radius: var(--radius); background: var(--surface); + display: grid; grid-template-columns: 26px 56px 40px 1fr auto; align-items: center; gap: 8px; + padding: 5px 6px; border-radius: var(--radius); background: var(--surface); border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent); } .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-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-flash { animation: flash 1.2s ease-out; } @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); } .dragging-active, .dragging-active * { cursor: grabbing !important; user-select: none; } .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; } .rank-input:hover { border-color: var(--border) !important; } .rank-input:focus { background: var(--bg) !important; border-color: var(--accent) !important; } -.rank-row .result-art { width: 44px; } -.rank-row-body { min-width: 0; } -.rank-row-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.rank-row-sub { color: var(--muted); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; gap: 8px; align-items: center; } -.rank-row-actions { display: flex; gap: 0; } +.rank-row.is-top .rank-input { color: var(--accent); } +.rank-row .result-art { width: 40px; } +.rank-row-body { + all: unset; box-sizing: border-box; display: flex; flex-direction: column; gap: 1px; min-width: 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) { - .rank-row { grid-template-columns: 24px 50px 38px 1fr auto; gap: 6px; } - .rank-row .result-art { width: 38px; } - .rank-input { width: 50px !important; } + .rank-row { grid-template-columns: 22px 46px 36px 1fr auto; gap: 5px; } + .rank-row .result-art { width: 36px; } + .rank-input { width: 46px !important; } .rank-row-actions .hide-sm { display: none; } + .rank-gap { padding-left: 10px; } } .rank-empty { text-align: center; padding: 40px 16px; color: var(--muted); } diff --git a/web/static/js/admin-list.js b/web/static/js/admin-list.js index dbefbf1..38e203c 100644 --- a/web/static/js/admin-list.js +++ b/web/static/js/admin-list.js @@ -1,6 +1,7 @@ // 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 { sortable } from "./sortable.js"; +import { Places } from "./places.js"; import { openListForm } from "./list-form.js"; import { previewButton } from "./player.js"; import { openImport } from "./import.js"; @@ -8,15 +9,12 @@ import { openImport } from "./import.js"; const boot = readBoot(); const { categories, limits, productions } = boot; const cat = boot.category; -const pageSize = boot.pageSize; let list = boot.list; let items = []; -let page = 1; const square = cat.aspect === "square"; const $ = (sel) => document.querySelector(sel); const rankList = $("[data-rank-list]"); -const pager = $("[data-pager]"); const results = $("[data-results]"); const searchForm = $("[data-search]"); 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 →