From b97edf02c5b6839e21aff30a59df12e54fad8c61 Mon Sep 17 00:00:00 2001 From: Daan Meijer Date: Fri, 9 Oct 2026 10:47:59 +0200 Subject: [PATCH] Rebuild: the web interface, from voyager 1 Voyager 1's page (search, sort, filters, poster filter, detail drawer, NZB download; no framework, Usenet text only as textContent) on the new catalog. Gone for now, as voyager 2 doesn't know them yet: passwords, encrypted containers, spots, par2 lists, archive contents, the extension filter (phase 2). New: a category filter, 'Collecting' for releases not settled yet, history and live-tail progress in the header, a release's files from its chunks. Co-Authored-By: Claude Opus 5.5 --- public/app.css | 844 ++++++++++++++++++++++++++++++++++++++++++++++ public/app.js | 455 +++++++++++++++++++++++++ public/index.html | 113 +++++++ 3 files changed, 1412 insertions(+) create mode 100644 public/app.css create mode 100644 public/app.js create mode 100644 public/index.html diff --git a/public/app.css b/public/app.css new file mode 100644 index 0000000..7156910 --- /dev/null +++ b/public/app.css @@ -0,0 +1,844 @@ +:root { + color-scheme: light dark; + --bg: #f6f7f9; + --surface: #ffffff; + --surface-2: #f0f2f5; + --border: #e1e4ea; + --text: #1b1f27; + --muted: #5f6878; + --faint: #8a93a3; + --accent: #2f6fed; + --accent-soft: #e7eefe; + --good: #1f8a4c; + --good-soft: #e3f4ea; + --warn: #9a6400; + --warn-soft: #fbf0d9; + --bad: #b3261e; + --bad-soft: #fbe6e4; + --shadow: 0 10px 30px rgba(20, 28, 45, 0.12); + --radius: 10px; + --mono: ui-monospace, 'SF Mono', 'Cascadia Code', 'JetBrains Mono', Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f1216; + --surface: #161a20; + --surface-2: #1d222a; + --border: #2a303a; + --text: #e6e9ef; + --muted: #a0a8b6; + --faint: #737c8c; + --accent: #6e9bff; + --accent-soft: #1c2740; + --good: #5fcf8e; + --good-soft: #15291e; + --warn: #e8b457; + --warn-soft: #2d2413; + --bad: #f28b82; + --bad-soft: #321a19; + --shadow: 0 10px 30px rgba(0, 0, 0, 0.45); + } +} + +* { + box-sizing: border-box; +} + +/* Elements with their own display value must still honour the hidden attribute. */ +[hidden] { + display: none !important; +} + +html, +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 15px/1.45 var(--sans); +} + +button, +input, +select { + font: inherit; + color: inherit; +} + +a { + color: var(--accent); +} + +svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + flex: none; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* --- Top bar ----------------------------------------------------------- */ + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + padding: 14px 24px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 650; + color: var(--text); + text-decoration: none; + letter-spacing: -0.01em; +} + +.brand-mark { + width: 22px; + height: 22px; + border-radius: 6px; + background: + linear-gradient(var(--surface), var(--surface)) 5px 6px / 12px 2px no-repeat, + linear-gradient(var(--surface), var(--surface)) 5px 10px / 12px 2px no-repeat, + linear-gradient(var(--surface), var(--surface)) 5px 14px / 8px 2px no-repeat, + var(--accent); +} + +.stats { + display: flex; + gap: 22px; + margin: 0; + flex-wrap: wrap; +} + +.stats div { + display: flex; + flex-direction: column; +} + +.stats dt { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--faint); +} + +.stats dd { + margin: 0; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +/* --- Search ------------------------------------------------------------ */ + +.layout { + max-width: 1180px; + margin: 0 auto; + padding: 22px 24px 60px; +} + +.search-row { + display: flex; + gap: 10px; +} + +.search-box { + flex: 1; + display: flex; + align-items: center; + gap: 10px; + padding: 0 14px; + height: 48px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--muted); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.search-box:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.search-box input { + flex: 1; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + font-size: 16px; + color: var(--text); +} + +kbd { + font: 12px var(--mono); + padding: 1px 6px; + border: 1px solid var(--border); + border-radius: 5px; + color: var(--faint); +} + +.select select { + height: 48px; + padding: 0 34px 0 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface) + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238a93a3' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") + no-repeat right 10px center / 16px; + appearance: none; + cursor: pointer; +} + +.select-small select { + height: 32px; + border-radius: 999px; + font-size: 13px; +} + +.filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 12px; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 13px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + font-size: 13px; + cursor: pointer; +} + +/* A chip holding inputs: size bounds and file extensions. */ +.field-chip { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 6px 0 13px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + font-size: 13px; + color: var(--muted); +} + +.field-chip:focus-within { + border-color: var(--accent); +} + +.field-chip input { + width: 64px; + height: 24px; + padding: 0 6px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-2); + color: var(--text); + font: inherit; +} + +.field-chip input.ext { + width: 96px; +} + +.field-chip .unit { + padding-right: 7px; +} + +.chip[aria-pressed='true'], +.chip-active { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--accent); +} + +.chip-active button { + border: 0; + background: none; + cursor: pointer; + font-size: 16px; + line-height: 1; + color: inherit; + padding: 0 0 0 2px; +} + +/* --- Results ----------------------------------------------------------- */ + +.summary { + margin: 20px 2px 10px; + color: var(--muted); + font-size: 13px; +} + +/* While a new search runs, the previous results stay in place but greyed out. */ +.release-list.stale { + opacity: 0.4; + pointer-events: none; + transition: opacity 0.12s ease-out; +} + +.results[aria-busy='true'] .summary::after { + content: ''; + display: block; + height: 2px; + margin-top: 6px; + border-radius: 1px; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + background-size: 40% 100%; + background-repeat: no-repeat; + animation: searching 1s linear infinite; +} + +@keyframes searching { + from { + background-position: -40% 0; + } + to { + background-position: 140% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .results[aria-busy='true'] .summary::after { + animation: none; + background: var(--accent-soft); + } +} + +.release-list { + list-style: none; + margin: 0; + padding: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.release-list:empty { + display: none; +} + +.release { + display: flex; + align-items: center; + gap: 14px; + border-top: 1px solid var(--border); +} + +.release:first-child { + border-top: 0; +} + +.release:hover, +.release.selected { + background: var(--surface-2); +} + +.release-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; + padding: 12px 0 12px 16px; + border: 0; + background: none; + text-align: left; + cursor: pointer; +} + +.release-main:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.release-name { + font-weight: 560; + overflow-wrap: anywhere; +} + +.release-name.obfuscated { + font-family: var(--mono); + font-size: 13px; + font-weight: 450; + color: var(--muted); +} + +.release-meta { + font-size: 12.5px; + color: var(--faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.release-side { + display: flex; + align-items: center; + gap: 12px; + padding-right: 10px; + flex: none; +} + +.release-size { + width: 76px; + text-align: right; + font-variant-numeric: tabular-nums; + font-size: 13.5px; + color: var(--muted); +} + +.badges { + display: flex; + gap: 6px; + justify-content: flex-end; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + height: 22px; + padding: 0 8px; + border-radius: 6px; + font-size: 11.5px; + font-weight: 560; + white-space: nowrap; + background: var(--surface-2); + color: var(--muted); +} + +.badge-good { + background: var(--good-soft); + color: var(--good); +} + +.badge-warn { + background: var(--warn-soft); + color: var(--warn); +} + +.badge-bad { + background: var(--bad-soft); + color: var(--bad); +} + +.badge-accent { + background: var(--accent-soft); + color: var(--accent); +} + +.icon-button { + display: inline-grid; + place-items: center; + width: 34px; + height: 34px; + border: 1px solid transparent; + border-radius: 8px; + background: none; + color: var(--muted); + cursor: pointer; +} + +.icon-button:hover { + border-color: var(--border); + background: var(--surface); + color: var(--text); +} + +.more { + display: flex; + justify-content: center; + margin-top: 16px; +} + +.button { + display: inline-flex; + align-items: center; + gap: 8px; + height: 38px; + padding: 0 16px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + font-weight: 560; + font-size: 14px; + text-decoration: none; + color: var(--text); + cursor: pointer; +} + +.button:hover { + border-color: var(--faint); +} + +.button-primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.button-primary:hover { + filter: brightness(1.07); + border-color: var(--accent); +} + +.empty { + text-align: center; + color: var(--muted); + padding: 40px 0; +} + +/* --- Detail drawer ----------------------------------------------------- */ + +.scrim { + position: fixed; + inset: 0; + background: rgba(10, 14, 20, 0.35); + z-index: 10; +} + +.drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(640px, 100%); + display: flex; + flex-direction: column; + background: var(--surface); + border-left: 1px solid var(--border); + box-shadow: var(--shadow); + z-index: 11; + animation: slide-in 0.18s ease-out; +} + +@keyframes slide-in { + from { + transform: translateX(24px); + opacity: 0; + } +} + +.drawer-head { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 20px 20px 14px 24px; + border-bottom: 1px solid var(--border); +} + +.drawer-title { + flex: 1; + min-width: 0; +} + +.eyebrow { + margin: 0 0 4px; + font-size: 11.5px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--faint); +} + +.drawer h2 { + margin: 0; + font-size: 18px; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.drawer-body { + flex: 1; + overflow-y: auto; + padding: 18px 24px 40px; +} + +.actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 18px; +} + +.facts { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 12px 18px; + margin: 0 0 22px; +} + +.facts dt { + font-size: 11.5px; + color: var(--faint); +} + +.facts dd { + margin: 2px 0 0; + font-weight: 560; + overflow-wrap: anywhere; +} + +.link-button { + border: 0; + padding: 0; + background: none; + color: var(--accent); + cursor: pointer; + text-align: left; + font-weight: 560; +} + +.notice { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 11px 14px; + margin-bottom: 18px; + border-radius: 8px; + font-size: 13.5px; +} + +.notice-bad { + background: var(--bad-soft); + color: var(--bad); +} + +.notice-good { + background: var(--good-soft); + color: var(--good); +} + +.notice .password { + padding: 1px 6px; + border-radius: 4px; + background: var(--surface); + color: var(--text); + font-family: var(--mono); + user-select: all; + overflow-wrap: anywhere; +} + +.spot { + display: flex; + gap: 16px; + align-items: flex-start; +} + +.spot-image { + flex: none; + width: 140px; + height: auto; + border-radius: 6px; + background: var(--surface-2); +} + +.spot-body { + flex: 1; + min-width: 0; +} + +.spot-body .facts { + margin-bottom: 14px; +} + +.spot-description { + font-size: 13.5px; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.spot-description a, +.spot-links a { + color: var(--accent); +} + +.spot-links { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 12px; + font-size: 13px; + overflow-wrap: anywhere; +} + +@media (max-width: 560px) { + .spot { + flex-direction: column; + } +} + +.notice-warn { + background: var(--warn-soft); + color: var(--warn); +} + +.progress { + height: 6px; + margin-top: 8px; + border-radius: 99px; + background: var(--border); + overflow: hidden; +} + +.progress span { + display: block; + height: 100%; + background: currentColor; +} + +.section-title { + display: flex; + align-items: baseline; + justify-content: space-between; + margin: 24px 0 8px; + font-size: 13px; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); +} + +.section-title small { + font-weight: 500; + text-transform: none; + letter-spacing: 0; + color: var(--faint); +} + +.file-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.file-table td { + padding: 7px 8px 7px 0; + border-top: 1px solid var(--border); + vertical-align: top; +} + +.file-table tr:first-child td { + border-top: 0; +} + +.file-table .name { + font-family: var(--mono); + font-size: 12.5px; + overflow-wrap: anywhere; +} + +.file-table .posted { + display: block; + margin-top: 2px; + font-size: 11.5px; + color: var(--faint); +} + +.file-table .num { + text-align: right; + white-space: nowrap; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.file-table .state { + width: 22px; + text-align: center; +} + +.ok { + color: var(--good); +} + +.missing { + color: var(--faint); +} + +.skeleton { + height: 14px; + margin: 10px 0; + border-radius: 4px; + background: linear-gradient(90deg, var(--surface-2), var(--border), var(--surface-2)); + background-size: 200% 100%; + animation: shimmer 1.2s infinite linear; +} + +@keyframes shimmer { + to { + background-position: -200% 0; + } +} + +/* --- Small screens ----------------------------------------------------- */ + +@media (max-width: 720px) { + .topbar, + .layout { + padding-left: 14px; + padding-right: 14px; + } + + .stats { + gap: 14px; + } + + .search-row { + flex-direction: column; + } + + .release { + flex-wrap: wrap; + gap: 0; + } + + .release-main { + padding-right: 16px; + } + + .release-side { + width: 100%; + padding: 0 10px 10px 16px; + justify-content: flex-start; + } + + .release-size { + width: auto; + margin-left: auto; + } + + kbd { + display: none; + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..71bd7fd --- /dev/null +++ b/public/app.js @@ -0,0 +1,455 @@ +// Voyager 2's web interface (from voyager 1's). No framework; all Usenet-derived +// text is rendered with textContent, never as HTML. + +const $ = (selector) => document.querySelector(selector) +const FILTERS = ['readable', 'complete'] +/** Text inputs that narrow a search: size bounds in MB. */ +const FIELDS = ['min_size', 'max_size'] +const SOURCE_LABELS = { + stem: 'Filename', + subject: 'Subject', + post: 'Post id', +} +/** Newznab categories (lib/newznab/categories.ts). */ +const CATEGORIES = [ + [2000, 'Movies', [[2030, 'SD'], [2040, 'HD'], [2045, 'UHD']]], + [3000, 'Audio', [[3010, 'MP3'], [3040, 'Lossless']]], + [5000, 'TV', [[5030, 'SD'], [5040, 'HD'], [5045, 'UHD']]], + [7000, 'Books', [[7020, 'Ebook']]], + [8000, 'Other', [[8010, 'Misc']]], +] +function categoryLabel(id) { + for (const [parent, name, subs] of CATEGORIES) { + if (Math.floor(id / 1000) * 1000 !== parent) continue + const sub = subs.find(([s]) => s === id) + return sub ? `${name} > ${sub[1]}` : name + } + return 'Other' +} + +const state = { + q: '', + sort: 'newest', + source: '', + cat: '', + poster: '', + min_size: '', + max_size: '', + readable: false, + complete: false, + page: 1, + release: null, +} +let requestId = 0 +let searchAbort = null + +// --- Formatting ------------------------------------------------------------ + +function formatSize(bytes) { + if (!bytes) return '–' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let value = bytes + let unit = 0 + while (value >= 1000 && unit < units.length - 1) { + value /= 1000 + unit++ + } + return `${value >= 100 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}` +} + +const number = new Intl.NumberFormat() + +function formatAge(date) { + if (!date) return '' + const seconds = (Date.now() - new Date(date).getTime()) / 1000 + if (seconds < 90) return 'just now' + const steps = [ + [60, 'minute'], + [3600, 'hour'], + [86400, 'day'], + [2592000, 'month'], + [31536000, 'year'], + ] + let [size, name] = steps[0] + for (const step of steps) if (seconds >= step[0]) [size, name] = step + const n = Math.floor(seconds / size) + return `${n} ${name}${n === 1 ? '' : 's'} ago` +} + +function el(tag, props = {}, ...children) { + const node = document.createElement(tag) + for (const [key, value] of Object.entries(props)) { + if (value === undefined || value === null || value === false) continue + if (key === 'class') node.className = value + else if (key === 'text') node.textContent = value + else if (key.startsWith('on')) node.addEventListener(key.slice(2), value) + else node.setAttribute(key, value === true ? '' : value) + } + for (const child of children.flat()) { + if (child === null || child === undefined || child === false) continue + node.append(child instanceof Node ? child : document.createTextNode(String(child))) + } + return node +} + +function badge(text, tone = '', title) { + return el('span', { class: `badge${tone ? ` badge-${tone}` : ''}`, text, title }) +} + +// --- URL state ------------------------------------------------------------- + +function readUrl() { + const params = new URLSearchParams(location.search) + state.q = params.get('q') ?? '' + state.sort = params.get('sort') ?? 'newest' + state.source = params.get('source') ?? '' + state.cat = params.get('cat') ?? '' + state.poster = params.get('poster') ?? '' + for (const k of FIELDS) state[k] = params.get(k) ?? '' + for (const f of FILTERS) state[f] = params.get(f) === '1' + state.release = params.get('release') ? Number(params.get('release')) : null +} + +function writeUrl(push = false) { + const params = new URLSearchParams() + if (state.q) params.set('q', state.q) + if (state.sort !== 'newest') params.set('sort', state.sort) + if (state.source) params.set('source', state.source) + if (state.cat) params.set('cat', state.cat) + if (state.poster) params.set('poster', state.poster) + for (const k of FIELDS) if (state[k]) params.set(k, state[k]) + for (const f of FILTERS) if (state[f]) params.set(f, '1') + if (state.release) params.set('release', String(state.release)) + const url = `${location.pathname}${params.size ? `?${params}` : ''}` + history[push ? 'pushState' : 'replaceState'](null, '', url) +} + +function syncControls() { + $('#q').value = state.q + $('#sort').value = state.sort + $('#source').value = state.source + $('#cat').value = state.cat + for (const k of FIELDS) $(`#${k}`).value = state[k] + for (const f of FILTERS) { + document.querySelector(`[data-filter="${f}"]`).setAttribute('aria-pressed', String(state[f])) + } + $('#poster-filter').hidden = !state.poster + $('#poster-filter-label').textContent = `Poster: ${state.poster}` +} + +// --- API ------------------------------------------------------------------- + +async function api(path, { signal } = {}) { + const response = await fetch(path, { headers: { accept: 'application/json' }, signal }) + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`) + return response.json() +} + +function searchParams(page) { + const params = new URLSearchParams({ sort: state.sort, page: String(page), per_page: '50' }) + if (state.q) params.set('q', state.q) + if (state.source) params.set('source', state.source) + if (state.cat) params.set('cat', state.cat) + if (state.poster) params.set('poster', state.poster) + for (const k of FIELDS) if (state[k]) params.set(k, state[k]) + for (const f of FILTERS) if (state[f]) params.set(f, '1') + return params +} + +// --- Stats ----------------------------------------------------------------- + +async function loadStats() { + try { + const stats = await api('/api/v1/stats') + const readable = stats.releases ? Math.round((100 * stats.readableReleases) / stats.releases) : 0 + // Estimated from Postgres statistics and a sample. + const about = stats.approximate ? '≈ ' : '' + const history = stats.ranges.history ? (100 * stats.ranges.historyDone) / stats.ranges.history : 0 + const items = [ + ['Releases', about + number.format(stats.releases)], + ['Readable', `${about}${readable}%`], + ['Files', about + number.format(stats.files)], + ['Size', about + formatSize(stats.size)], + ['History', `${history < 10 ? history.toFixed(1) : Math.round(history)}%`], + ['Live', formatAge(stats.tailIndexedAt) || '–'], + ] + $('#stats').replaceChildren(...items.map(([dt, dd]) => el('div', {}, el('dt', { text: dt }), el('dd', { text: dd })))) + } catch { + // Stats are decoration; the page works without them. + } +} + +// --- Results --------------------------------------------------------------- + +function releaseRow(release) { + const row = $('#row-template').content.firstElementChild.cloneNode(true) + row.dataset.id = release.id + if (release.id === state.release) row.classList.add('selected') + + const name = row.querySelector('.release-name') + name.textContent = release.name + if (!release.readable) name.classList.add('obfuscated') + + const meta = [ + formatAge(release.postedAt), + release.poster ? release.poster.replace(/\s*<.*>$/, '') : 'obfuscated poster', + release.groups?.length ? release.groups[0] + (release.groups.length > 1 ? ` +${release.groups.length - 1}` : '') : '', + ].filter(Boolean) + row.querySelector('.release-meta').textContent = meta.join(' · ') + + const badges = row.querySelector('.badges') + const files = `${release.fileCount} ${release.fileCount === 1 ? 'file' : 'files'}` + if (release.complete) badges.append(badge(files, 'good', 'Complete')) + else if (release.fileTotal) badges.append(badge(`${release.fileCount}/${release.fileTotal}`, 'warn', 'Files present / announced')) + else badges.append(badge(files)) + if (!release.settled) badges.append(badge('Collecting', 'accent', 'Recently seen: more files may still arrive')) + if (release.category && release.category !== 8010) badges.append(badge(categoryLabel(release.category), '', 'Category (from the name)')) + if (release.source && release.source !== 'stem' && release.source !== 'subject') { + badges.append(badge(SOURCE_LABELS[release.source] ?? release.source, 'accent', 'How this release was recognised')) + } + + row.querySelector('.release-size').textContent = formatSize(release.size) + const nzb = row.querySelector('.nzb') + nzb.href = `/api/v1/releases/${release.id}/nzb` + row.querySelector('.release-main').addEventListener('click', () => openRelease(release.id, true)) + return row +} + +async function loadResults({ append = false } = {}) { + const id = ++requestId + if (!append) state.page = 1 + // A newer search makes the running one pointless: cancel it. + searchAbort?.abort() + const abort = (searchAbort = new AbortController()) + $('#more').hidden = true + if (!append) { + $('#summary').textContent = 'Searching…' + $('#empty').hidden = true + // The old rows no longer match the search; grey them out until the new ones arrive. + $('#list').classList.add('stale') + $('.results').setAttribute('aria-busy', 'true') + } + + try { + const result = await api(`/api/v1/releases?${searchParams(state.page)}`, { signal: abort.signal }) + if (id !== requestId) return // a newer search superseded this one + $('#list').classList.remove('stale') + $('.results').removeAttribute('aria-busy') + const rows = result.releases.map(releaseRow) + if (append) $('#list').append(...rows) + else $('#list').replaceChildren(...rows) + + const shown = $('#list').children.length + $('#summary').textContent = + result.total === 0 ? '' : `${number.format(result.total)}${result.totalCapped ? '+' : ''} ${result.total === 1 ? 'release' : 'releases'}` + + (shown < result.total ? ` · showing ${number.format(shown)}` : '') + $('#empty').hidden = result.total !== 0 + $('#more').hidden = shown >= result.total && !result.totalCapped + } catch (error) { + if (id !== requestId) return + $('#list').classList.remove('stale') + $('.results').removeAttribute('aria-busy') + $('#summary').textContent = `Search failed: ${error.message}` + } +} + +let debounce +function scheduleSearch() { + clearTimeout(debounce) + debounce = setTimeout(() => { + writeUrl() + loadResults() + }, 220) +} + +// --- Detail drawer --------------------------------------------------------- + +function fact(label, value) { + if (value === null || value === undefined || value === '') return null + return el('div', {}, el('dt', { text: label }), el('dd', {}, value)) +} + +function section(title, note, content) { + return [el('h3', { class: 'section-title' }, title, note ? el('small', { text: note }) : null), content] +} + +function renderDetail(release) { + $('#detail-name').textContent = release.name + $('#detail-source').textContent = `Recognised by ${SOURCE_LABELS[release.source] ?? release.source ?? 'filename'} · ${categoryLabel(release.category)}` + + const actions = el( + 'div', + { class: 'actions' }, + el('a', { class: 'button button-primary', href: `/api/v1/releases/${release.id}/nzb` }, + el('span', { text: 'Download NZB' })), + el('button', { + class: 'button', + type: 'button', + text: 'Copy name', + onclick: async (event) => { + await navigator.clipboard?.writeText(release.name) + event.target.textContent = 'Copied' + setTimeout(() => (event.target.textContent = 'Copy name'), 1200) + }, + }) + ) + + const poster = release.poster + ? el('button', { + class: 'link-button', + type: 'button', + text: release.poster.replace(/\s*<.*>$/, ''), + title: 'Show releases by this poster', + onclick: () => { + state.poster = release.poster + closeRelease() + syncControls() + writeUrl(true) + loadResults() + }, + }) + : 'Random per article (obfuscated)' + + const facts = el( + 'dl', + { class: 'facts' }, + fact('Size', formatSize(release.size)), + fact('Files', release.fileTotal ? `${release.fileCount} of ${release.fileTotal}` : `${release.fileCount} posted`), + fact('Articles', number.format(release.segments)), + fact('Posted', release.postedAt ? `${new Date(release.postedAt).toLocaleString()} (${formatAge(release.postedAt)})` : null), + fact('Poster', poster), + fact('Groups', release.groups?.join(', ')), + fact('Indexed', release.firstSeenAt ? `${new Date(release.firstSeenAt).toLocaleString()} (${formatAge(release.firstSeenAt)})` : null) + ) + + const notices = [] + if (!release.settledAt) { + notices.push(el('div', { class: 'notice notice-warn', text: 'Recently seen: more files of this release may still arrive. The NZB has what is indexed so far.' })) + } + if (!release.readable) { + notices.push(el('div', { class: 'notice', text: 'Obfuscated file names: what this release holds is only known once its articles are read (phase 2).' })) + } + + const files = el('table', { class: 'file-table' }, + el('tbody', {}, release.files.map((f) => + el('tr', {}, + el('td', { class: 'name' }, f.name), + el('td', { class: 'num', text: formatSize(f.size) }), + el('td', { class: 'num', text: `${f.parts} ${f.parts === 1 ? 'part' : 'parts'}` }))))) + + $('#detail').replaceChildren( + actions, + ...notices, + facts, + ...section('Files', `${release.files.length} posted`, files) + ) +} + +async function openRelease(id, push = false) { + state.release = id + writeUrl(push) + document.querySelectorAll('.release.selected').forEach((r) => r.classList.remove('selected')) + document.querySelector(`.release[data-id="${id}"]`)?.classList.add('selected') + + $('#drawer').hidden = false + $('#scrim').hidden = false + $('#detail-name').textContent = '' + $('#detail-source').textContent = '' + $('#detail').replaceChildren(...Array.from({ length: 6 }, () => el('div', { class: 'skeleton' }))) + $('#close').focus() + try { + renderDetail(await api(`/api/v1/releases/${id}`)) + } catch (error) { + $('#detail').replaceChildren(el('p', { class: 'empty', text: `Could not load this release (${error.message}).` })) + } +} + +function closeRelease(push = false) { + if (!state.release) return + const id = state.release + state.release = null + $('#drawer').hidden = true + $('#scrim').hidden = true + writeUrl(push) + document.querySelector(`.release[data-id="${id}"] .release-main`)?.focus() +} + +// --- Wiring ---------------------------------------------------------------- + +$('#q').addEventListener('input', (event) => { + state.q = event.target.value.trim() + scheduleSearch() +}) +for (const k of FIELDS) { + $(`#${k}`).addEventListener('input', (event) => { + state[k] = event.target.value.trim() + scheduleSearch() + }) +} +$('#sort').addEventListener('change', (event) => { + state.sort = event.target.value + writeUrl() + loadResults() +}) +for (const [parent, name, subs] of CATEGORIES) { + $('#cat').append(el('option', { value: String(parent), text: name }), ...subs.map(([id, sub]) => el('option', { value: String(id), text: `${name} > ${sub}` }))) +} +$('#cat').addEventListener('change', (event) => { + state.cat = event.target.value + writeUrl() + loadResults() +}) +$('#source').addEventListener('change', (event) => { + state.source = event.target.value + writeUrl() + loadResults() +}) +for (const button of document.querySelectorAll('[data-filter]')) { + button.addEventListener('click', () => { + const f = button.dataset.filter + state[f] = !state[f] + button.setAttribute('aria-pressed', String(state[f])) + writeUrl() + loadResults() + }) +} +$('#poster-clear').addEventListener('click', () => { + state.poster = '' + syncControls() + writeUrl() + loadResults() +}) +$('#more').addEventListener('click', () => { + state.page++ + loadResults({ append: true }) +}) +$('#close').addEventListener('click', () => closeRelease(true)) +$('#scrim').addEventListener('click', () => closeRelease(true)) + +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closeRelease(true) + if (event.key === '/' && document.activeElement !== $('#q')) { + event.preventDefault() + $('#q').focus() + $('#q').select() + } +}) + +window.addEventListener('popstate', () => { + const previous = { ...state } + readUrl() + syncControls() + const searchChanged = ['q', 'sort', 'source', 'cat', 'poster', ...FIELDS, ...FILTERS].some((k) => previous[k] !== state[k]) + if (searchChanged) loadResults() + if (state.release && state.release !== previous.release) openRelease(state.release) + if (!state.release && previous.release) { + $('#drawer').hidden = true + $('#scrim').hidden = true + } +}) + +readUrl() +syncControls() +loadStats() +loadResults() +if (state.release) openRelease(state.release) diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..9d89fb0 --- /dev/null +++ b/public/index.html @@ -0,0 +1,113 @@ + + + + + + Voyager 2 + + + + +
+ + + Voyager 2 + +
+
+ +
+ + +
+

+
    +
    + +
    + +
    +
    + + + + + + + + +