1369 lines
46 KiB
HTML
1369 lines
46 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Letterboxd Pairwise Ranker</title>
|
||
<script>
|
||
// Runs before the body renders, so light mode does not flash dark first.
|
||
try {
|
||
if (localStorage.getItem("lbx-ranker-theme") === "light") { document.documentElement.setAttribute("data-theme", "light"); }
|
||
} catch (error) { /* site data blocked, stay on the dark default */ }
|
||
</script>
|
||
<style>
|
||
:root {
|
||
--bg: #14181c;
|
||
--fg: #e7ebee;
|
||
--muted: #99aabb;
|
||
--card: #1d242c;
|
||
--border: #2c343c;
|
||
--accent: #00e054;
|
||
--accent-fg: #14181c;
|
||
--shadow: 0 2px 12px rgba(0, 0, 0, .45);
|
||
}
|
||
:root[data-theme="light"] {
|
||
--bg: #f6f5f3;
|
||
--fg: #14181c;
|
||
--muted: #6a737c;
|
||
--card: #ffffff;
|
||
--border: #dfdcd6;
|
||
--accent: #00a942;
|
||
--accent-fg: #ffffff;
|
||
--shadow: 0 2px 10px rgba(20, 24, 28, .10);
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
background: var(--bg);
|
||
color: var(--fg);
|
||
font: 15px/1.5 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||
height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
header {
|
||
padding: 14px 22px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 14px;
|
||
flex-wrap: wrap;
|
||
}
|
||
header h1 { font-size: 16px; margin: 0; letter-spacing: .01em; }
|
||
header .sub { color: var(--muted); font-size: 13px; }
|
||
#themeBtn { padding: 3px 10px; font-size: 12px; border-radius: 6px; color: var(--muted); }
|
||
main { flex: 1; display: flex; flex-direction: column; padding: 22px; min-height: 0; }
|
||
.screen { display: none; }
|
||
.screen.active { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||
h2 { font-size: 20px; margin: 0 0 6px; font-weight: 600; }
|
||
p.lead { color: var(--muted); margin: 0 0 18px; }
|
||
button {
|
||
font: inherit;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border);
|
||
background: var(--card);
|
||
color: var(--fg);
|
||
padding: 9px 16px;
|
||
cursor: pointer;
|
||
}
|
||
button:hover { border-color: var(--muted); }
|
||
button.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; font-weight: 600; }
|
||
button.primary:hover { filter: brightness(1.08); }
|
||
button:disabled { opacity: .45; cursor: default; }
|
||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||
|
||
#drop {
|
||
border: 2px dashed var(--border);
|
||
border-radius: 14px;
|
||
padding: 44px 24px;
|
||
text-align: center;
|
||
background: var(--card);
|
||
transition: border-color .12s;
|
||
max-width: 720px;
|
||
}
|
||
#drop.over { border-color: var(--accent); }
|
||
#drop h2 { margin-bottom: 10px; }
|
||
.files { margin-top: 16px; font-size: 13px; color: var(--muted); }
|
||
.files div { margin: 3px 0; }
|
||
.ok { color: var(--accent); }
|
||
.err { color: #e0533d; }
|
||
.hint { color: var(--muted); font-size: 13px; }
|
||
code { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; font-size: 12px; }
|
||
|
||
#sessions { margin-top: 26px; max-width: 720px; }
|
||
#sessions h3 { font-size: 14px; font-weight: 600; margin: 0 0 10px; }
|
||
.session {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
background: var(--card);
|
||
padding: 10px 12px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.session .about { flex: 1; min-width: 0; }
|
||
.session .what { font-weight: 600; }
|
||
.session .meta { color: var(--muted); font-size: 12px; }
|
||
.session .titles { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.session .drop-btn { color: var(--muted); padding: 6px 10px; }
|
||
.session .drop-btn:hover { color: #e0533d; border-color: #e0533d; }
|
||
|
||
.bar { display: flex; align-items: center; gap: 14px; margin-bottom: 4px; flex-wrap: wrap; }
|
||
.count { font-variant-numeric: tabular-nums; font-size: 14px; color: var(--muted); }
|
||
.progress { flex: 1; min-width: 120px; height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
|
||
.progress > div { height: 100%; background: var(--accent); width: 0; transition: width .18s; }
|
||
.compare-body { display: flex; gap: 18px; flex: 1; min-height: 0; }
|
||
.arena {
|
||
flex: 1;
|
||
display: flex;
|
||
gap: 26px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 18px 0;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
#sidebar {
|
||
width: 272px;
|
||
flex: none;
|
||
position: relative;
|
||
border-left: 1px solid var(--border);
|
||
padding: 10px 0 10px 16px;
|
||
overflow-y: auto;
|
||
font-size: 13px;
|
||
}
|
||
#sidebar.hidden { display: none; }
|
||
@media (max-width: 900px) { #sidebar { display: none; } }
|
||
.side-head {
|
||
color: var(--muted);
|
||
margin-bottom: 8px;
|
||
position: sticky;
|
||
top: 0;
|
||
background: var(--bg);
|
||
padding-bottom: 6px;
|
||
z-index: 1;
|
||
}
|
||
.side-head b { color: var(--fg); font-weight: 600; }
|
||
.side-list { list-style: none; margin: 0; padding: 0; }
|
||
.side-list li {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
padding: 3px 6px 3px 0;
|
||
border-radius: 6px;
|
||
color: var(--muted);
|
||
}
|
||
.side-list li:last-child { margin-bottom: 4px; }
|
||
.side-list li.pivot {
|
||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||
color: var(--fg);
|
||
padding-left: 6px;
|
||
font-weight: 600;
|
||
}
|
||
.side-list .n { width: 30px; text-align: right; font-variant-numeric: tabular-nums; flex: none; }
|
||
.side-list img { width: 22px; aspect-ratio: 2 / 3; object-fit: cover; border-radius: 2px; background: var(--border); flex: none; }
|
||
.side-list .ph { width: 22px; aspect-ratio: 2 / 3; border-radius: 2px; background: var(--border); flex: none; }
|
||
.side-list .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
/* The break that says two blocks have no established order between them. */
|
||
.side-split {
|
||
display: block;
|
||
border-top: 1px dashed var(--muted);
|
||
margin: 9px 0 9px 38px;
|
||
padding: 0;
|
||
height: 0;
|
||
opacity: .55;
|
||
}
|
||
.pick {
|
||
width: min(38vw, 300px);
|
||
padding: 0;
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
background: var(--card);
|
||
box-shadow: var(--shadow);
|
||
transition: transform .1s, border-color .1s;
|
||
display: flex;
|
||
flex-direction: column;
|
||
text-align: left;
|
||
}
|
||
.pick:hover, .pick:focus-visible { transform: translateY(-3px); border-color: var(--accent); outline: none; }
|
||
.poster { aspect-ratio: 2 / 3; width: 100%; background: var(--bg); display: block; object-fit: cover; }
|
||
.poster.blank {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 18px;
|
||
text-align: center;
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
color: var(--muted);
|
||
}
|
||
.cap { padding: 10px 12px; }
|
||
.cap .t { font-weight: 600; }
|
||
.cap .y { color: var(--muted); font-size: 13px; }
|
||
.key { display: inline-block; min-width: 18px; text-align: center; border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; padding: 0 4px; font-size: 11px; color: var(--muted); }
|
||
|
||
/* Result: band editor + ranked list */
|
||
.bands {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(122px, 1fr));
|
||
gap: 8px;
|
||
margin: 12px 0 6px;
|
||
}
|
||
.band-cell {
|
||
border: 1px solid var(--border);
|
||
border-radius: 9px;
|
||
background: var(--card);
|
||
padding: 8px 9px;
|
||
}
|
||
.band-cell .stars { font-size: 13px; color: var(--accent); letter-spacing: -1px; white-space: nowrap; }
|
||
.band-cell .value { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; margin-left: 5px; }
|
||
.band-cell .ctrl { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
|
||
.band-cell input {
|
||
width: 100%;
|
||
min-width: 0;
|
||
font: inherit;
|
||
font-variant-numeric: tabular-nums;
|
||
text-align: center;
|
||
padding: 3px 2px;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--border);
|
||
background: var(--bg);
|
||
color: var(--fg);
|
||
}
|
||
.band-cell button { padding: 2px 8px; border-radius: 6px; line-height: 1.2; }
|
||
.band-cell .range { font-size: 11px; color: var(--muted); margin-top: 4px; font-variant-numeric: tabular-nums; }
|
||
|
||
.ranking { list-style: none; margin: 10px 0 0; padding: 0; max-width: 820px; }
|
||
.ranking li { display: flex; gap: 12px; align-items: center; padding: 7px 0; border-bottom: 1px solid var(--border); }
|
||
.ranking li.band-head {
|
||
border-bottom: none;
|
||
padding: 16px 0 4px;
|
||
gap: 8px;
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
position: sticky;
|
||
top: 0;
|
||
background: var(--bg);
|
||
}
|
||
.ranking li.band-head .n { color: var(--accent); }
|
||
.ranking .n { width: 42px; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||
.ranking img { width: 34px; aspect-ratio: 2 / 3; object-fit: cover; border-radius: 3px; background: var(--border); }
|
||
.ranking .ph { width: 34px; aspect-ratio: 2 / 3; border-radius: 3px; background: var(--border); }
|
||
.ranking a { color: inherit; text-decoration: none; }
|
||
.ranking a:hover { color: var(--accent); }
|
||
.scroll { overflow-y: auto; flex: 1; min-height: 0; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>Letterboxd Pairwise Ranker</h1>
|
||
<span class="sub">two films at a time, until the whole list has an order</span>
|
||
<span class="sub" id="posterStatus" style="margin-left:auto"></span>
|
||
<button id="themeBtn">Theme</button>
|
||
</header>
|
||
|
||
<main>
|
||
<section class="screen active" id="screenLoad">
|
||
<div id="drop">
|
||
<h2>Drop your <code>ratings.csv</code> here</h2>
|
||
<p class="lead" id="dropLead">Also accepts <code>posters.json</code> from <code>fetch_posters.py</code>, for box art.</p>
|
||
<div class="row" style="justify-content:center">
|
||
<button class="primary" id="pickBtn">Choose files</button>
|
||
<input type="file" id="fileInput" accept=".csv,.json" multiple hidden>
|
||
</div>
|
||
<div class="files" id="fileStatus"></div>
|
||
</div>
|
||
|
||
<div id="sessions" hidden>
|
||
<h3>Continue where you left off</h3>
|
||
<div id="sessionList"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="screen" id="screenStart">
|
||
<h2 id="startTitle"></h2>
|
||
<p class="lead" id="startLead"></p>
|
||
<div class="row">
|
||
<button class="primary" id="resumeBtn" hidden>Resume</button>
|
||
<button class="primary" id="startBtn">Start ranking</button>
|
||
<button id="backBtn">Load different files</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="screen" id="screenCompare">
|
||
<div class="bar">
|
||
<span class="count" id="counter"></span>
|
||
<div class="progress"><div id="progressFill"></div></div>
|
||
<button id="undoBtn">Undo</button>
|
||
<button id="listBtn">Hide list</button>
|
||
<button id="quitBtn">Stop</button>
|
||
</div>
|
||
<div class="compare-body">
|
||
<div class="arena">
|
||
<button class="pick" id="pickA"></button>
|
||
<button class="pick" id="pickB"></button>
|
||
</div>
|
||
<aside id="sidebar">
|
||
<div class="side-head" id="sideHead"></div>
|
||
<ol class="side-list" id="sideList"></ol>
|
||
</aside>
|
||
</div>
|
||
<p class="hint" style="text-align:center">
|
||
<span class="key">1</span> <span class="key">←</span> left ·
|
||
<span class="key">2</span> <span class="key">→</span> right ·
|
||
<span class="key">U</span> undo · progress saved in browser cache
|
||
</p>
|
||
</section>
|
||
|
||
<section class="screen" id="screenResult">
|
||
<h2 id="resultTitle"></h2>
|
||
<div class="row">
|
||
<span class="hint">Star thresholds:</span>
|
||
<button id="presetOld">Match old ratings</button>
|
||
<button id="presetEven">Even split</button>
|
||
<button id="presetTop">Top heavy</button>
|
||
</div>
|
||
<div class="bands" id="bands"></div>
|
||
<div class="row" style="margin-top:8px">
|
||
<button class="primary" id="csvBtn">Download rated CSV</button>
|
||
<button id="copyBtn">Copy as text</button>
|
||
<button id="againBtn">Rank again</button>
|
||
<button id="newBtn">Load different files</button>
|
||
<span class="hint" id="copyNote"></span>
|
||
</div>
|
||
<div class="scroll"><ol class="ranking" id="ranking"></ol></div>
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
"use strict";
|
||
|
||
const STORAGE_PREFIX = "lbx-ranker-v1-";
|
||
const BAND_COUNT = 10; // 5.0 down to 0.5 in half steps
|
||
const POSTER_BATCH = 20;
|
||
|
||
let header = null; // original csv header cells
|
||
let films = null; // parsed csv rows, file order
|
||
let posters = {}; // uri -> poster image url
|
||
let state = null; // { key, header, items, answers, counts }
|
||
let answerMap = null; // "keyA|keyB" -> winning key
|
||
let current = null; // { done, pair, ranked }
|
||
let hasPosterServer = false;
|
||
let resolving = false;
|
||
|
||
function $(id) { return document.getElementById(id); }
|
||
|
||
function show(screenId) {
|
||
for (const section of document.querySelectorAll(".screen")) {
|
||
section.classList.toggle("active", section.id === screenId);
|
||
}
|
||
}
|
||
|
||
function hashKey(keys) {
|
||
let hash = 0x811c9dc5;
|
||
for (const key of keys.slice().sort()) {
|
||
for (let i = 0; i < key.length; i++) {
|
||
hash = ((hash ^ key.charCodeAt(i)) * 0x01000193) >>> 0;
|
||
}
|
||
}
|
||
return hash.toString(16);
|
||
}
|
||
|
||
function shuffle(list) {
|
||
const out = list.slice();
|
||
for (let i = out.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
const swap = out[i];
|
||
out[i] = out[j];
|
||
out[j] = swap;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Worst case comparison count: every merge of two runs can cost their combined length minus one.
|
||
function worstCase(n) {
|
||
let total = 0;
|
||
let sizes = new Array(n).fill(1);
|
||
while (sizes.length > 1) {
|
||
const next = [];
|
||
for (let i = 0; i + 1 < sizes.length; i += 2) {
|
||
total += sizes[i] + sizes[i + 1] - 1;
|
||
next.push(sizes[i] + sizes[i + 1]);
|
||
}
|
||
if (sizes.length % 2 === 1) { next.push(sizes[sizes.length - 1]); }
|
||
sizes = next;
|
||
}
|
||
return total;
|
||
}
|
||
|
||
function roundCount(n) { return n > 1 ? Math.ceil(Math.log2(n)) : 0; }
|
||
|
||
// -- CSV in
|
||
|
||
function parseCsv(text) {
|
||
const rows = [];
|
||
let row = [], field = "", quoted = false;
|
||
if (text.charCodeAt(0) === 0xfeff) { text = text.slice(1); }
|
||
for (let i = 0; i < text.length; i++) {
|
||
const c = text[i];
|
||
if (quoted) {
|
||
if (c !== '"') { field += c; }
|
||
else if (text[i + 1] === '"') { field += '"'; i++; }
|
||
else { quoted = false; }
|
||
}
|
||
else if (c === '"') { quoted = true; }
|
||
else if (c === ",") { row.push(field); field = ""; }
|
||
else if (c === "\n") { row.push(field); rows.push(row); row = []; field = ""; }
|
||
else if (c !== "\r") { field += c; }
|
||
}
|
||
if (field !== "" || row.length) { row.push(field); rows.push(row); }
|
||
return rows;
|
||
}
|
||
|
||
// Each film keeps its original row so the export can reproduce the input format.
|
||
function filmsFromCsv(text) {
|
||
const rows = parseCsv(text).filter(row => row.some(cell => cell.trim() !== ""));
|
||
if (rows.length === 0) { throw new Error("that file is empty"); }
|
||
const head = rows[0];
|
||
const lower = head.map(cell => cell.trim().toLowerCase());
|
||
const nameAt = lower.indexOf("name");
|
||
const yearAt = lower.indexOf("year");
|
||
const uriAt = lower.indexOf("letterboxd uri");
|
||
const ratingAt = lower.indexOf("rating");
|
||
if (nameAt < 0) {
|
||
throw new Error("no 'Name' column, so this is not a Letterboxd ratings.csv. Header reads: " + head.join(", "));
|
||
}
|
||
const seen = new Set();
|
||
const out = [];
|
||
for (const row of rows.slice(1)) {
|
||
const title = (row[nameAt] || "").trim();
|
||
if (title === "") { continue; }
|
||
const uri = uriAt >= 0 ? (row[uriAt] || "").trim() : "";
|
||
const year = yearAt >= 0 ? (row[yearAt] || "").trim() : "";
|
||
const key = uri || title + "|" + year;
|
||
if (seen.has(key)) { continue; }
|
||
seen.add(key);
|
||
out.push({
|
||
title: title,
|
||
year: year,
|
||
uri: uri,
|
||
key: key,
|
||
oldRating: ratingAt >= 0 ? parseFloat(row[ratingAt]) : NaN,
|
||
row: row
|
||
});
|
||
}
|
||
if (out.length < 2) { throw new Error("need at least two films, found " + out.length); }
|
||
return { header: head, films: out };
|
||
}
|
||
|
||
// -- Sort engine: pure function of items + answers
|
||
|
||
function buildAnswerMap(answers) {
|
||
const map = Object.create(null);
|
||
for (const answer of answers) {
|
||
map[answer.a + "|" + answer.b] = answer.winner;
|
||
map[answer.b + "|" + answer.a] = answer.winner;
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// Consume the answers this merge already has. Returns the pair it is waiting on, or null
|
||
// once the two runs are fully interleaved.
|
||
function advanceMerge(merge, map) {
|
||
while (merge.i < merge.left.length && merge.j < merge.right.length) {
|
||
const a = merge.left[merge.i];
|
||
const b = merge.right[merge.j];
|
||
const winner = map[a.key + "|" + b.key];
|
||
if (winner === undefined) { return { a: a, b: b }; }
|
||
if (winner === a.key) { merge.out.push(a); merge.i++; } else { merge.out.push(b); merge.j++; }
|
||
}
|
||
while (merge.i < merge.left.length) { merge.out.push(merge.left[merge.i++]); }
|
||
while (merge.j < merge.right.length) { merge.out.push(merge.right[merge.j++]); }
|
||
return null;
|
||
}
|
||
|
||
// Every group in a round is waiting on its own pair, so ask the one that shares no film with
|
||
// the previous question. Two fresh posters each time reads as progress; the same film held
|
||
// against a parade of opponents does not.
|
||
function pickFreshest(blocked, lastKeys) {
|
||
let halfFresh = null;
|
||
for (const entry of blocked) {
|
||
const seenA = lastKeys.has(entry.pending.a.key);
|
||
const seenB = lastKeys.has(entry.pending.b.key);
|
||
if (seenA === false && seenB === false) { return entry; }
|
||
if (halfFresh === null && (seenA === false || seenB === false)) { halfFresh = entry; }
|
||
}
|
||
return halfFresh !== null ? halfFresh : blocked[0];
|
||
}
|
||
|
||
// Bottom-up merge sort, best first. Rounds halve the number of runs; within a round the
|
||
// groups merge independently, which is what lets the questions rotate between them.
|
||
function runSort(items, map, lastKeys) {
|
||
let runs = items.map(item => [item]);
|
||
let round = 1;
|
||
while (runs.length > 1) {
|
||
const merges = [];
|
||
for (let i = 0; i + 1 < runs.length; i += 2) {
|
||
merges.push({ left: runs[i], right: runs[i + 1], i: 0, j: 0, out: [] });
|
||
}
|
||
const carry = runs.length % 2 === 1 ? runs[runs.length - 1] : null;
|
||
|
||
const blocked = [];
|
||
merges.forEach((merge, group) => {
|
||
const pending = advanceMerge(merge, map);
|
||
if (pending !== null) { blocked.push({ merge: merge, group: group, pending: pending }); }
|
||
});
|
||
|
||
if (blocked.length > 0) {
|
||
const chosen = pickFreshest(blocked, lastKeys || new Set());
|
||
return {
|
||
done: false,
|
||
pair: [chosen.pending.a, chosen.pending.b],
|
||
merge: chosen.merge,
|
||
group: chosen.group,
|
||
merges: merges,
|
||
carry: carry,
|
||
round: round,
|
||
ranked: longestRun(runs)
|
||
};
|
||
}
|
||
|
||
runs = merges.map(merge => merge.out);
|
||
if (carry !== null) { runs.push(carry); }
|
||
round++;
|
||
}
|
||
return { done: true, pair: null, ranked: runs[0] || [] };
|
||
}
|
||
|
||
// The best partial answer available: the largest group that is already fully ordered.
|
||
function longestRun(runs) {
|
||
let best = [];
|
||
for (const run of runs) {
|
||
if (run.length > best.length) { best = run; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function lastAnswerKeys(answers) {
|
||
const keys = new Set();
|
||
const last = answers[answers.length - 1];
|
||
if (last !== undefined) { keys.add(last.a); keys.add(last.b); }
|
||
return keys;
|
||
}
|
||
|
||
// -- Persistence
|
||
|
||
function save() {
|
||
if (state === null) { return; }
|
||
try {
|
||
localStorage.setItem(STORAGE_PREFIX + state.key, JSON.stringify(state));
|
||
} catch (error) {
|
||
// Private window or blocked site data. Everything still works, minus resume.
|
||
}
|
||
}
|
||
|
||
function load(key) {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_PREFIX + key);
|
||
if (raw === null) { return null; }
|
||
const data = JSON.parse(raw);
|
||
if (!data || !Array.isArray(data.items) || !Array.isArray(data.answers)) { return null; }
|
||
return data;
|
||
} catch (error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// -- Loading files
|
||
|
||
// -- Saved sessions
|
||
|
||
// A session stores the films with their original csv rows, so it can be continued and
|
||
// exported without the export being dropped again.
|
||
function listSessions() {
|
||
const sessions = [];
|
||
try {
|
||
for (let i = 0; i < localStorage.length; i++) {
|
||
const storageKey = localStorage.key(i);
|
||
if (storageKey === null || storageKey.indexOf(STORAGE_PREFIX) !== 0) { continue; }
|
||
let data = null;
|
||
try { data = JSON.parse(localStorage.getItem(storageKey)); } catch (error) { continue; }
|
||
if (!data || !Array.isArray(data.items) || !Array.isArray(data.answers) || data.answers.length === 0) { continue; }
|
||
sessions.push({ storageKey: storageKey, key: data.key || storageKey.slice(STORAGE_PREFIX.length), data: data });
|
||
}
|
||
} catch (error) {
|
||
return [];
|
||
}
|
||
sessions.sort((a, b) => (b.data.savedAt || 0) - (a.data.savedAt || 0));
|
||
return sessions;
|
||
}
|
||
|
||
function timeAgo(stamp) {
|
||
if (!stamp) { return "date unknown"; }
|
||
const minutes = Math.floor((Date.now() - stamp) / 60000);
|
||
if (minutes < 1) { return "just now"; }
|
||
if (minutes < 60) { return minutes + (minutes === 1 ? " minute ago" : " minutes ago"); }
|
||
const hours = Math.floor(minutes / 60);
|
||
if (hours < 24) { return hours + (hours === 1 ? " hour ago" : " hours ago"); }
|
||
const days = Math.floor(hours / 24);
|
||
if (days <= 30) { return days + (days === 1 ? " day ago" : " days ago"); }
|
||
return new Date(stamp).toLocaleDateString();
|
||
}
|
||
|
||
function renderSessions() {
|
||
const sessions = listSessions();
|
||
const holder = $("sessions");
|
||
const list = $("sessionList");
|
||
holder.hidden = sessions.length === 0;
|
||
list.innerHTML = "";
|
||
|
||
for (const session of sessions) {
|
||
const items = session.data.items;
|
||
const answers = session.data.answers;
|
||
const finished = runSort(items, buildAnswerMap(answers), new Set()).done;
|
||
|
||
const row = document.createElement("div");
|
||
row.className = "session";
|
||
|
||
const about = document.createElement("div");
|
||
about.className = "about";
|
||
const what = document.createElement("div");
|
||
what.className = "what";
|
||
what.textContent = items.length + " films";
|
||
const meta = document.createElement("div");
|
||
meta.className = "meta";
|
||
meta.textContent = (finished
|
||
? "finished in " + answers.length.toLocaleString() + " comparisons"
|
||
: answers.length.toLocaleString() + " of ~" + worstCase(items.length).toLocaleString() + " answered")
|
||
+ " - " + timeAgo(session.data.savedAt);
|
||
const titles = document.createElement("div");
|
||
titles.className = "titles";
|
||
titles.textContent = items.slice(0, 3).map(film => film.title).join(", ") + (items.length > 3 ? ", ..." : "");
|
||
about.appendChild(what);
|
||
about.appendChild(meta);
|
||
about.appendChild(titles);
|
||
|
||
const open = document.createElement("button");
|
||
open.className = "primary";
|
||
open.textContent = finished ? "Open result" : "Continue";
|
||
open.onclick = () => continueSession(session);
|
||
|
||
const drop = document.createElement("button");
|
||
drop.className = "drop-btn";
|
||
drop.textContent = "Delete";
|
||
drop.title = "Forget this ranking";
|
||
drop.onclick = () => {
|
||
if (window.confirm("Delete this saved ranking of " + items.length + " films? This cannot be undone.") === false) { return; }
|
||
try { localStorage.removeItem(session.storageKey); } catch (error) { /* nothing to undo */ }
|
||
renderSessions();
|
||
};
|
||
|
||
row.appendChild(about);
|
||
row.appendChild(open);
|
||
row.appendChild(drop);
|
||
list.appendChild(row);
|
||
}
|
||
}
|
||
|
||
function continueSession(session) {
|
||
header = session.data.header || null;
|
||
films = session.data.items;
|
||
begin(session.key, session.data.items, session.data.answers, session.data.counts, session.data.savedAt);
|
||
resolvePosters();
|
||
}
|
||
|
||
function fileStatus(message, className) {
|
||
const line = document.createElement("div");
|
||
line.textContent = message;
|
||
if (className) { line.className = className; }
|
||
$("fileStatus").appendChild(line);
|
||
}
|
||
|
||
function handleFiles(fileList) {
|
||
const list = Array.from(fileList);
|
||
if (list.length === 0) { return; }
|
||
$("fileStatus").innerHTML = "";
|
||
let pending = list.length;
|
||
for (const file of list) {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
try {
|
||
if (/\.json$/i.test(file.name)) {
|
||
const data = JSON.parse(reader.result);
|
||
posters = {};
|
||
let count = 0;
|
||
for (const uri of Object.keys(data)) {
|
||
if (data[uri]) { posters[uri] = data[uri]; count++; }
|
||
}
|
||
fileStatus(file.name + ": " + count + " poster URLs", "ok");
|
||
} else {
|
||
const parsed = filmsFromCsv(reader.result);
|
||
header = parsed.header;
|
||
films = parsed.films;
|
||
fileStatus(file.name + ": " + films.length + " films", "ok");
|
||
}
|
||
} catch (error) {
|
||
fileStatus(file.name + ": " + error.message, "err");
|
||
}
|
||
pending--;
|
||
if (pending === 0) { afterLoad(); }
|
||
};
|
||
reader.onerror = () => {
|
||
fileStatus(file.name + ": could not be read", "err");
|
||
pending--;
|
||
if (pending === 0) { afterLoad(); }
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
}
|
||
|
||
function afterLoad() {
|
||
if (films === null) {
|
||
fileStatus("Still waiting for a ratings.csv.", "");
|
||
return;
|
||
}
|
||
const key = hashKey(films.map(film => film.key));
|
||
const saved = load(key);
|
||
|
||
$("startTitle").textContent = films.length + " films";
|
||
updateStartLead();
|
||
|
||
const resumeBtn = $("resumeBtn");
|
||
if (saved !== null && saved.answers.length > 0) {
|
||
resumeBtn.hidden = false;
|
||
resumeBtn.textContent = "Resume (" + saved.answers.length + " answered)";
|
||
resumeBtn.onclick = () => begin(key, saved.items, saved.answers, saved.counts, saved.savedAt);
|
||
$("startBtn").className = "";
|
||
$("startBtn").textContent = "Start over";
|
||
} else {
|
||
resumeBtn.hidden = true;
|
||
$("startBtn").className = "primary";
|
||
$("startBtn").textContent = "Start ranking";
|
||
}
|
||
$("startBtn").onclick = () => begin(key, shuffle(films), [], null);
|
||
show("screenStart");
|
||
resolvePosters();
|
||
}
|
||
|
||
function updateStartLead() {
|
||
if (films === null) { return; }
|
||
const withPosters = films.filter(film => posters[film.uri]).length;
|
||
let art;
|
||
if (resolving) { art = "Looking up box art. "; }
|
||
else if (withPosters > 0) { art = withPosters + " of them have box art. "; }
|
||
else { art = "No posters loaded, so films show as text. "; }
|
||
$("startLead").textContent =
|
||
"About " + worstCase(films.length).toLocaleString() + " comparisons. " + art
|
||
+ "Progress saves as you go, so you can stop any time.";
|
||
}
|
||
|
||
// -- Posters from serve.py
|
||
|
||
// A file:// page cannot reach letterboxd.com, so serve.py fetches on the page's behalf.
|
||
async function detectPosterServer() {
|
||
if (location.protocol !== "http:" && location.protocol !== "https:") { return; }
|
||
try {
|
||
const response = await fetch("api/health");
|
||
hasPosterServer = response.ok;
|
||
} catch (error) {
|
||
hasPosterServer = false;
|
||
}
|
||
}
|
||
|
||
async function resolvePosters() {
|
||
await serverProbe;
|
||
if (hasPosterServer === false || resolving || films === null) { return; }
|
||
const missing = films.map(film => film.uri).filter(uri => uri && posters[uri] === undefined);
|
||
if (missing.length === 0) { return; }
|
||
|
||
resolving = true;
|
||
let done = 0;
|
||
updateStartLead();
|
||
for (let i = 0; i < missing.length; i += POSTER_BATCH) {
|
||
const batch = missing.slice(i, i + POSTER_BATCH);
|
||
try {
|
||
const response = await fetch("api/posters", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ uris: batch })
|
||
});
|
||
const data = await response.json();
|
||
for (const uri of Object.keys(data)) {
|
||
posters[uri] = data[uri] || null;
|
||
}
|
||
} catch (error) {
|
||
$("posterStatus").textContent = "poster lookup failed, films show as text";
|
||
break;
|
||
}
|
||
done += batch.length;
|
||
$("posterStatus").textContent = "box art " + done + " / " + missing.length;
|
||
refreshPosters(batch);
|
||
updateStartLead();
|
||
}
|
||
resolving = false;
|
||
updateStartLead();
|
||
const found = films.filter(film => posters[film.uri]).length;
|
||
$("posterStatus").textContent = "box art " + found + " / " + films.length;
|
||
}
|
||
|
||
// Redraw only when a film already on screen just got its poster.
|
||
function refreshPosters(arrived) {
|
||
if (current === null) { return; }
|
||
const fresh = new Set(arrived.filter(uri => posters[uri]));
|
||
if (current.done) {
|
||
if (current.ranked.some(film => fresh.has(film.uri))) { renderRanking(); }
|
||
} else if (current.pair.some(film => fresh.has(film.uri))) {
|
||
renderCard($("pickA"), current.pair[0]);
|
||
renderCard($("pickB"), current.pair[1]);
|
||
}
|
||
}
|
||
|
||
// -- Ranking
|
||
|
||
// savedAt tracks the last answer, not the last time the session was opened, so the list on
|
||
// the load screen keeps its order when you look at an old ranking.
|
||
function begin(key, items, answers, counts, savedAt) {
|
||
state = {
|
||
key: key,
|
||
header: header,
|
||
items: items,
|
||
answers: answers,
|
||
counts: counts || null,
|
||
savedAt: savedAt || Date.now()
|
||
};
|
||
save();
|
||
render();
|
||
}
|
||
|
||
function render() {
|
||
answerMap = buildAnswerMap(state.answers);
|
||
current = runSort(state.items, answerMap, lastAnswerKeys(state.answers));
|
||
if (current.done) { renderResult(); return; }
|
||
|
||
const total = worstCase(state.items.length);
|
||
const answered = state.answers.length;
|
||
$("counter").textContent = answered.toLocaleString() + " / ~" + total.toLocaleString();
|
||
$("progressFill").style.width = Math.min(100, (answered / Math.max(1, total)) * 100) + "%";
|
||
$("undoBtn").disabled = answered === 0;
|
||
renderCard($("pickA"), current.pair[0]);
|
||
renderCard($("pickB"), current.pair[1]);
|
||
renderSidebar();
|
||
show("screenCompare");
|
||
preloadNext();
|
||
}
|
||
|
||
// Everything known so far, best at the top. Each block is ordered within itself; a break
|
||
// between two blocks means their order has not been decided yet.
|
||
function orderedBlocks() {
|
||
const blocks = [];
|
||
for (const merge of current.merges) {
|
||
// Anything already emitted by a merge outranks whatever is left in both of its runs.
|
||
if (merge.out.length > 0) { blocks.push(merge.out); }
|
||
const leftRest = merge.left.slice(merge.i);
|
||
const rightRest = merge.right.slice(merge.j);
|
||
if (leftRest.length > 0) { blocks.push(leftRest); }
|
||
if (rightRest.length > 0) { blocks.push(rightRest); }
|
||
}
|
||
if (current.carry !== null) { blocks.push(current.carry); }
|
||
return blocks;
|
||
}
|
||
|
||
function renderSidebar() {
|
||
// A film nobody has judged yet says nothing about the order, so it stays out of the list.
|
||
const seen = new Set();
|
||
for (const answer of state.answers) {
|
||
seen.add(answer.a);
|
||
seen.add(answer.b);
|
||
}
|
||
seen.add(current.pair[0].key);
|
||
seen.add(current.pair[1].key);
|
||
|
||
const blocks = orderedBlocks()
|
||
.map(block => block.filter(film => seen.has(film.key)))
|
||
.filter(block => block.length > 0);
|
||
|
||
const head = $("sideHead");
|
||
head.innerHTML = "";
|
||
head.appendChild(headLine("Round " + current.round + " of " + roundCount(state.items.length)));
|
||
head.appendChild(headLine(seen.size + " of " + state.items.length + " films seen"));
|
||
head.appendChild(headLine("a break means the order across it is still unknown"));
|
||
|
||
const contenders = new Set([current.pair[0].key, current.pair[1].key]);
|
||
const list = $("sideList");
|
||
list.innerHTML = "";
|
||
let firstPivot = null;
|
||
blocks.forEach((block, index) => {
|
||
if (index > 0) {
|
||
const split = document.createElement("li");
|
||
split.className = "side-split";
|
||
list.appendChild(split);
|
||
}
|
||
block.forEach((film, position) => {
|
||
const row = sideRow(film, block.length > 1 ? position + 1 : null, contenders.has(film.key) ? "pivot" : "");
|
||
if (firstPivot === null && contenders.has(film.key)) { firstPivot = row; }
|
||
list.appendChild(row);
|
||
});
|
||
});
|
||
|
||
const sidebar = $("sidebar");
|
||
if (firstPivot !== null) {
|
||
sidebar.scrollTop = firstPivot.offsetTop - sidebar.clientHeight / 2 + firstPivot.offsetHeight / 2;
|
||
}
|
||
}
|
||
|
||
function sideRow(film, position, className) {
|
||
const item = document.createElement("li");
|
||
if (className) { item.className = className; }
|
||
const number = document.createElement("span");
|
||
number.className = "n";
|
||
number.textContent = position === null ? "" : position + ".";
|
||
const poster = posters[film.uri];
|
||
let art;
|
||
if (poster) {
|
||
art = document.createElement("img");
|
||
art.src = poster;
|
||
art.alt = "";
|
||
art.loading = "lazy";
|
||
} else {
|
||
art = document.createElement("span");
|
||
art.className = "ph";
|
||
}
|
||
const title = document.createElement("span");
|
||
title.className = "t";
|
||
title.textContent = film.title;
|
||
title.title = film.title + (film.year ? " (" + film.year + ")" : "");
|
||
item.appendChild(number);
|
||
item.appendChild(art);
|
||
item.appendChild(title);
|
||
return item;
|
||
}
|
||
|
||
function headLine(text) {
|
||
const line = document.createElement("div");
|
||
line.textContent = text;
|
||
return line;
|
||
}
|
||
|
||
function renderCard(button, film) {
|
||
const poster = posters[film.uri];
|
||
button.innerHTML = "";
|
||
if (poster) {
|
||
const img = document.createElement("img");
|
||
img.className = "poster";
|
||
img.src = poster;
|
||
img.alt = film.title;
|
||
img.onerror = () => { img.replaceWith(blankPoster(film)); };
|
||
button.appendChild(img);
|
||
} else {
|
||
button.appendChild(blankPoster(film));
|
||
}
|
||
const cap = document.createElement("div");
|
||
cap.className = "cap";
|
||
const title = document.createElement("div");
|
||
title.className = "t";
|
||
title.textContent = film.title;
|
||
const year = document.createElement("div");
|
||
year.className = "y";
|
||
year.textContent = film.year;
|
||
cap.appendChild(title);
|
||
cap.appendChild(year);
|
||
button.appendChild(cap);
|
||
}
|
||
|
||
function blankPoster(film) {
|
||
const blank = document.createElement("div");
|
||
blank.className = "poster blank";
|
||
blank.textContent = film.title;
|
||
return blank;
|
||
}
|
||
|
||
// Either answer leads to a known next pair, so fetch both ahead of the click.
|
||
function preloadNext() {
|
||
for (const winner of [current.pair[0].key, current.pair[1].key]) {
|
||
const map = Object.assign(Object.create(null), answerMap);
|
||
map[current.pair[0].key + "|" + current.pair[1].key] = winner;
|
||
map[current.pair[1].key + "|" + current.pair[0].key] = winner;
|
||
const next = runSort(state.items, map, new Set([current.pair[0].key, current.pair[1].key]));
|
||
if (next.done) { continue; }
|
||
for (const film of next.pair) {
|
||
const url = posters[film.uri];
|
||
if (url) { new Image().src = url; }
|
||
}
|
||
}
|
||
}
|
||
|
||
function answer(index) {
|
||
if (current === null || current.done) { return; }
|
||
state.answers.push({ a: current.pair[0].key, b: current.pair[1].key, winner: current.pair[index].key });
|
||
state.savedAt = Date.now();
|
||
save();
|
||
render();
|
||
}
|
||
|
||
function undo() {
|
||
if (state === null || state.answers.length === 0) { return; }
|
||
state.answers.pop();
|
||
state.savedAt = Date.now();
|
||
save();
|
||
render();
|
||
}
|
||
|
||
// -- Star bands
|
||
|
||
function bandValue(band) { return (BAND_COUNT - band) / 2; }
|
||
|
||
function formatRating(value) { return Number.isInteger(value) ? String(value) : value.toFixed(1); }
|
||
|
||
function starGlyphs(value) {
|
||
const full = Math.floor(value);
|
||
return "★".repeat(full) + (value - full >= 0.5 ? "½" : "");
|
||
}
|
||
|
||
function evenCounts(n) {
|
||
const counts = new Array(BAND_COUNT).fill(Math.floor(n / BAND_COUNT));
|
||
for (let i = 0; i < n % BAND_COUNT; i++) { counts[i]++; }
|
||
return counts;
|
||
}
|
||
|
||
// Same shape as the old ratings, so only which film sits where changes.
|
||
function oldRatingCounts(items) {
|
||
const counts = new Array(BAND_COUNT).fill(0);
|
||
const middle = BAND_COUNT - Math.round(3 * 2);
|
||
for (const item of items) {
|
||
const value = item.oldRating;
|
||
const band = Number.isFinite(value) ? BAND_COUNT - Math.round(value * 2) : middle;
|
||
counts[Math.min(BAND_COUNT - 1, Math.max(0, band))]++;
|
||
}
|
||
return counts;
|
||
}
|
||
|
||
function topHeavyCounts(n) {
|
||
const weights = [3, 6, 11, 15, 18, 17, 13, 9, 5, 3];
|
||
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
||
const counts = weights.map(weight => Math.floor(n * weight / total));
|
||
let left = n - counts.reduce((sum, count) => sum + count, 0);
|
||
for (let i = 0; left > 0; i = (i + 1) % BAND_COUNT) { counts[i]++; left--; }
|
||
return counts;
|
||
}
|
||
|
||
function ensureCounts() {
|
||
const n = current.ranked.length;
|
||
const counts = state.counts;
|
||
if (Array.isArray(counts) && counts.length === BAND_COUNT
|
||
&& counts.reduce((sum, count) => sum + count, 0) === n) {
|
||
return;
|
||
}
|
||
const hasOld = current.ranked.some(film => Number.isFinite(film.oldRating));
|
||
state.counts = hasOld ? oldRatingCounts(current.ranked) : evenCounts(n);
|
||
save();
|
||
}
|
||
|
||
function setCounts(counts) {
|
||
state.counts = counts;
|
||
save();
|
||
renderResult();
|
||
}
|
||
|
||
// Moving a boundary takes from, or gives to, the neighbouring bands below it.
|
||
function nudge(band, delta) {
|
||
const counts = state.counts.slice();
|
||
if (delta > 0) {
|
||
let donor = -1;
|
||
for (let i = band + 1; i < BAND_COUNT; i++) { if (counts[i] > 0) { donor = i; break; } }
|
||
if (donor < 0) { return; }
|
||
counts[band]++;
|
||
counts[donor]--;
|
||
} else {
|
||
if (counts[band] === 0) { return; }
|
||
const target = band + 1 < BAND_COUNT ? band + 1 : band - 1;
|
||
counts[band]--;
|
||
counts[target]++;
|
||
}
|
||
setCounts(counts);
|
||
}
|
||
|
||
function setBandCount(band, wanted) {
|
||
const n = current.ranked.length;
|
||
const counts = state.counts.slice();
|
||
const value = Math.max(0, Math.min(n, Math.floor(wanted)));
|
||
let delta = value - counts[band];
|
||
counts[band] = value;
|
||
// Pull the difference from the bands below, then from the bands above.
|
||
const order = [];
|
||
for (let i = band + 1; i < BAND_COUNT; i++) { order.push(i); }
|
||
for (let i = band - 1; i >= 0; i--) { order.push(i); }
|
||
for (const other of order) {
|
||
if (delta === 0) { break; }
|
||
if (delta > 0) {
|
||
const take = Math.min(delta, counts[other]);
|
||
counts[other] -= take;
|
||
delta -= take;
|
||
} else {
|
||
counts[other] -= delta;
|
||
delta = 0;
|
||
}
|
||
}
|
||
if (delta !== 0) { counts[band] -= delta; }
|
||
setCounts(counts);
|
||
}
|
||
|
||
function bandOfIndex() {
|
||
const map = new Array(current.ranked.length);
|
||
let index = 0;
|
||
for (let band = 0; band < BAND_COUNT; band++) {
|
||
for (let i = 0; i < state.counts[band]; i++) { map[index++] = band; }
|
||
}
|
||
for (; index < map.length; index++) { map[index] = BAND_COUNT - 1; }
|
||
return map;
|
||
}
|
||
|
||
// -- Result
|
||
|
||
function renderResult() {
|
||
ensureCounts();
|
||
const ranked = current.ranked;
|
||
$("resultTitle").textContent =
|
||
"Your ranking - " + ranked.length + " films in " + state.answers.length.toLocaleString() + " comparisons";
|
||
renderBands();
|
||
renderRanking();
|
||
show("screenResult");
|
||
}
|
||
|
||
function renderBands() {
|
||
const holder = $("bands");
|
||
holder.innerHTML = "";
|
||
let start = 1;
|
||
for (let band = 0; band < BAND_COUNT; band++) {
|
||
const count = state.counts[band];
|
||
const cell = document.createElement("div");
|
||
cell.className = "band-cell";
|
||
|
||
const stars = document.createElement("div");
|
||
stars.className = "stars";
|
||
stars.textContent = starGlyphs(bandValue(band));
|
||
const value = document.createElement("span");
|
||
value.className = "value";
|
||
value.textContent = " " + formatRating(bandValue(band));
|
||
stars.appendChild(value);
|
||
|
||
const ctrl = document.createElement("div");
|
||
ctrl.className = "ctrl";
|
||
const minus = document.createElement("button");
|
||
minus.textContent = "−";
|
||
minus.onclick = () => nudge(band, -1);
|
||
const input = document.createElement("input");
|
||
input.type = "number";
|
||
input.min = "0";
|
||
input.value = String(count);
|
||
input.onchange = () => setBandCount(band, parseInt(input.value, 10) || 0);
|
||
const plus = document.createElement("button");
|
||
plus.textContent = "+";
|
||
plus.onclick = () => nudge(band, 1);
|
||
ctrl.appendChild(minus);
|
||
ctrl.appendChild(input);
|
||
ctrl.appendChild(plus);
|
||
|
||
const range = document.createElement("div");
|
||
range.className = "range";
|
||
range.textContent = count > 0 ? "#" + start + " - #" + (start + count - 1) : "empty";
|
||
|
||
cell.appendChild(stars);
|
||
cell.appendChild(ctrl);
|
||
cell.appendChild(range);
|
||
holder.appendChild(cell);
|
||
start += count;
|
||
}
|
||
}
|
||
|
||
function renderRanking() {
|
||
const list = $("ranking");
|
||
const bands = bandOfIndex();
|
||
list.innerHTML = "";
|
||
let lastBand = -1;
|
||
current.ranked.forEach((film, index) => {
|
||
if (bands[index] !== lastBand) {
|
||
lastBand = bands[index];
|
||
const head = document.createElement("li");
|
||
head.className = "band-head";
|
||
const label = document.createElement("span");
|
||
label.className = "n";
|
||
label.textContent = formatRating(bandValue(lastBand));
|
||
const stars = document.createElement("span");
|
||
stars.textContent = starGlyphs(bandValue(lastBand)) + " (" + state.counts[lastBand] + ")";
|
||
head.appendChild(label);
|
||
head.appendChild(stars);
|
||
list.appendChild(head);
|
||
}
|
||
list.appendChild(rankingRow(film, index));
|
||
});
|
||
}
|
||
|
||
function rankingRow(film, index) {
|
||
const item = document.createElement("li");
|
||
const number = document.createElement("span");
|
||
number.className = "n";
|
||
number.textContent = (index + 1) + ".";
|
||
const poster = posters[film.uri];
|
||
let art;
|
||
if (poster) {
|
||
art = document.createElement("img");
|
||
art.src = poster;
|
||
art.alt = "";
|
||
art.loading = "lazy";
|
||
} else {
|
||
art = document.createElement("span");
|
||
art.className = "ph";
|
||
}
|
||
const label = document.createElement("span");
|
||
if (film.uri) {
|
||
const link = document.createElement("a");
|
||
link.href = film.uri;
|
||
link.target = "_blank";
|
||
link.rel = "noreferrer";
|
||
link.textContent = film.title;
|
||
label.appendChild(link);
|
||
} else {
|
||
label.appendChild(document.createTextNode(film.title));
|
||
}
|
||
if (film.year) {
|
||
const year = document.createElement("span");
|
||
year.style.color = "var(--muted)";
|
||
year.textContent = " " + film.year;
|
||
label.appendChild(year);
|
||
}
|
||
item.appendChild(number);
|
||
item.appendChild(art);
|
||
item.appendChild(label);
|
||
return item;
|
||
}
|
||
|
||
// -- Export
|
||
|
||
function rankingText() {
|
||
const bands = bandOfIndex();
|
||
return current.ranked.map((film, index) =>
|
||
(index + 1) + ". " + film.title + (film.year ? " (" + film.year + ")" : "")
|
||
+ " - " + formatRating(bandValue(bands[index]))).join("\n");
|
||
}
|
||
|
||
// Input header and columns, in ranking order, with Rating replaced by the new value.
|
||
function rankingCsv() {
|
||
const escape = value => /[",\n]/.test(value) ? '"' + value.replace(/"/g, '""') + '"' : value;
|
||
const head = (state.header || ["Name", "Year", "Letterboxd URI", "Rating"]).slice();
|
||
let ratingAt = head.findIndex(cell => cell.trim().toLowerCase() === "rating");
|
||
if (ratingAt < 0) { head.push("Rating"); ratingAt = head.length - 1; }
|
||
const bands = bandOfIndex();
|
||
const lines = [head.map(escape).join(",")];
|
||
current.ranked.forEach((film, index) => {
|
||
const row = (film.row || [film.title, film.year, film.uri]).slice(0, head.length);
|
||
while (row.length < head.length) { row.push(""); }
|
||
row[ratingAt] = formatRating(bandValue(bands[index]));
|
||
lines.push(row.map(escape).join(","));
|
||
});
|
||
return lines.join("\n");
|
||
}
|
||
|
||
function download(name, text) {
|
||
const url = URL.createObjectURL(new Blob([text], { type: "text/csv;charset=utf-8" }));
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = name;
|
||
link.click();
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
}
|
||
|
||
function copyText(text) {
|
||
const fallback = () => {
|
||
const area = document.createElement("textarea");
|
||
area.value = text;
|
||
document.body.appendChild(area);
|
||
area.select();
|
||
try { document.execCommand("copy"); } catch (error) { /* nothing else to try */ }
|
||
area.remove();
|
||
};
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(text).catch(fallback);
|
||
} else {
|
||
fallback();
|
||
}
|
||
$("copyNote").textContent = "Copied.";
|
||
setTimeout(() => { $("copyNote").textContent = ""; }, 2000);
|
||
}
|
||
|
||
// -- Wiring
|
||
|
||
const serverProbe = detectPosterServer().then(() => {
|
||
if (hasPosterServer) {
|
||
$("dropLead").textContent = "Box art is fetched for you, so the csv on its own is enough.";
|
||
$("posterStatus").textContent = "box art server connected";
|
||
}
|
||
});
|
||
|
||
$("pickBtn").onclick = () => $("fileInput").click();
|
||
$("fileInput").onchange = event => handleFiles(event.target.files);
|
||
$("pickA").onclick = () => answer(0);
|
||
$("pickB").onclick = () => answer(1);
|
||
let theme = "dark";
|
||
|
||
function applyTheme(next) {
|
||
theme = next;
|
||
if (theme === "light") {
|
||
document.documentElement.setAttribute("data-theme", "light");
|
||
} else {
|
||
document.documentElement.removeAttribute("data-theme");
|
||
}
|
||
$("themeBtn").textContent = theme === "light" ? "Dark mode" : "Light mode";
|
||
try { localStorage.setItem("lbx-ranker-theme", theme); } catch (error) { /* not important enough to matter */ }
|
||
}
|
||
|
||
try {
|
||
applyTheme(localStorage.getItem("lbx-ranker-theme") === "light" ? "light" : "dark");
|
||
} catch (error) {
|
||
applyTheme("dark");
|
||
}
|
||
$("themeBtn").onclick = () => applyTheme(theme === "light" ? "dark" : "light");
|
||
|
||
renderSessions();
|
||
|
||
$("undoBtn").onclick = undo;
|
||
$("listBtn").onclick = () => {
|
||
const hidden = $("sidebar").classList.toggle("hidden");
|
||
$("listBtn").textContent = hidden ? "Show list" : "Hide list";
|
||
try { localStorage.setItem("lbx-ranker-sidebar", hidden ? "hidden" : "shown"); } catch (error) { /* not important enough to matter */ }
|
||
};
|
||
try {
|
||
if (localStorage.getItem("lbx-ranker-sidebar") === "hidden") { $("listBtn").click(); }
|
||
} catch (error) { /* not important enough to matter */ }
|
||
$("quitBtn").onclick = () => { if (current !== null && current.ranked.length > 1) { renderResult(); } else { show("screenStart"); } };
|
||
$("backBtn").onclick = () => { renderSessions(); show("screenLoad"); };
|
||
$("newBtn").onclick = () => { renderSessions(); show("screenLoad"); };
|
||
$("againBtn").onclick = () => begin(state.key, shuffle(state.items), [], null);
|
||
$("copyBtn").onclick = () => copyText(rankingText());
|
||
$("csvBtn").onclick = () => download("ranked.csv", rankingCsv());
|
||
$("presetEven").onclick = () => setCounts(evenCounts(current.ranked.length));
|
||
$("presetOld").onclick = () => setCounts(oldRatingCounts(current.ranked));
|
||
$("presetTop").onclick = () => setCounts(topHeavyCounts(current.ranked.length));
|
||
|
||
const drop = $("drop");
|
||
for (const type of ["dragenter", "dragover"]) {
|
||
drop.addEventListener(type, event => { event.preventDefault(); drop.classList.add("over"); });
|
||
}
|
||
for (const type of ["dragleave", "drop"]) {
|
||
drop.addEventListener(type, event => { event.preventDefault(); drop.classList.remove("over"); });
|
||
}
|
||
drop.addEventListener("drop", event => { if (event.dataTransfer) { handleFiles(event.dataTransfer.files); } });
|
||
window.addEventListener("dragover", event => event.preventDefault());
|
||
window.addEventListener("drop", event => event.preventDefault());
|
||
|
||
document.addEventListener("keydown", event => {
|
||
if ($("screenCompare").classList.contains("active") === false) { return; }
|
||
if (event.key === "1" || event.key === "ArrowLeft") { event.preventDefault(); answer(0); }
|
||
else if (event.key === "2" || event.key === "ArrowRight") { event.preventDefault(); answer(1); }
|
||
else if (event.key === "u" || event.key === "U" || event.key === "Backspace") { event.preventDefault(); undo(); }
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|