Extract CSS/JS to static files, wrap header pills, add version footer
All checks were successful
Deploy / deploy (push) Successful in 23s
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:
parent
faf25a811a
commit
18c7719cd7
12 changed files with 1396 additions and 1413 deletions
74
static/css/base.css
Normal file
74
static/css/base.css
Normal 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
604
static/css/dashboard.css
Normal 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
83
static/css/login.css
Normal 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
138
static/css/settings.css
Normal 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
4
static/favicon.svg
Normal 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
400
static/js/dashboard.js
Normal 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 =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
61
static/js/settings.js
Normal 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); }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue