Rebuild: the web interface, from voyager 1
Test and deploy / test (push) Successful in 42s
Test and deploy / deploy (push) Skipped

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 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-09 10:47:59 +02:00
1 parent 8e52bff213
commit b97edf02c5
3 files changed
+1412

No files matched your search

+844
View File
@@ -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;
}
}
+455
View File
@@ -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)
+113
View File
@@ -0,0 +1,113 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Voyager 2</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%232f6fed'/%3E%3Cpath d='M9 10h14M9 16h14M9 22h9' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E" />
<link rel="stylesheet" href="/app.css" />
</head>
<body>
<header class="topbar">
<a class="brand" href="/" aria-label="Voyager 2, home">
<span class="brand-mark" aria-hidden="true"></span>
<span>Voyager 2</span>
</a>
<dl class="stats" id="stats" aria-label="Index statistics"></dl>
</header>
<main class="layout">
<section class="search" aria-label="Search">
<div class="search-row">
<label class="search-box">
<svg aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" /></svg>
<input id="q" type="search" autocomplete="off" spellcheck="false"
placeholder="Search release names…" aria-label="Search" />
<kbd aria-hidden="true">/</kbd>
</label>
<label class="select">
<span class="visually-hidden">Sort</span>
<select id="sort">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="largest">Largest</option>
<option value="files">Most files</option>
<option value="name">Name</option>
</select>
</label>
</div>
<div class="filters" role="group" aria-label="Filters">
<button class="chip" type="button" data-filter="readable" aria-pressed="false">Readable names</button>
<button class="chip" type="button" data-filter="complete" aria-pressed="false">Complete</button>
<label class="select select-small">
<span class="visually-hidden">Category</span>
<select id="cat">
<option value="">Any category</option>
</select>
</label>
<label class="select select-small">
<span class="visually-hidden">Recognised by</span>
<select id="source">
<option value="">Any source</option>
<option value="stem">Filename</option>
<option value="subject">Subject</option>
<option value="post">Post id</option>
</select>
</label>
<span class="field-chip" title="Release size in MB">
<label for="min_size">Size</label>
<input type="number" id="min_size" min="0" step="any" inputmode="decimal" placeholder="min" aria-label="Minimum size in MB" />
<span aria-hidden="true">–</span>
<input type="number" id="max_size" min="0" step="any" inputmode="decimal" placeholder="max" aria-label="Maximum size in MB" />
<span class="unit">MB</span>
</span>
<span class="chip chip-active" id="poster-filter" hidden>
<span id="poster-filter-label"></span>
<button type="button" id="poster-clear" aria-label="Remove poster filter">×</button>
</span>
</div>
</section>
<section class="results" aria-live="polite">
<p class="summary" id="summary"></p>
<ol class="release-list" id="list"></ol>
<div class="more">
<button type="button" class="button" id="more" hidden>Load more</button>
</div>
<p class="empty" id="empty" hidden>No releases match. Try fewer words or turn off a filter.</p>
</section>
</main>
<div class="scrim" id="scrim" hidden></div>
<aside class="drawer" id="drawer" aria-labelledby="detail-name" hidden>
<div class="drawer-head">
<div class="drawer-title">
<p class="eyebrow" id="detail-source"></p>
<h2 id="detail-name"></h2>
</div>
<button type="button" class="icon-button" id="close" aria-label="Close details">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18" /></svg>
</button>
</div>
<div class="drawer-body" id="detail"></div>
</aside>
<template id="row-template">
<li class="release">
<button type="button" class="release-main">
<span class="release-name"></span>
<span class="release-meta"></span>
</button>
<div class="release-side">
<span class="badges"></span>
<span class="release-size"></span>
<a class="icon-button nzb" title="Download NZB" aria-label="Download NZB">
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14" /></svg>
</a>
</div>
</li>
</template>
<script src="/app.js" type="module"></script>
</body>
</html>