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:
1 parent
bf915c04db
commit
88373176f2
15 files changed
+1090
-233
No files matched your search
+55
-13
@@ -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); }
|
||||
|
||||
|
||||
+349
-132
@@ -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 → <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) {
|
||||
page = Math.min(Math.max(1, p), pages());
|
||||
async function loadAll(flashId) {
|
||||
try {
|
||||
items = await api("GET", `/api/lists/${list.id}/items?offset=${(page - 1) * pageSize}&limit=${pageSize}`);
|
||||
items = await allItems();
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
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 = "") {
|
||||
const box = h("span", { class: "result-art" });
|
||||
if (src) {
|
||||
@@ -94,27 +214,73 @@ function artBox(src, alt = "") {
|
||||
return box;
|
||||
}
|
||||
|
||||
function row(it) {
|
||||
const rankInput = h("input", {
|
||||
class: "rank-input", type: "number", min: 1, max: list.itemCount, value: it.rank,
|
||||
"aria-label": `Rank of ${it.title}`, inputmode: "numeric",
|
||||
function itemRow(it) {
|
||||
let li = rowEls.get(it.id);
|
||||
if (!li) {
|
||||
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 n = Number(rankInput.value);
|
||||
if (!Number.isInteger(n) || n === it.rank) { rankInput.value = it.rank; return; }
|
||||
moveTo(it, Math.min(Math.max(1, n), list.itemCount));
|
||||
// A redraw detaching the box fires blur; that isn't the owner leaving it.
|
||||
if (rendering) return;
|
||||
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(); } });
|
||||
rankInput.addEventListener("change", commit);
|
||||
input.addEventListener("input", () => { input.dataset.dirty = "1"; });
|
||||
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 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 },
|
||||
h("button", { type: "button", class: "drag-handle", "aria-label": `Drag to reorder ${it.title}, or use arrow keys` }, icon("grip")),
|
||||
rankInput,
|
||||
h("button", { type: "button", class: "drag-handle", "aria-label": `Drag to reorder ${it.title}, or use the arrow keys` }, icon("grip")),
|
||||
input,
|
||||
artBox(it.image ? `/images/${it.image}` : ""),
|
||||
h("div", { class: "rank-row-body" },
|
||||
h("div", { class: "rank-row-title" }, it.title),
|
||||
h("div", { class: "rank-row-sub" },
|
||||
h("button", { type: "button", class: "rank-row-body", onclick: () => openItemForm({ item: it }), title: "Edit" },
|
||||
h("span", { class: "rank-row-title" }, it.title),
|
||||
h("span", { class: "rank-row-sub" },
|
||||
it.extra?.production && h("span", { class: "badge" }, it.extra.production),
|
||||
sub && h("span", {}, sub),
|
||||
it.note && h("span", { title: "Has a note" }, icon("pencil")),
|
||||
@@ -122,15 +288,145 @@ function row(it) {
|
||||
),
|
||||
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"),
|
||||
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 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 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 });
|
||||
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 });
|
||||
await load(page, it.id);
|
||||
await loadAll(it.id);
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
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 foot = h("div", { class: "dialog-foot" }, stop);
|
||||
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";
|
||||
|
||||
@@ -257,7 +553,7 @@ async function runRefreshAll(targets, replaceCustomArt) {
|
||||
failed.length > 12 && h("li", { class: "muted" }, `…and ${failed.length - 12} more`)),
|
||||
].filter(Boolean));
|
||||
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);
|
||||
@@ -265,7 +561,7 @@ document.querySelector("[data-action=refresh-all]")?.addEventListener("click", r
|
||||
document.querySelector("[data-action=import]").addEventListener("click", () => openImport({
|
||||
list, cat,
|
||||
onListChange: (l) => { list = l; renderHeader(); },
|
||||
onDone: () => load(pages()),
|
||||
onDone: () => loadAll(),
|
||||
}));
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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 ----------
|
||||
|
||||
let lastResults = null;
|
||||
@@ -410,7 +618,7 @@ function renderResults(res) {
|
||||
? h("span", { class: "result-added" }, icon("check"), `#${r.existingRank}`)
|
||||
: h("button", {
|
||||
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),
|
||||
}, icon("plus"), "Add");
|
||||
const art = artBox(r.imageUrl, "");
|
||||
@@ -450,6 +658,9 @@ function fromResult(r) {
|
||||
|
||||
async function createItem(body, done) {
|
||||
try {
|
||||
// Let earlier moves reach the server first, so places line up.
|
||||
await queue;
|
||||
body = { ...body, rank: addAt };
|
||||
let res;
|
||||
try {
|
||||
res = await api("POST", `/api/lists/${list.id}/items`, body);
|
||||
@@ -459,13 +670,19 @@ async function createItem(body, done) {
|
||||
if (!again) return null;
|
||||
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++;
|
||||
advanceAddAt(it.rank);
|
||||
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 });
|
||||
markResult(res.item, res.item.rank);
|
||||
await load(Math.ceil(res.item.rank / pageSize), res.item.id);
|
||||
return res.item;
|
||||
markResult(it, it.rank);
|
||||
return it;
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
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 note = h("textarea", { rows: 3, maxlength: 10000, value: item?.note || "", placeholder: "What earns it this spot? Shown to visitors." });
|
||||
const source = { tmdb: "TMDB", tvdb: "TheTVDB", igdb: "IGDB", wikipedia: "Wikipedia" }[src.provider];
|
||||
const rankLabel = editing ? `#${item.rank}` : `#${list.itemCount + 1}`;
|
||||
const rankLabel = editing ? `#${item.rank}` : `#${nextPlace()}`;
|
||||
const link = h("input", { type: "url", maxlength: 2000, value: src.externalUrl || "", placeholder: "https://" });
|
||||
|
||||
// Theater production gets top billing with suggestions.
|
||||
@@ -549,7 +766,7 @@ function openItemForm({ item, result }) {
|
||||
clearImage = false;
|
||||
preview.replaceChildren(artBox(`/images/${res.item.image}`));
|
||||
toast("Artwork updated");
|
||||
await load(page);
|
||||
replaceItem(res.item);
|
||||
} catch (err) {
|
||||
toast(err.message, { error: true });
|
||||
} finally {
|
||||
@@ -569,7 +786,7 @@ function openItemForm({ item, result }) {
|
||||
: "Art is saved to your server, so it keeps working if the source changes.";
|
||||
|
||||
const error = h("p", { class: "alert alert--error", role: "alert", hidden: true });
|
||||
const save = h("button", { type: "submit", class: "btn btn--primary" }, editing ? "Save" : `Add at #${list.itemCount + 1}`);
|
||||
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 form = h("form", { novalidate: true },
|
||||
@@ -623,10 +840,10 @@ function openItemForm({ item, result }) {
|
||||
busy(save, true);
|
||||
try {
|
||||
if (editing) {
|
||||
await api("PUT", `/api/items/${item.id}`, body);
|
||||
const res = await api("PUT", `/api/items/${item.id}`, body);
|
||||
dlg.close();
|
||||
toast("Saved");
|
||||
await load(page, item.id);
|
||||
replaceItem(res.item);
|
||||
} else {
|
||||
Object.assign(body, {
|
||||
provider: result?.provider || "", providerId: result?.providerId || "", details: !!result,
|
||||
@@ -637,8 +854,8 @@ function openItemForm({ item, result }) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", pendingFile);
|
||||
try {
|
||||
await api("POST", `/api/items/${created.id}/image`, fd);
|
||||
await load(page, created.id);
|
||||
const res = await api("POST", `/api/items/${created.id}/image`, fd);
|
||||
replaceItem(res.item);
|
||||
} catch (err) {
|
||||
toast(`Added, but the artwork didn't upload: ${err.message}`, { error: true });
|
||||
}
|
||||
@@ -658,4 +875,4 @@ function openItemForm({ item, result }) {
|
||||
}
|
||||
|
||||
renderHeader();
|
||||
load(1);
|
||||
loadAll();
|
||||
@@ -186,7 +186,7 @@ export function storageSet(key, val) {
|
||||
// Close <details class="menu"> dropdowns when clicking elsewhere or picking an item.
|
||||
document.addEventListener("click", (e) => {
|
||||
for (const d of document.querySelectorAll("details.menu[open]")) {
|
||||
if (!d.contains(e.target) || e.target.closest(".menu-pop a")) d.open = false;
|
||||
if (!d.contains(e.target) || e.target.closest(".menu-pop a, .menu-pop button")) d.open = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export function openImport({ list, cat, onListChange, onDone }) {
|
||||
dialogHead("Import a list", () => dlg),
|
||||
h("div", { class: "dialog-body stack" },
|
||||
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."),
|
||||
drop,
|
||||
text,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user