Extract CSS/JS to static files, wrap header pills, add version footer
All checks were successful
Deploy / deploy (push) Successful in 23s

The palette lived in a :root block duplicated across all three
templates, so every reshade meant three edits — and drifted twice in
practice (the favicon hardcoded its own hex; stale danger-border rgba
literals survived two palette changes). Move tokens to a single
static/css/base.css, derive every dim/border tint from the four source
hues with color-mix, and point the favicon at one static/favicon.svg.

Also splits the 1558-line dashboard.html into template + static assets:
662 lines of CSS and 396 of JS were re-sent uninlined on every load
with no browser caching, which costs real time on the remote/cellular
access this app is built for. Templates drop to 501/42/107 lines.
Server-rendered config (timezone, script_root) now arrives via
<body data-*>; wiring URL_BASE to request.script_root also fixes /api/*
fetches for a future subpath mount, which the hardcoded "" would break.

Header service pills now wrap instead of scrolling — they were clipped
at narrow widths, and a hidden pill is a service whose status you
cannot see. Verified 0 clipped and no page overflow at 320/375/700/1280.

Footer gains a version and date so the running build is identifiable
without trusting the Unraid Docker tab's cached container IDs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Barely Removable 2026-08-26 14:27:52 -07:00
parent faf25a811a
commit 18c7719cd7
12 changed files with 1396 additions and 1413 deletions

74
static/css/base.css Normal file
View file

@ -0,0 +1,74 @@
/* ═══════════════════════════════════════════════════════
Media Centre — shared design tokens + base reset.
Single source of truth for the palette. Loaded by
dashboard.html, login.html and settings.html; page-specific
rules live in dashboard.css / login.css / settings.css.
To reshade the whole app, change the four hues below —
every dim/border variant is derived from them via color-mix.
═══════════════════════════════════════════════════════ */
:root {
/* ── Surfaces ── */
--bg: #141416;
--surface: #1a1a1d;
--surface-2: #202024;
--border: #2c2c31;
--border-lt: #38383f;
/* ── Text (all ≥ 4.5:1 on every surface above) ── */
--text: #edecf1;
--text-2: #a8a7b0;
--text-3: #7c7b84;
/* ── Accent + semantics (the four source hues) ── */
--accent: #7c86ff;
--ok: #68b98a;
--warn: #d0a75a;
--danger: #dc5b52;
/* ── Derived tints — never hardcode these as literals ── */
--accent-dim: color-mix(in srgb, var(--accent) 14%, transparent);
--ok-dim: color-mix(in srgb, var(--ok) 14%, transparent);
--warn-dim: color-mix(in srgb, var(--warn) 14%, transparent);
--danger-dim: color-mix(in srgb, var(--danger) 14%, transparent);
--danger-border: color-mix(in srgb, var(--danger) 35%, transparent);
/* ── Type scale (6 steps, no one-offs) ── */
--fs-xs: 0.75rem; /* 12px — smallest permitted */
--fs-sm: 0.8125rem; /* 13px */
--fs-base: 0.9375rem; /* 15px */
--fs-lg: 1.125rem; /* 18px */
--fs-xl: 1.5rem; /* 24px */
--fs-2xl: 2rem; /* 32px */
/* ── Spacing / shape ── */
--gap: 1rem;
--gap-lg: 1.5rem;
--radius: 10px;
--radius-sm: 7px;
--tap: 44px; /* minimum touch target */
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: var(--fs-base);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); }
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}

604
static/css/dashboard.css Normal file
View file

