implemented a lot of features
This commit is contained in:
1 parent
62f1767df3
commit
bcb9be5da9
141 files changed
+10705
-385
No files matched your search
+708
@@ -0,0 +1,708 @@
|
||||
: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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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-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;
|
||||
}
|
||||
}
|
||||
+436
@@ -0,0 +1,436 @@
|
||||
// Usenet Indexer web interface. No framework; all Usenet-derived text is
|
||||
// rendered with textContent, never as HTML.
|
||||
|
||||
const $ = (selector) => document.querySelector(selector)
|
||||
const FILTERS = ['readable', 'complete', 'hide_encrypted', 'par2_only']
|
||||
const SOURCE_LABELS = {
|
||||
stem: 'Filename',
|
||||
subject: 'Subject',
|
||||
post: 'Post id',
|
||||
par2: 'par2 contents',
|
||||
rar: 'RAR headers',
|
||||
'weak-stem': 'Obfuscated filename',
|
||||
nzb: 'Posted NZB',
|
||||
spot: 'Spotnet',
|
||||
}
|
||||
|
||||
const state = {
|
||||
q: '',
|
||||
sort: 'newest',
|
||||
source: '',
|
||||
poster: '',
|
||||
readable: false,
|
||||
complete: false,
|
||||
hide_encrypted: false,
|
||||
par2_only: false,
|
||||
page: 1,
|
||||
release: null,
|
||||
}
|
||||
let requestId = 0
|
||||
|
||||
// --- 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.poster = params.get('poster') ?? ''
|
||||
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.poster) params.set('poster', state.poster)
|
||||
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
|
||||
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) {
|
||||
const response = await fetch(path, { headers: { accept: 'application/json' } })
|
||||
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.poster) params.set('poster', state.poster)
|
||||
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
|
||||
const items = [
|
||||
['Releases', number.format(stats.releases)],
|
||||
['Readable', `${readable}%`],
|
||||
['Files', number.format(stats.files)],
|
||||
['Size', formatSize(stats.size)],
|
||||
['Indexed', formatAge(stats.lastIndexedAt) || '–'],
|
||||
]
|
||||
$('#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')
|
||||
if (release.par2Only) badges.append(badge('par2 only', 'warn', 'Only the par2 files arrived; the data they describe is missing'))
|
||||
if (release.container === 'nzbe') badges.append(badge('Encrypted NZB', 'bad', 'An encrypted NZB container: unusable without its key'))
|
||||
else if (release.container === 'rclone') badges.append(badge('Encrypted (rclone)', 'bad', 'rclone crypt: names and contents encrypted, unusable without the key'))
|
||||
else if (release.encrypted) badges.append(badge('Password', 'bad', 'Password-protected archive'))
|
||||
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 if (release.listedFiles) {
|
||||
badges.append(badge(`${release.listedPresent}/${release.listedFiles} listed`, 'warn', 'Files the par2 set lists: present / total'))
|
||||
} else badges.append(badge(files))
|
||||
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
|
||||
$('#more').hidden = true
|
||||
if (!append) $('#summary').textContent = 'Searching…'
|
||||
|
||||
try {
|
||||
const result = await api(`/api/v1/releases?${searchParams(state.page)}`)
|
||||
if (id !== requestId) return // a newer search superseded this one
|
||||
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.total === 1 ? 'release' : 'releases'}` +
|
||||
(shown < result.total ? ` · showing ${number.format(shown)}` : '')
|
||||
$('#empty').hidden = result.total !== 0
|
||||
$('#more').hidden = shown >= result.total
|
||||
} catch (error) {
|
||||
if (id !== requestId) return
|
||||
$('#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'}`
|
||||
|
||||
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 listedPresent = release.par2Files.filter((f) => f.present).length
|
||||
const facts = el(
|
||||
'dl',
|
||||
{ class: 'facts' },
|
||||
fact('Size', formatSize(release.size)),
|
||||
fact('Files', release.fileTotal ? `${release.fileCount} of ${release.fileTotal}` : `${release.fileCount} posted`),
|
||||
fact('par2 lists', release.par2Files.length ? `${listedPresent} of ${release.par2Files.length} present` : null),
|
||||
fact('Posted', release.postedAt ? `${new Date(release.postedAt).toLocaleString()} (${formatAge(release.postedAt)})` : null),
|
||||
fact('Poster', poster),
|
||||
fact('Groups', release.groups?.join(', '))
|
||||
)
|
||||
|
||||
const notices = []
|
||||
if (release.container === 'nzbe') {
|
||||
notices.push(el('div', { class: 'notice notice-bad', text: 'Encrypted NZB container ("nzbe"): an encrypted download index for others, unreadable without its key.' }))
|
||||
} else if (release.container === 'rclone') {
|
||||
notices.push(el('div', { class: 'notice notice-bad', text: 'rclone crypt: file names and contents are encrypted with a password and salt that are not part of the post.' }))
|
||||
} else if (release.encrypted) {
|
||||
notices.push(el('div', { class: 'notice notice-bad', text: 'Password-protected: the archive needs a password that is not part of the post.' }))
|
||||
}
|
||||
if (release.pending.binaries > 0) {
|
||||
const pct = release.pending.partsTotal ? Math.round((100 * release.pending.partsHave) / release.pending.partsTotal) : 0
|
||||
notices.push(
|
||||
el('div', { class: 'notice notice-warn' },
|
||||
el('div', { style: 'flex:1' },
|
||||
`${release.pending.binaries} more ${release.pending.binaries === 1 ? 'file is' : 'files are'} still arriving (${pct}% of their parts seen).`,
|
||||
el('div', { class: 'progress' }, el('span', { style: `width:${pct}%` }))))
|
||||
)
|
||||
}
|
||||
|
||||
const files = el('table', { class: 'file-table' },
|
||||
el('tbody', {}, release.files.map((f) =>
|
||||
el('tr', {},
|
||||
el('td', { class: 'name' }, f.name,
|
||||
f.realName && f.realName !== f.postedName ? el('span', { class: 'posted', text: `posted as ${f.postedName}` }) : null),
|
||||
el('td', { class: 'num', text: formatSize(f.size) }),
|
||||
el('td', { class: 'num', text: `${f.parts} ${f.parts === 1 ? 'part' : 'parts'}` })))))
|
||||
|
||||
const par2 = release.par2Files.length
|
||||
? el('table', { class: 'file-table' },
|
||||
el('tbody', {}, release.par2Files.map((f) =>
|
||||
el('tr', {},
|
||||
el('td', { class: `state ${f.present ? 'ok' : 'missing'}`, text: f.present ? '✓' : '·', title: f.present ? 'Present' : 'Not seen yet' }),
|
||||
el('td', { class: 'name', text: f.name }),
|
||||
el('td', { class: 'num', text: formatSize(f.size) })))))
|
||||
: null
|
||||
|
||||
const contents = release.contents.length
|
||||
? el('table', { class: 'file-table' },
|
||||
el('tbody', {}, release.contents.map((entry) =>
|
||||
el('tr', {},
|
||||
el('td', { class: 'name', text: entry.name + (entry.directory ? '/' : '') }),
|
||||
el('td', { class: 'num', text: entry.size === null ? '' : formatSize(entry.size) }),
|
||||
el('td', { class: 'state', text: entry.encrypted ? '🔒' : '', title: entry.encrypted ? 'Encrypted' : undefined })))))
|
||||
: null
|
||||
|
||||
$('#detail').replaceChildren(
|
||||
actions,
|
||||
...notices,
|
||||
facts,
|
||||
...section('Files', `${release.files.length} posted`, files),
|
||||
...(par2 ? section('par2 file list', `${listedPresent} of ${release.par2Files.length} present`, par2) : []),
|
||||
...(contents ? section('Archive contents', `${release.contents.length} entries`, contents) : [])
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
$('#sort').addEventListener('change', (event) => {
|
||||
state.sort = 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', 'poster', ...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)
|
||||
@@ -0,0 +1,107 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Usenet Indexer</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="Usenet Indexer, home">
|
||||
<span class="brand-mark" aria-hidden="true"></span>
|
||||
<span>Usenet Indexer</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 releases, file names and archive contents…" 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>
|
||||
<button class="chip" type="button" data-filter="hide_encrypted" aria-pressed="false">Hide passworded</button>
|
||||
<button class="chip" type="button" data-filter="par2_only" aria-pressed="false" title="Also show releases that are only par2 files whose data never arrived">Show par2-only</button>
|
||||
<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>
|
||||
<option value="par2">par2 contents</option>
|
||||
<option value="rar">RAR headers</option>
|
||||
<option value="weak-stem">Obfuscated filename</option>
|
||||
<option value="nzb">Posted NZB</option>
|
||||
<option value="spot">Spotnet</option>
|
||||
</select>
|
||||
</label>
|
||||
<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>
|
||||
Reference in new issue
Block a user