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>
105 lines
3.9 KiB
JavaScript
105 lines
3.9 KiB
JavaScript
// 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);
|
|
});
|
|
}
|