Initial TopDeck: self-hosted ranked favorites

A single Go binary with SQLite that serves a public page of the owner's
ranked lists (movies, TV, albums, songs, video games, theater) and a
login-gated editor.

- Lists of 1–65,535 entries (100 recommended), several per category
- Search-to-add via TMDB, TheTVDB, IGDB, Deezer/MusicBrainz, Wikipedia;
  manual entry as a fallback
- Theater entries carry a production (Broadway, tour, …), venue and cast
- Artwork cached locally; URL override and uploads
- Drag, arrow and type-a-rank reordering; paginated for long lists
- 12 theme presets plus custom CSS
- First-run setup code or env-provided admin; bcrypt, rate-limited login,
  CSRF-guarded JSON API
- Dockerfile and docker-compose.yml

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-24 16:54:19 -07:00
commit e9a75a968a
53 files changed
+7078

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
// Minimal drag-to-reorder for vertical lists. Works with mouse, touch and pen
// via pointer events; the handle also supports ArrowUp/ArrowDown.
/**
* @param {HTMLElement} container element whose children are sortable
* @param {object} opts
* @param {string} opts.handle selector for the drag handle inside a child
* @param {string} opts.placeholderClass classes for the gap left while dragging
* @param {(el: HTMLElement, from: number, to: number) => void} opts.onMove
*/
export function sortable(container, { handle = ".drag-handle", placeholderClass = "is-placeholder", onMove }) {
const children = () => [...container.children];
container.addEventListener("pointerdown", (e) => {
const hdl = e.target.closest(handle);
if (!hdl || !container.contains(hdl) || (e.pointerType === "mouse" && e.button !== 0)) return;
const el = [...container.children].find((c) => c.contains(hdl));
if (!el) return;
e.preventDefault();
const from = children().indexOf(el);
const rect = el.getBoundingClientRect();
const grabOffset = e.clientY - rect.top;
let pointerY = e.clientY;
const ph = el.cloneNode(true);
ph.className = el.className + " " + placeholderClass;
ph.removeAttribute("id");
ph.setAttribute("aria-hidden", "true");
ph.style.height = rect.height + "px";
el.after(ph);
Object.assign(el.style, {
position: "fixed",
left: rect.left + "px",
top: rect.top + "px",
width: rect.width + "px",
zIndex: "50",
pointerEvents: "none",
});
el.classList.add("is-dragging");
document.documentElement.classList.add("dragging-active");
hdl.setPointerCapture(e.pointerId);
const place = () => {
el.style.top = pointerY - grabOffset + "px";
const others = children().filter((c) => c !== el && c !== ph);
let before = null;
for (const c of others) {
const r = c.getBoundingClientRect();
if (pointerY < r.top + r.height / 2) { before = c; break; }
}
if (before) {
if (ph.nextElementSibling !== before) container.insertBefore(ph, before);
} else if (container.lastElementChild !== ph) {
container.append(ph);
}
};
// Scroll the page when dragging near the top or bottom edge.
let raf = 0;
const autoscroll = () => {
const edge = 70;
let dy = 0;
if (pointerY < edge + 60) dy = -Math.ceil((edge + 60 - pointerY) / 6);
else if (pointerY > innerHeight - edge) dy = Math.ceil((pointerY - (innerHeight - edge)) / 6);
if (dy) { scrollBy(0, dy); place(); }
raf = requestAnimationFrame(autoscroll);
};
raf = requestAnimationFrame(autoscroll);
const move = (ev) => { pointerY = ev.clientY; place(); };
const end = () => {
cancelAnimationFrame(raf);
hdl.removeEventListener("pointermove", move);
hdl.removeEventListener("pointerup", end);
hdl.removeEventListener("pointercancel", end);
ph.replaceWith(el);
el.removeAttribute("style");
el.classList.remove("is-dragging");
document.documentElement.classList.remove("dragging-active");
const to = children().indexOf(el);
if (to !== from) onMove(el, from, to);
};
hdl.addEventListener("pointermove", move);
hdl.addEventListener("pointerup", end);
hdl.addEventListener("pointercancel", end);
});
container.addEventListener("keydown", (e) => {
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
const hdl = e.target.closest(handle);
if (!hdl) return;
const el = children().find((c) => c.contains(hdl));
const from = children().indexOf(el);
const to = e.key === "ArrowUp" ? from - 1 : from + 1;
if (to < 0 || to >= children().length) return;
e.preventDefault();
if (e.key === "ArrowUp") el.previousElementSibling.before(el);
else el.nextElementSibling.after(el);
hdl.focus();
onMove(el, from, to);
});
}