@ -0,0 +1,604 @@
/* ── Header ─────────────────────────────────────────── */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
gap: var(--gap);
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 100;
}
.brand {
display: flex;
align-items: center;
gap: 0.65rem;
min-width: 0;
flex: 0 0 auto;
}
.brand svg { flex-shrink: 0; display: block; }
.brand h1 {
margin: 0;
font-size: var(--fs-lg);
font-weight: 600;
letter-spacing: -0.01em;
white-space: nowrap;
}
/* Service status pills — wrap onto as many rows as needed so every
service stays visible at any window width. Never scroll or clip:
a hidden pill is a service you can't see the status of. */
.svc-strip {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 1 1 auto;
flex-wrap: wrap;
justify-content: center;
min-width: 0;
}
.svc-pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.7rem;
min-height: 34px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 100px;
font-size: var(--fs-xs);
color: var(--text-2);
text-decoration: none;
white-space: nowrap;
transition: border-color 0.2s, color 0.2s;
}
.svc-pill:hover { border-color: var(--border-lt); color: var(--text); }
.svc-pill.down { color: var(--danger); border-color: var(--danger-border); }
.svc-name { font-weight: 600; }
.svc-stat { color: var(--text-3); }
.svc-pill.down .svc-stat { color: var(--danger); }
.status-light {
width: 7px; height: 7px; border-radius: 50%;
flex-shrink: 0; background: var(--text-3);
}
.status-light.up { background: var(--ok); box-shadow: 0 0 0 3px var(--ok-dim); }
.status-light.down { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-dim); }
.header-right {
display: flex;
align-items: center;
gap: 0.75rem;
margin-left: auto;
flex: 0 0 auto;
}
.header-clock {
font-variant-numeric: tabular-nums;
font-size: var(--fs-sm);
color: var(--text-2);
white-space: nowrap;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-width: var(--tap);
min-height: var(--tap);
padding: 0 0.85rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-2);
font-size: var(--fs-sm);
font-family: inherit;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: color 0.2s, border-color 0.2s, background 0.2s;
}
.icon-btn:hover { color: var(--text); border-color: var(--border-lt); background: var(--surface); }
/* ── Layout ─────────────────────────────────────────── */
main {
padding: var(--gap-lg);
max-width: 1360px;
margin: 0 auto;
}
.section {
margin-bottom: 2rem;
}
.section > h2 {
margin: 0 0 0.85rem;
font-size: var(--fs-xs);
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--text-3);
}
.row {
display: grid;
gap: var(--gap);
}
.row.cols-3 { grid-template-columns: repeat(3, 1fr); }
.row.cols-2 { grid-template-columns: repeat(2, 1fr); }
/* ── Card ───────────────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
min-width: 0;
}
.card-header {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.85rem 1.1rem;
border-bottom: 1px solid var(--border);
}
.card-header h3 {
margin: 0;
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text);
}
.card-header h3 a { color: inherit; text-decoration: none; }
.card-header h3 a:hover { color: var(--accent); }
.card-meta {
margin-left: auto;
font-size: var(--fs-xs);
color: var(--text-3);
white-space: nowrap;
}
.card-body { padding: 1.1rem; flex: 1; }
.card-body > :first-child { margin-top: 0; }
#prowlarr-body { display: flex; flex-direction: column; justify-content: center; }
/* ── "Right now" hero strip ─────────────────────────── */
.hero .card { border-color: var(--border-lt); }
.hero-figure {
font-size: var(--fs-2xl);
font-weight: 600;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.hero-sub {
font-size: var(--fs-sm);
color: var(--text-3);
margin-top: 0.3rem;
}
/* Now-playing list */
.stream {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0;
border-top: 1px solid var(--border);
}
.stream:first-child { border-top: none; padding-top: 0; }
.stream-icon { font-size: var(--fs-base); flex-shrink: 0; }
.stream-info { min-width: 0; flex: 1; }
.stream-title {
font-size: var(--fs-sm); font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stream-user { font-size: var(--fs-xs); color: var(--text-3); }
.live-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--ok); flex-shrink: 0;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
@media (prefers-reduced-motion: reduce) {
.live-dot { animation: none; }
* { transition-duration: 0.01ms !important; }
}
/* ── Meter (storage / progress) ─────────────────────── */
.meter {
height: 7px;
background: var(--surface-2);
border-radius: 4px;
overflow: hidden;
margin-top: 0.65rem;
}
.meter-fill {
height: 100%;
border-radius: 4px;
background: var(--ok);
transition: width 0.5s ease;
}
.meter-fill.warn { background: var(--warn); }
.meter-fill.danger { background: var(--danger); }
.meter-legend {
display: flex;
justify-content: space-between;
font-size: var(--fs-xs);
color: var(--text-3);
margin-top: 0.45rem;
}
/* ── Stats (semantic definition list) ───────────────── */
dl.stats {
display: grid;
gap: 0.6rem;
margin: 0;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
}
dl.stats > div {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.65rem 0.75rem;
min-width: 0;
}
dl.stats dt {
font-size: var(--fs-xs);
color: var(--text-3);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
dl.stats dd {
margin: 0.2rem 0 0;
font-size: var(--fs-lg);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
dd.is-warn { color: var(--warn); }
dd.is-danger { color: var(--danger); }
dd.is-ok { color: var(--ok); }
dd.is-accent { color: var(--accent); }
/* ── Buttons ────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--tap);
padding: 0 1rem;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--bg);
font-family: inherit;
font-size: var(--fs-sm);
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.15s, background 0.15s;
}
.btn:hover:not(:disabled) { opacity: 0.88; }
.btn:disabled { opacity: 0.5; cursor: default; }
.btn-quiet {
background: var(--surface-2);
border-color: var(--border);
color: var(--text-2);
}
.btn-quiet:hover:not(:disabled) { color: var(--text); border-color: var(--border-lt); }
/* Compact add button — still meets 44px on touch (see media query) */
.btn-add {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding: 0 0.7rem;
border: 1px solid var(--border-lt);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text);
font-family: inherit;
font-size: var(--fs-xs);
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn-add:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
/* Armed = awaiting the confirming second tap */
.btn-add.armed {
background: var(--warn);
border-color: var(--warn);
color: var(--bg);
}
.btn-add.done {
background: var(--ok-dim);
border-color: transparent;
color: var(--ok);
cursor: default;
}
.btn-add:disabled { cursor: default; }
/* ── Search ─────────────────────────────────────────── */
.search-bar { display: flex; gap: 0.5rem; margin-bottom: 0.85rem; }
.search-bar input {
flex: 1;
min-width: 0;
min-height: var(--tap);
padding: 0 0.85rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: var(--fs-sm);
}
.search-bar input::placeholder { color: var(--text-3); }
.search-bar input:focus { border-color: var(--accent); outline: none; }
.search-result {
display: none;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 0.85rem;
margin-bottom: 0.85rem;
background: var(--accent-dim);
border: 1px solid rgba(122,162,247,0.3);
border-radius: var(--radius-sm);
}
.search-result.visible { display: flex; }
.search-result-info { min-width: 0; flex: 1; }
.search-result-title {
font-size: var(--fs-sm); font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.search-result-meta { font-size: var(--fs-xs); color: var(--text-3); }
.search-msg {
font-size: var(--fs-sm);
color: var(--text-2);
padding: 0.5rem 0;
}
.search-msg.error { color: var(--danger); }
/* ── Discover (RT) list ─────────────────────────────── */
.rt-list { display: flex; flex-direction: column; gap: 0.5rem; }
.rt-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.6rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
transition: border-color 0.2s, opacity 0.3s, transform 0.3s;
}
.rt-item:hover { border-color: var(--border-lt); }
.rt-item.removing { opacity: 0; transform: scale(0.97); pointer-events: none; }
.rt-item.is-hidden { display: none; }
.rt-rank {
font-size: var(--fs-xs);
color: var(--text-3);
font-variant-numeric: tabular-nums;
width: 1.2rem;
text-align: right;
flex-shrink: 0;
}
.rt-poster {
width: 38px; height: 56px;
object-fit: cover;
border-radius: 4px;
background: var(--surface);
flex-shrink: 0;
}
.rt-info { min-width: 0; flex: 1; }
.rt-title {
font-size: var(--fs-sm);
font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.rt-year { color: var(--text-3); font-weight: 400; }
.rt-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: var(--fs-xs);
margin-top: 0.15rem;
}
.score.certified { color: var(--ok); }
.score.critics { color: var(--warn); }
.score.audience { color: var(--text-3); }
/* Description is inline + clamped — readable on touch, no hover required */
.rt-desc {
font-size: var(--fs-xs);
color: var(--text-3);
margin-top: 0.25rem;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.rt-desc:empty { display: none; }
.show-more {
margin-top: 0.6rem;
width: 100%;
min-height: 38px;
}
/* ── Indexers ───────────────────────────────────────── */
.indexer-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 0.65rem;
}
.indexer-item {
display: flex;
align-items: center;
gap: 0.65rem;
min-height: var(--tap);
padding: 0.75rem 0.9rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--fs-base);
min-width: 0;
}
.indexer-item.down { border-color: var(--danger-border); }
.indexer-item .status-light { width: 9px; height: 9px; }
.indexer-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-2); }
/* ── Requests (Ombi) ────────────────────────────────── */
.req-list { display: flex; flex-direction: column; gap: 0.5rem; }
.req-item.is-hidden { display: none; }
.req-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.6rem 0.7rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.badge {
font-size: var(--fs-xs);
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: 100px;
white-space: nowrap;
flex-shrink: 0;
}
.badge.movie { background: var(--accent-dim); color: var(--accent); }
.badge.tv { background: var(--ok-dim); color: var(--ok); }
.badge.pending { background: var(--warn-dim); color: var(--warn); }
.badge.available, .badge.approved { background: var(--ok-dim); color: var(--ok); }
.badge.denied { background: var(--danger-dim); color: var(--danger); }
.badge.idle { background: var(--surface); color: var(--text-3); }
.badge.downloading { background: var(--accent-dim); color: var(--accent); }
.badge.paused { background: var(--warn-dim); color: var(--warn); }
.req-info { min-width: 0; flex: 1; }
.req-title {
font-size: var(--fs-sm); font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.req-sub { font-size: var(--fs-xs); color: var(--text-3); }
/* ── Insights top lists ─────────────────────────────── */
.top-list { display: flex; flex-direction: column; gap: 0.4rem; }
.top-item {
display: flex;
align-items: center;
gap: 0.65rem;
min-height: 3.25rem;
padding: 0.5rem 0.65rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.top-rank {
font-size: var(--fs-xs);
color: var(--text-3);
font-variant-numeric: tabular-nums;
width: 1.1rem;
flex-shrink: 0;
}
.top-info { min-width: 0; flex: 1; }
.top-name {
font-size: var(--fs-sm);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.top-sub { font-size: var(--fs-xs); color: var(--text-3); }
.top-plays {
font-size: var(--fs-xs);
color: var(--text-2);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.empty { color: var(--text-3); font-size: var(--fs-sm); }
/* ── Errors ─────────────────────────────────────────── */
.error {
background: var(--surface-2);
border: 1px solid var(--border);
border-left: 3px solid var(--danger);
border-radius: var(--radius-sm);
padding: 0.8rem 0.9rem;
color: var(--text-2);
font-size: var(--fs-sm);
}
.error-detail { margin-top: 0.5rem; }
.error-detail summary {
cursor: pointer;
font-size: var(--fs-xs);
color: var(--text-3);
list-style: none;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0;
}
.error-detail summary::-webkit-details-marker { display: none; }
.error-detail summary::before { content: "▸"; }
.error-detail[open] summary::before { content: "▾"; }
.error-detail summary:hover { color: var(--text-2); }
.error-detail pre {
margin: 0.4rem 0 0;
padding: 0.6rem 0.7rem;
background: rgba(0,0,0,0.3);
border-radius: 6px;
font-family: var(--mono);
font-size: var(--fs-xs);
line-height: 1.5;
color: var(--text-3);
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
footer {
padding: 1.5rem;
text-align: center;
font-size: var(--fs-xs);
color: var(--text-3);
border-top: 1px solid var(--border);
}
/* Build identity — dim, so it reads as metadata rather than content. */
footer .version {
display: block;
margin-top: 0.3rem;
font-family: var(--mono);
font-variant-numeric: tabular-nums;
opacity: 0.75;
}
.visually-hidden {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* ── Responsive ─────────────────────────────────────── */
@media (max-width: 1100px) {
.row.cols-3 { grid-template-columns: 1fr 1fr; }
.row.cols-3 > :first-child { grid-column: 1 / -1; }
}
@media (max-width: 820px) {
.row.cols-2, .row.cols-3 { grid-template-columns: 1fr; }
.row.cols-3 > :first-child { grid-column: auto; }
}
@media (max-width: 700px) {
header { padding: 0.7rem 1rem; gap: 0.6rem; }
.brand { order: 1; flex: 1 1 auto; min-width: 0; }
.brand h1 { font-size: var(--fs-base); }
.header-right { order: 2; }
.header-clock { display: none; }
.svc-strip { order: 3; width: 100%; flex: 1 0 100%; justify-content: flex-start; }
main { padding: 1rem; }
.card-body { padding: 0.9rem; }
.card-header { padding: 0.75rem 0.9rem; }
.search-bar { flex-direction: column; }
.search-bar .btn { width: 100%; }
/* Full touch targets once there's no mouse */
.btn-add { min-height: var(--tap); padding: 0 0.9rem; }
.search-bar input { font-size: 16px; } /* prevent iOS zoom on focus */
}

83
static/css/login.css Normal file
View file

@ -0,0 +1,83 @@
/* Login page — tokens and base reset come from base.css */
/* Centre the card in the viewport (overrides base.css body). */
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.wrap { width: 100%; max-width: 360px; }
.brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
margin-bottom: 1.75rem;
}
.brand h1 {
margin: 0;
font-size: var(--fs-lg);
font-weight: 600;
letter-spacing: -0.01em;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
}
label {
display: block;
font-size: var(--fs-xs);
font-weight: 600;
color: var(--text-2);
margin-bottom: 0.4rem;
}
input {
width: 100%;
min-height: var(--tap);
padding: 0 0.85rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: 16px; /* prevents iOS zoom on focus */
outline: none;
transition: border-color 0.2s;
}
input:focus { border-color: var(--accent); }
input::placeholder { color: var(--text-3); }
.error-msg {
background: var(--surface-2);
border: 1px solid var(--border);
border-left: 3px solid var(--danger);
border-radius: var(--radius-sm);
padding: 0.7rem 0.85rem;
color: var(--text-2);
font-size: var(--fs-sm);
margin-bottom: 1rem;
}
button {
width: 100%;
min-height: var(--tap);
margin-top: 1rem;
background: var(--accent);
color: var(--bg);
border: none;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: var(--fs-sm);
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
button:hover { opacity: 0.88; }

138
static/css/settings.css Normal file
View file

@ -0,0 +1,138 @@
/* Settings page — tokens and base reset come from base.css */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
gap: 0.65rem;
position: sticky;
top: 0;
z-index: 100;
}
header h1 {
margin: 0;
font-size: var(--fs-lg);
font-weight: 600;
letter-spacing: -0.01em;
}
.back-btn {
margin-left: auto;
display: inline-flex;
align-items: center;
min-height: var(--tap);
padding: 0 0.9rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-2);
font-size: var(--fs-sm);
text-decoration: none;
white-space: nowrap;
transition: color 0.2s, border-color 0.2s;
}
.back-btn:hover { color: var(--text); border-color: var(--border-lt); }
main { padding: 1.5rem; max-width: 900px; margin: 0 auto; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--gap);
}
.full { grid-column: 1 / -1; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.card-header {
padding: 0.85rem 1.1rem;
border-bottom: 1px solid var(--border);
}
.card-header h2 {
margin: 0;
font-size: var(--fs-sm);
font-weight: 600;
}
.card-body { padding: 1.1rem; }
.field { margin-bottom: 0.9rem; }
label {
display: block;
font-size: var(--fs-xs);
font-weight: 600;
color: var(--text-2);
margin-bottom: 0.35rem;
}
input, select {
width: 100%;
min-height: var(--tap);
padding: 0 0.8rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: 16px; /* prevents iOS zoom on focus */
outline: none;
transition: border-color 0.2s;
}
input:focus, select:focus { border-color: var(--accent); }
input::placeholder { color: var(--text-3); }
.hint {
font-size: var(--fs-xs);
color: var(--text-3);
margin-top: 0.35rem;
}
button.save {
width: 100%;
min-height: var(--tap);
background: var(--accent);
color: var(--bg);
border: none;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: var(--fs-sm);
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
button.save:hover:not(:disabled) { opacity: 0.88; }
button.save:disabled { background: var(--border); color: var(--text-3); cursor: default; }
.error-inline {
font-size: var(--fs-xs);
color: var(--danger);
display: none;
margin-top: 0.35rem;
}
.toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
background: var(--surface);
border: 1px solid var(--ok);
color: var(--ok);
padding: 0.7rem 1.1rem;
border-radius: var(--radius-sm);
font-size: var(--fs-sm);
font-weight: 500;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
max-width: calc(100vw - 3rem);
}
.toast.error { border-color: var(--danger); color: var(--danger); }
.toast.show { opacity: 1; }
@media (max-width: 600px) {
header { padding: 0.7rem 1rem; }
main { padding: 1rem; }
.toast { left: 1rem; right: 1rem; bottom: 1rem; }
}

4
static/favicon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="22" fill="#7c86ff"/>
<path d="M40 32 L72 50 L40 68 Z" fill="#141416"/>
</svg>

After

Width:  |  Height:  |  Size: 180 B

400
static/js/dashboard.js Normal file
View file

@ -0,0 +1,400 @@
/* Server-rendered config, read off <body data-*> (see dashboard.html).
URL_BASE carries the reverse-proxy prefix so /api/* fetches still
resolve when the app is mounted on a subpath via SCRIPT_NAME. */
const URL_BASE = document.body.dataset.urlBase || "";
const TIMEZONE = document.body.dataset.timezone || "UTC";
let sonarrSearchResult = null;
let radarrSearchResult = null;
/* ── Clock ── */
function updateClock() {
const el = document.getElementById('header-clock');
if (!el) return;
try {
el.textContent = new Date().toLocaleTimeString('en-US', {
timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit'
});
} catch (e) {
el.textContent = new Date().toLocaleTimeString();
}
}
updateClock();
setInterval(updateClock, 1000);
/* ── Discover: show more / show less ── */
function toggleMore(btn, listId) {
const list = document.getElementById(listId);
if (!list) return;
const items = Array.from(list.children);
const hidden = items.filter(el => el.classList.contains('is-hidden'));
if (hidden.length) {
hidden.forEach(el => el.classList.remove('is-hidden'));
btn.textContent = 'Show less';
btn.setAttribute('aria-expanded', 'true');
} else {
items.forEach((el, i) => {
if (i >= 5) el.classList.add('is-hidden');
});
const n = items.length - 5;
btn.textContent = 'Show ' + n + ' more';
btn.setAttribute('aria-expanded', 'false');
}
}
/* ── Add flow: first tap arms, second tap commits ──
Adding queues a real download, so a single stray tap shouldn't do it. */
let armedBtn = null;
let armedTimer = null;
function disarm() {
if (armedBtn) {
armedBtn.classList.remove('armed');
armedBtn.textContent = '+ Add';
armedBtn = null;
}
clearTimeout(armedTimer);
}
function armAdd(btn) {
if (armedBtn === btn) { disarm(); return doAdd(btn); }
disarm();
armedBtn = btn;
btn.classList.add('armed');
btn.textContent = 'Confirm?';
armedTimer = setTimeout(disarm, 4000);
}
function removeRtItem(btn) {
const item = btn.closest('.rt-item');
if (!item) return;
item.classList.add('removing');
setTimeout(() => {
const list = item.parentElement;
item.remove();
if (list) {
let n = 0;
list.querySelectorAll('.rt-item').forEach(el => {
el.querySelector('.rt-rank').textContent = ++n;
});
}
}, 320);
}
async function doAdd(btn) {
const title = btn.dataset.title;
const target = btn.dataset.target; // 'sonarr' | 'radarr'
btn.disabled = true;
btn.textContent = 'Adding…';
try {
const resp = await fetch(URL_BASE + '/add-to-' + target, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ title })
});
const data = await resp.json();
if ((resp.ok && data.success) || resp.status === 409) {
removeRtItem(btn);
} else {
btn.disabled = false;
btn.textContent = '+ Add';
alert('Failed to add "' + title + '": ' + (data.error || 'Unknown error'));
}
} catch (e) {
btn.disabled = false;
btn.textContent = '+ Add';
alert('Network error: ' + e.message);
}
}
// A tap anywhere else cancels a pending confirm.
document.addEventListener('click', e => {
if (armedBtn && !e.target.closest('.btn-add')) disarm();
});
/* ── SABnzbd live polling (never cached server-side) ── */
const sabSpeed = document.getElementById('sab-speed');
const sabQueue = document.getElementById('sab-queue');
const sabRemaining = document.getElementById('sab-remaining');
const sabBadge = document.getElementById('sab-badge');
const sabTimeleft = document.getElementById('sab-timeleft');
const sabTimeleftRow = document.getElementById('sab-timeleft-row');
async function pollSabnzbd() {
try {
const resp = await fetch(URL_BASE + '/api/sabnzbd', { cache: 'no-store', credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
if (d.status !== 'ok') return;
if (sabSpeed) sabSpeed.textContent = d.speed;
if (sabQueue) sabQueue.textContent = d.queue_count;
if (sabRemaining) sabRemaining.textContent = d.sizeleft;
if (sabBadge) {
const s = (d.sab_status || '').toLowerCase();
sabBadge.className = 'badge ' + (s === 'downloading' ? 'downloading' : s === 'paused' ? 'paused' : 'idle');
sabBadge.textContent = d.sab_status;
}
const active = (d.sab_status || '').toLowerCase() === 'downloading'
&& d.timeleft && d.timeleft !== '0:00:00';
if (sabTimeleftRow) sabTimeleftRow.style.display = active ? 'inline' : 'none';
if (sabTimeleft) sabTimeleft.textContent = d.timeleft;
const pill = document.getElementById('svc-stat-sabnzbd');
if (pill) pill.textContent = d.speed;
} catch (e) { /* transient network error — try again next tick */ }
}
pollSabnzbd();
setInterval(pollSabnzbd, 3000);
/* ── RT descriptions arrive asynchronously from IMDB ── */
async function loadRtDescriptions() {
try {
const resp = await fetch(URL_BASE + '/api/rt/descriptions', { credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
for (const [title, desc] of Object.entries(d.descriptions || {})) {
const item = document.querySelector(`.rt-item[data-rt-title="${CSS.escape(title)}"]`);
if (!item || !desc) continue;
const el = item.querySelector('.rt-desc');
if (!el || el.textContent.trim()) continue; // already filled
el.textContent = desc.length > 180 ? desc.slice(0, 180) + '…' : desc;
}
if (d.pending) setTimeout(loadRtDescriptions, 4000);
} catch (e) { /* silently skip */ }
}
setTimeout(loadRtDescriptions, 1500);
/* ── Prowlarr indexer polling ── */
function updateServicePill(name, isUp) {
const pill = document.getElementById('svc-' + name);
const light = document.getElementById('svc-light-' + name);
if (pill) { pill.classList.toggle('up', isUp); pill.classList.toggle('down', !isUp); }
if (light) { light.classList.toggle('up', isUp); light.classList.toggle('down', !isUp); }
}
async function pollProwlarr() {
try {
const resp = await fetch(URL_BASE + '/api/prowlarr', { credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
if (d.status !== 'ok') return;
const upEl = document.getElementById('prowlarr-up-count');
const totEl = document.getElementById('prowlarr-total');
if (upEl) upEl.textContent = d.up_count;
if (totEl) totEl.textContent = d.total;
d.indexers.forEach(idx => {
const row = document.getElementById('idx-' + idx.id);
const light = document.getElementById('idx-light-' + idx.id);
if (row) { row.classList.toggle('up', idx.up); row.classList.toggle('down', !idx.up); }
if (light) { light.classList.toggle('up', idx.up); light.classList.toggle('down', !idx.up); }
});
updateServicePill('prowlarr', d.up_count === d.total && d.total > 0);
const stat = document.getElementById('svc-stat-prowlarr');
if (stat) stat.textContent = d.up_count + '/' + d.total + ' up';
} catch (e) { /* silently skip */ }
}
pollProwlarr();
setInterval(pollProwlarr, 30000);
/* ── Header service lights ── */
async function pollServices() {
try {
const resp = await fetch(URL_BASE + '/api/services', { credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
for (const [name, isUp] of Object.entries(d)) updateServicePill(name, isUp);
} catch (e) { /* silently skip */ }
}
pollServices();
setInterval(pollServices, 60000);
/* ── Tautulli live streams ── */
async function pollTautulli() {
try {
const resp = await fetch(URL_BASE + '/api/tautulli', { credentials: 'include' });
if (!resp.ok) return;
const d = await resp.json();
if (d.status !== 'ok') return;
const count = d.stream_count;
const sessions = d.sessions || [];
const stat = document.getElementById('svc-stat-tautulli');
if (stat) stat.textContent = count > 0 ? count + ' streaming' : '0 streams';
const countEl = document.getElementById('tautulli-count');
if (countEl) countEl.textContent = count + ' active';
const body = document.getElementById('now-playing');
if (!body) return;
if (sessions.length) {
body.innerHTML = sessions.map(s => {
const icon = s.media_type === 'movie' ? '🎬' : '📺';
const esc = t => String(t).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
return `<div class="stream">
<span class="live-dot"></span>
<span class="stream-icon" aria-hidden="true">${icon}</span>
<span class="stream-info">
<span class="stream-title">${esc(s.title)}</span>
<span class="stream-user">${esc(s.user)}</span>
</span>
</div>`;
}).join('');
} else {
body.innerHTML = '<p class="empty">Nothing streaming right now.</p>';
}
} catch (e) { /* silently skip */ }
}
pollTautulli();
setInterval(pollTautulli, 15000);
/* ── Clear search results when the field is emptied ── */
document.getElementById('sonarr-search-input').addEventListener('input', function () {
if (!this.value.trim()) {
document.getElementById('sonarr-search-result').classList.remove('visible');
document.getElementById('sonarr-search-msg').style.display = 'none';
sonarrSearchResult = null;
}
});
document.getElementById('radarr-search-input').addEventListener('input', function () {
if (!this.value.trim()) {
document.getElementById('radarr-search-result').classList.remove('visible');
document.getElementById('radarr-search-msg').style.display = 'none';
radarrSearchResult = null;
}
});
/* ── Sonarr search ── */
async function searchSonarr() {
const input = document.getElementById('sonarr-search-input');
const btn = document.getElementById('sonarr-search-btn');
const resultEl = document.getElementById('sonarr-search-result');
const msgEl = document.getElementById('sonarr-search-msg');
const title = input.value.trim();
if (!title) return;
btn.disabled = true; btn.textContent = 'Searching…';
resultEl.classList.remove('visible');
msgEl.style.display = 'none';
sonarrSearchResult = null;
try {
const resp = await fetch(URL_BASE + '/api/sonarr/lookup?q=' + encodeURIComponent(title), { credentials: 'include' });
if (resp.redirected || !resp.headers.get('content-type')?.includes('application/json')) {
msgEl.textContent = 'Session expired — please refresh the page.';
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
btn.disabled = false; btn.textContent = 'Search'; return;
}
const d = await resp.json();
if (!resp.ok || d.error) {
msgEl.textContent = d.error || 'Search failed.';
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
} else if (!d.title) {
msgEl.textContent = 'No results found.';
msgEl.className = 'search-msg'; msgEl.style.display = 'block';
} else {
sonarrSearchResult = d;
document.getElementById('sonarr-result-title').textContent = d.title;
document.getElementById('sonarr-result-meta').textContent =
(d.year || '') + (d.network ? ' · ' + d.network : '') + (d.status ? ' · ' + d.status : '');
const addBtn = document.getElementById('sonarr-add-btn');
if (d.exists) {
addBtn.textContent = '✓ In library'; addBtn.disabled = true; addBtn.className = 'btn-add done';
} else {
addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add';
}
resultEl.classList.add('visible');
}
} catch (e) {
msgEl.textContent = 'Search failed: ' + e.message;
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
}
btn.disabled = false; btn.textContent = 'Search';
}
async function confirmAddSonarr() {
if (!sonarrSearchResult) return;
const addBtn = document.getElementById('sonarr-add-btn');
addBtn.disabled = true; addBtn.textContent = 'Adding…';
try {
const resp = await fetch(URL_BASE + '/add-to-sonarr', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
credentials: 'include', body: JSON.stringify({ title: sonarrSearchResult.title })
});
const d = await resp.json();
if ((resp.ok && d.success) || resp.status === 409) {
addBtn.textContent = '✓ Added'; addBtn.className = 'btn-add done';
} else {
addBtn.disabled = false; addBtn.textContent = '+ Add';
alert('Failed: ' + (d.error || 'Unknown error'));
}
} catch (e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; }
}
/* ── Radarr search ── */
async function searchRadarr() {
const input = document.getElementById('radarr-search-input');
const btn = document.getElementById('radarr-search-btn');
const resultEl = document.getElementById('radarr-search-result');
const msgEl = document.getElementById('radarr-search-msg');
const title = input.value.trim();
if (!title) return;
btn.disabled = true; btn.textContent = 'Searching…';
resultEl.classList.remove('visible');
msgEl.style.display = 'none';
radarrSearchResult = null;
try {
const resp = await fetch(URL_BASE + '/api/radarr/lookup?q=' + encodeURIComponent(title), { credentials: 'include' });
if (resp.redirected || !resp.headers.get('content-type')?.includes('application/json')) {
msgEl.textContent = 'Session expired — please refresh the page.';
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
btn.disabled = false; btn.textContent = 'Search'; return;
}
const d = await resp.json();
if (!resp.ok || d.error) {
msgEl.textContent = d.error || 'Search failed.';
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
} else if (!d.title) {
msgEl.textContent = 'No results found.';
msgEl.className = 'search-msg'; msgEl.style.display = 'block';
} else {
radarrSearchResult = d;
document.getElementById('radarr-result-title').textContent = d.title;
document.getElementById('radarr-result-meta').textContent =
(d.year || '') + (d.studio ? ' · ' + d.studio : '') + (d.runtime ? ' · ' + d.runtime + ' min' : '');
const addBtn = document.getElementById('radarr-add-btn');
if (d.exists) {
addBtn.textContent = '✓ In library'; addBtn.disabled = true; addBtn.className = 'btn-add done';
} else {
addBtn.textContent = '+ Add'; addBtn.disabled = false; addBtn.className = 'btn-add';
}
resultEl.classList.add('visible');
}
} catch (e) {
msgEl.textContent = 'Search failed: ' + e.message;
msgEl.className = 'search-msg error'; msgEl.style.display = 'block';
}
btn.disabled = false; btn.textContent = 'Search';
}
async function confirmAddRadarr() {
if (!radarrSearchResult) return;
const addBtn = document.getElementById('radarr-add-btn');
addBtn.disabled = true; addBtn.textContent = 'Adding…';
try {
const resp = await fetch(URL_BASE + '/add-to-radarr', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
credentials: 'include', body: JSON.stringify({ title: radarrSearchResult.title })
});
const d = await resp.json();
if ((resp.ok && d.success) || resp.status === 409) {
addBtn.textContent = '✓ Added'; addBtn.className = 'btn-add done';
} else {
addBtn.disabled = false; addBtn.textContent = '+ Add';
alert('Failed: ' + (d.error || 'Unknown error'));
}
} catch (e) { addBtn.disabled = false; addBtn.textContent = '+ Add'; }
}

61
static/js/settings.js Normal file
View file

@ -0,0 +1,61 @@
/* Reverse-proxy prefix, so /api/* still resolves on a subpath mount. */
const URL_BASE = document.body.dataset.urlBase || "";
function showToast(msg, isError) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.toggle('error', !!isError);
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2800);
}
async function post(url, payload) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload)
});
}
async function saveGeneral() {
try {
const resp = await post(URL_BASE + '/api/settings', {
timezone: document.getElementById('timezone').value,
arr_host: document.getElementById('arr_host').value.trim()
});
showToast(resp.ok ? 'General settings saved — reload the dashboard to apply.'
: 'Save failed.', !resp.ok);
} catch (e) { showToast('Network error — not saved.', true); }
}
async function saveService(name) {
try {
const key = document.getElementById(name + '_api_key').value.trim();
const resp = await post(URL_BASE + '/api/settings', { [name + '_api_key']: key });
const label = name.charAt(0).toUpperCase() + name.slice(1);
showToast(resp.ok ? label + ' key saved — restart the container to apply.'
: 'Save failed.', !resp.ok);
} catch (e) { showToast('Network error — not saved.', true); }
}
async function changePassword() {
const errEl = document.getElementById('pw-error');
errEl.style.display = 'none';
try {
const resp = await post(URL_BASE + '/api/change-password', {
current: document.getElementById('pw_current').value,
new: document.getElementById('pw_new').value,
confirm: document.getElementById('pw_confirm').value
});
const data = await resp.json();
if (resp.ok) {
['pw_current', 'pw_new', 'pw_confirm'].forEach(id => document.getElementById(id).value = '');
showToast('Password updated.');
} else {
errEl.textContent = data.error || 'Failed to update password.';
errEl.style.display = 'block';
showToast(data.error || 'Failed to update password.', true);
}
} catch (e) { showToast('Network error — password not changed.', true); }
}