Compare commits

..

No commits in common. "018bc58bbf72614fa22391cb586f83e1dccd4951" and "951914dd6929cc8ebfb4540e6edfbc72ff3a197b" have entirely different histories.

8 changed files with 4814 additions and 1470 deletions

View file

@ -3,9 +3,8 @@
Flask dashboard aggregating status from the user's self-hosted media stack Flask dashboard aggregating status from the user's self-hosted media stack
— Sonarr, Radarr, SABnzbd, Tautulli, Prowlarr, Ombi — plus Unraid array — Sonarr, Radarr, SABnzbd, Tautulli, Prowlarr, Ombi — plus Unraid array
stats and a Rotten Tomatoes "what's popular that I don't have yet" feed. stats and a Rotten Tomatoes "what's popular that I don't have yet" feed.
Branded "Media Centre" — a single neutral, professional dark theme (the Branded "Greendale Media Centre." Runs as one Docker container on the
earlier five novelty themes were removed). Runs as one Docker container on user's Unraid home server. Server-side project directory is named
the user's Unraid home server. Server-side project directory is still named
`greendale`; the container/repo are named `arr-summary`. `greendale`; the container/repo are named `arr-summary`.
## Architecture ## Architecture
@ -34,20 +33,10 @@ the user's Unraid home server. Server-side project directory is still named
`BeautifulSoup`, JSON-LD parsing), not via an official API — brittle `BeautifulSoup`, JSON-LD parsing), not via an official API — brittle
to upstream HTML changes by nature. to upstream HTML changes by nature.
- **`templates/`** — Jinja2 templates (`dashboard.html`, `login.html`, - **`templates/`** — Jinja2 templates (`dashboard.html`, `login.html`,
`settings.html`). Stale duplicates of these once sat at the repo root; `settings.html`); duplicated at the top level too (`dashboard.html` etc.
they were never rendered (Flask loads from `templates/`) and were deleted outside `templates/`) — appears to be a leftover from an earlier project
during the redesign. layout or the `export-to-unraid.sh` tooling. Left as-is rather than
- All three share one token set defined on `:root` — surfaces, a 6-step restructured without confirming with the user first.
`--fs-*` type scale, semantic colours, and `--tap: 44px` for minimum
touch targets. Add new UI with those tokens rather than literal values.
- `dashboard.html` is ordered by urgency, deliberately mirroring the cache
tiers in `app.py`: a "Right now" strip (live streams, download queue,
array usage) sits above the fold, with library, discovery, requests and
watch insights below it.
- Degraded services render through the `service_error()` macro, which
pairs `friendly_error` (human cause) with the raw exception behind a
disclosure, passed through `redact_secrets` — SABnzbd and Tautulli put
their API key in the request URL, so it appears in raw tracebacks.
- **`export-to-unraid.sh`** — a pre-existing standalone deploy script - **`export-to-unraid.sh`** — a pre-existing standalone deploy script
(build image locally → tarball → rsync + `docker load` over SSH) that (build image locally → tarball → rsync + `docker load` over SSH) that
predates the Forgejo pipeline below. Still present, not removed, but the predates the Forgejo pipeline below. Still present, not removed, but the
@ -89,5 +78,5 @@ pipeline mechanics. Push to `main` on the `forgejo` remote
and `LOGIN_PASSWORD` — this project was already doing this correctly and `LOGIN_PASSWORD` — this project was already doing this correctly
before the Forgejo migration (compose file uses `${VAR}` substitution from before the Forgejo migration (compose file uses `${VAR}` substitution from
`.env`, no hardcoded secrets), unlike `n64-tracker` which needed fixing. `.env`, no hardcoded secrets), unlike `n64-tracker` which needed fixing.
`settings.json` (timezone, changed login password) also persists `settings.json` (timezone, theme, changed login password) also persists
server-side outside git. server-side outside git.

6
app.py
View file

@ -1001,6 +1001,7 @@ def dashboard():
s = load_settings() s = load_settings()
timezone = s.get("timezone", "America/New_York") timezone = s.get("timezone", "America/New_York")
theme = s.get("theme", "community")
# Public-facing URLs for card header links (via NPM reverse proxy) # Public-facing URLs for card header links (via NPM reverse proxy)
arr_host_public = f"https://hippofam.com" arr_host_public = f"https://hippofam.com"
service_urls = { service_urls = {
@ -1019,6 +1020,7 @@ def dashboard():
rt_movies=rt_movies, rt_tv=rt_tv, rt_movies=rt_movies, rt_tv=rt_tv,
sonarr_titles=sonarr_titles, radarr_titles=radarr_titles, sonarr_titles=sonarr_titles, radarr_titles=radarr_titles,
timezone=timezone, timezone=timezone,
theme=theme,
service_urls=service_urls, service_urls=service_urls,
) )
@ -1241,6 +1243,7 @@ def settings_page():
prowlarr_api_key=PROWLARR_API_KEY, prowlarr_api_key=PROWLARR_API_KEY,
ombi_api_key=OMBI_API_KEY, ombi_api_key=OMBI_API_KEY,
arr_host=os.environ.get("ARR_HOST", ""), arr_host=os.environ.get("ARR_HOST", ""),
theme=s.get("theme", "community"),
) )
@ -1249,7 +1252,7 @@ def settings_page():
def api_save_settings(): def api_save_settings():
data = request.get_json() data = request.get_json()
allowed = {"timezone", "sonarr_api_key", "radarr_api_key", "sabnzbd_api_key", allowed = {"timezone", "sonarr_api_key", "radarr_api_key", "sabnzbd_api_key",
"tautulli_api_key", "prowlarr_api_key", "ombi_api_key", "arr_host"} "tautulli_api_key", "prowlarr_api_key", "ombi_api_key", "arr_host", "theme"}
filtered = {k: v for k, v in data.items() if k in allowed} filtered = {k: v for k, v in data.items() if k in allowed}
save_settings(filtered) save_settings(filtered)
return jsonify({"success": True}) return jsonify({"success": True})
@ -1261,6 +1264,7 @@ def api_get_settings():
s = load_settings() s = load_settings()
return jsonify({ return jsonify({
"timezone": s.get("timezone", "America/New_York"), "timezone": s.get("timezone", "America/New_York"),
"theme": s.get("theme", "community"),
}) })

1964
dashboard.html Normal file

File diff suppressed because it is too large Load diff

203
login.html Normal file
View file

@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Greendale Media Centre — Login</title>
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link href="https://fonts.googleapis.com/css2?family=Special+Elite&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<style>
:root {
--gold: #e8b84b;
--gold-dk: #c49a2a;
--teal: #2a9d8f;
--teal-lt: #3dbdb0;
--navy: #1b2a3b;
--navy-lt: #243447;
--navy-mid:#1e3050;
--panel: #192433;
--border: #2e4460;
--text: #d8e4f0;
--muted: #7a93b0;
--red: #e84b4b;
--green: #27c98f;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', sans-serif;
background: var(--navy);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
.login-wrap {
width: 100%;
max-width: 400px;
}
/* ── Logo / brand ── */
.brand {
text-align: center;
margin-bottom: 2rem;
}
.brand .logo-badge {
display: inline-block;
background: var(--gold);
color: var(--navy);
font-family: 'Special Elite', cursive;
font-size: 1.3rem;
font-weight: 700;
padding: 0.5rem 1.2rem;
border-radius: 8px;
letter-spacing: 1px;
margin-bottom: 0.85rem;
}
.brand h1 {
font-family: 'Special Elite', cursive;
font-size: 1.6rem;
color: var(--gold);
letter-spacing: 1px;
}
.brand p {
font-size: 0.8rem;
color: var(--muted);
margin-top: 0.3rem;
font-style: italic;
}
/* ── Card ── */
.login-card {
background: var(--panel);
border: 1px solid var(--border);
border-top: 3px solid var(--gold);
border-radius: 12px;
padding: 2rem 2rem 1.75rem;
}
.login-card h2 {
font-family: 'Special Elite', cursive;
font-size: 1rem;
color: var(--muted);
text-align: center;
margin-bottom: 1.5rem;
letter-spacing: 0.5px;
}
/* ── Form fields ── */
.field { margin-bottom: 1.1rem; }
.field label {
display: block;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
margin-bottom: 0.4rem;
}
.field input {
width: 100%;
background: var(--navy-lt);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.7rem 1rem;
font-size: 0.95rem;
color: var(--text);
font-family: 'Inter', sans-serif;
outline: none;
transition: border-color 0.2s;
}
.field input:focus { border-color: var(--gold); }
.field input::placeholder { color: var(--muted); }
/* ── Error ── */
.error-msg {
background: rgba(232,75,75,0.1);
border: 1px solid rgba(232,75,75,0.4);
border-radius: 8px;
padding: 0.7rem 1rem;
color: #ff8080;
font-size: 0.83rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* ── Submit button ── */
.btn-login {
width: 100%;
background: var(--gold);
color: var(--navy);
border: none;
border-radius: 8px;
padding: 0.8rem;
font-size: 0.95rem;
font-weight: 700;
font-family: 'Special Elite', cursive;
letter-spacing: 0.5px;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
margin-top: 0.4rem;
}
.btn-login:hover { opacity: 0.88; }
.btn-login:active { transform: scale(0.98); }
/* ── Footer quote ── */
.login-footer {
text-align: center;
margin-top: 1.5rem;
font-family: 'Special Elite', cursive;
font-size: 0.72rem;
color: var(--muted);
font-style: italic;
}
</style>
</head>
<body>
<div class="login-wrap">
<div class="brand">
<img src="{{ url_for('static', filename='logo.png') }}" alt="Greendale Community College" style="width:90px; height:90px; object-fit:contain; margin-bottom:0.75rem; filter:drop-shadow(0 2px 8px rgba(0,0,0,0.4));">
<h1>Greendale Media Centre</h1>
<p>Human beings welcome. Robots too.</p>
</div>
<div class="login-card">
<h2>🔒 &nbsp;Study Room F — Access Required</h2>
{% if error %}
<div class="error-msg">⚠️ {{ error }}</div>
{% endif %}
<form method="POST" action="{{ url_for('login', next=request.args.get('next', '')) }}">
<div class="field">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="Enter password"
autofocus
autocomplete="current-password"
>
</div>
<button type="submit" class="btn-login">Enter Study Room →</button>
</form>
</div>
<div class="login-footer">
"I discovered at a young age that I have… a gift." — Jeff Winger
</div>
</div>
</body>
</html>

486
settings.html Normal file
View file

@ -0,0 +1,486 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% if theme == 'office' %}<title>Settings — Dunder Mifflin Media Centre</title>
{% elif theme == 'parks' %}<title>Settings — Pawnee Parks Media Centre</title>
{% elif theme == 'potter' %}<title>Settings — Hogwarts Media Vault</title>
{% elif theme == 'starwars' %}<title>Settings — Rebel Alliance Media Archive</title>
{% else %}<title>Settings — Greendale Media Centre</title>{% endif %}
{% if theme == 'office' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📎</text></svg>">
{% elif theme == 'parks' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌳</text></svg>">
{% elif theme == 'potter' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
{% elif theme == 'starwars' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
{% else %}<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">{% endif %}
{% if theme == 'office' %}
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
{% elif theme == 'parks' %}
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
{% elif theme == 'potter' %}
<link href="https://fonts.googleapis.com/css2?family=MedievalSharp&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
{% elif theme == 'starwars' %}
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
{% else %}
<link href="https://fonts.googleapis.com/css2?family=Special+Elite&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
{% endif %}
<style>
:root {
--gold: #e8b84b;
--teal: #2a9d8f;
--teal-lt: #3dbdb0;
--navy: #1b2a3b;
--navy-lt: #243447;
--navy-mid:#1e3050;
--panel: #192433;
--border: #2e4460;
--text: #d8e4f0;
--muted: #7a93b0;
--red: #e84b4b;
--green: #27c98f;
--brand-font: 'Special Elite', cursive;
}
{% if theme == 'office' %}
:root {
--gold: #4a90d9; --gold-dk: #357abd; --teal: #6c757d; --teal-lt: #8e9aa8;
--navy: #1c2128; --navy-lt: #252d38; --navy-mid: #1a2232; --panel: #1e2530;
--border: #334155; --text: #dce6f0; --muted: #7a8fa6; --red: #e05252; --green: #3aad74;
--brand-font: 'Libre Baskerville', serif;
}
{% elif theme == 'parks' %}
:root {
--gold: #5aad4e; --gold-dk: #3d8c33; --teal: #c8842a; --teal-lt: #e09a3a;
--navy: #1a2213; --navy-lt: #222e19; --navy-mid: #1d2a16; --panel: #1a2615;
--border: #2d4220; --text: #d8edd0; --muted: #7a9a72; --red: #e84b4b; --green: #5aad4e;
--brand-font: 'Playfair Display', serif;
}
{% elif theme == 'potter' %}
:root {
--gold: #d4af37; --gold-dk: #b8961e; --teal: #7b2d8b; --teal-lt: #9b3dab;
--navy: #1a1228; --navy-lt: #231934; --navy-mid: #1d1530; --panel: #1a1228;
--border: #3d2a5a; --text: #e8dff8; --muted: #9080b8; --red: #8b1a1a; --green: #2d6e3a;
--brand-font: 'MedievalSharp', cursive;
}
{% elif theme == 'starwars' %}
:root {
--gold: #ffe81f; --gold-dk: #d4c000; --teal: #4fc3f7; --teal-lt: #81d4fa;
--navy: #080c14; --navy-lt: #0f1620; --navy-mid: #0a1018; --panel: #0d1422;
--border: #1e2d42; --text: #d0e8ff; --muted: #607d9e; --red: #e84b4b; --green: #00e676;
--brand-font: 'Orbitron', sans-serif;
}
{% endif %}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: var(--navy); color: var(--text); min-height: 100vh; }
header {
background: var(--navy-mid);
border-bottom: 3px solid var(--gold);
padding: 1.2rem 2rem;
display: flex;
align-items: center;
gap: 1rem;
}
header img { width: 40px; height: 40px; object-fit: contain; }
header h1 { font-family: var(--brand-font); font-size: 1.3rem; color: var(--gold); flex: 1; }
.back-btn {
background: var(--navy-lt);
border: 1px solid var(--border);
color: var(--muted);
border-radius: 8px;
padding: 0.45rem 0.9rem;
font-size: 0.82rem;
text-decoration: none;
transition: border-color 0.2s;
}
.back-btn:hover { border-color: var(--gold); color: var(--gold); }
main { padding: 2rem; max-width: 900px; margin: 0 auto; }
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
@media (max-width: 700px) { .settings-grid { grid-template-columns: 1fr; } }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.card-header {
padding: 0.9rem 1.25rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 0.6rem;
}
.card-header h2 {
font-family: var(--brand-font);
font-size: 1rem;
color: var(--gold);
}
.card-body { padding: 1.1rem 1.25rem; display: flex; flex-direction: column; gap: 0.85rem; }
.field label {
display: block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
margin-bottom: 0.35rem;
}
.field input, .field select {
width: 100%;
background: var(--navy-lt);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 0.9rem;
font-size: 0.88rem;
color: var(--text);
font-family: 'Inter', sans-serif;
outline: none;
transition: border-color 0.2s;
}
.field input:focus, .field select:focus { border-color: var(--gold); }
.field select option { background: var(--navy-mid); }
.save-btn {
width: 100%;
background: var(--gold);
color: var(--navy);
border: none;
border-radius: 8px;
padding: 0.65rem;
font-size: 0.9rem;
font-weight: 700;
font-family: var(--brand-font);
cursor: pointer;
transition: opacity 0.15s;
margin-top: 0.25rem;
}
.save-btn:hover { opacity: 0.88; }
.save-btn:disabled { background: var(--border); color: var(--muted); cursor: default; }
.error-inline {
font-size: 0.78rem;
color: var(--red);
display: none;
margin-top: -0.3rem;
}
.toast {
position: fixed; bottom: 1.5rem; right: 1.5rem;
background: var(--green); color: #fff;
padding: 0.7rem 1.2rem; border-radius: 10px;
font-size: 0.88rem; font-weight: 700;
opacity: 0; transition: opacity 0.3s;
pointer-events: none;
}
.toast.error { background: var(--red); }
.toast.show { opacity: 1; }
.section-label {
font-family: var(--brand-font);
font-size: 0.8rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 1rem;
margin-top: 0.5rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--border);
}
/* Theme preview chips */
.theme-options {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.75rem;
}
.theme-chip {
border: 2px solid var(--border);
border-radius: 10px;
padding: 0.75rem 0.6rem 0.6rem;
cursor: pointer;
text-align: center;
transition: border-color 0.2s, background 0.2s;
background: var(--navy-lt);
}
.theme-chip:hover { border-color: var(--gold); }
.theme-chip.active { border-color: var(--gold); background: rgba(232,184,75,0.08); }
.theme-chip .tc-emoji { font-size: 1.6rem; margin-bottom: 0.35rem; }
.theme-chip .tc-name {
font-size: 0.72rem;
font-weight: 700;
color: var(--text);
font-family: var(--brand-font);
letter-spacing: 0.3px;
}
.theme-chip .tc-sub {
font-size: 0.65rem;
color: var(--muted);
margin-top: 0.15rem;
}
</style>
</head>
<body>
<header>
<img src="{{ url_for('static', filename='logo.png') }}" alt="Greendale" id="header-logo">
<h1>Settings</h1>
<a href="/" class="back-btn">← Dashboard</a>
</header>
<main>
<div class="settings-grid">
<!-- General -->
<div class="card" style="grid-column: 1 / -1;">
<div class="card-header">
<span>🌍</span>
<h2>General</h2>
</div>
<div class="card-body">
<div class="field">
<label>Timezone</label>
<select id="timezone">
{% set tzs = [
'America/New_York','America/Chicago','America/Denver','America/Los_Angeles',
'America/Toronto','America/Vancouver','America/Phoenix','America/Halifax',
'Europe/London','Europe/Paris','Europe/Berlin','Europe/Amsterdam',
'Europe/Stockholm','Europe/Rome','Europe/Madrid','Europe/Zurich',
'Australia/Sydney','Australia/Melbourne','Australia/Brisbane','Australia/Perth',
'Pacific/Auckland','Asia/Tokyo','Asia/Singapore','Asia/Dubai','Asia/Kolkata'
] %}
{% for tz in tzs %}
<option value="{{ tz }}" {% if tz == timezone %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label>Host IP (ARR_HOST)</label>
<input type="text" id="arr_host" value="{{ arr_host }}" placeholder="e.g. 192.168.86.33">
</div>
<button class="save-btn" onclick="saveGeneral()">Save General</button>
</div>
</div>
<!-- Theme -->
<div class="card" style="grid-column: 1 / -1;">
<div class="card-header">
<span>🎨</span>
<h2>Theme</h2>
</div>
<div class="card-body">
<div class="theme-options" id="theme-options">
<div class="theme-chip {% if theme == 'community' %}active{% endif %}" data-theme="community" onclick="selectTheme(this)">
<div class="tc-emoji">🏫</div>
<div class="tc-name">Community</div>
<div class="tc-sub">Greendale College</div>
</div>
<div class="theme-chip {% if theme == 'office' %}active{% endif %}" data-theme="office" onclick="selectTheme(this)">
<div class="tc-emoji">📎</div>
<div class="tc-name">The Office</div>
<div class="tc-sub">Dunder Mifflin</div>
</div>
<div class="theme-chip {% if theme == 'parks' %}active{% endif %}" data-theme="parks" onclick="selectTheme(this)">
<div class="tc-emoji">🌳</div>
<div class="tc-name">Parks & Rec</div>
<div class="tc-sub">Pawnee, Indiana</div>
</div>
<div class="theme-chip {% if theme == 'potter' %}active{% endif %}" data-theme="potter" onclick="selectTheme(this)">
<div class="tc-emoji">⚡</div>
<div class="tc-name">Harry Potter</div>
<div class="tc-sub">Hogwarts</div>
</div>
<div class="theme-chip {% if theme == 'starwars' %}active{% endif %}" data-theme="starwars" onclick="selectTheme(this)">
<div class="tc-emoji">🌌</div>
<div class="tc-name">Star Wars</div>
<div class="tc-sub">A long time ago…</div>
</div>
</div>
<button class="save-btn" onclick="saveTheme()">Apply Theme</button>
</div>
</div>
<!-- Change Password -->
<div class="card" style="grid-column: 1 / -1;">
<div class="card-header">
<span>🔐</span>
<h2>Change Password</h2>
</div>
<div class="card-body">
<div class="field">
<label>Current Password</label>
<input type="password" id="pw_current" placeholder="Enter current password">
</div>
<div class="field">
<label>New Password</label>
<input type="password" id="pw_new" placeholder="At least 6 characters">
</div>
<div class="field">
<label>Confirm New Password</label>
<input type="password" id="pw_confirm" placeholder="Repeat new password">
<div class="error-inline" id="pw-error"></div>
</div>
<button class="save-btn" onclick="changePassword()">Update Password</button>
</div>
</div>
<!-- Sonarr -->
<div class="card">
<div class="card-header"><span>📺</span><h2>Sonarr</h2></div>
<div class="card-body">
<div class="field">
<label>API Key</label>
<input type="text" id="sonarr_api_key" value="{{ sonarr_api_key }}">
</div>
<button class="save-btn" onclick="saveService('sonarr')">Save</button>
</div>
</div>
<!-- Radarr -->
<div class="card">
<div class="card-header"><span>🎬</span><h2>Radarr</h2></div>
<div class="card-body">
<div class="field">
<label>API Key</label>
<input type="text" id="radarr_api_key" value="{{ radarr_api_key }}">
</div>
<button class="save-btn" onclick="saveService('radarr')">Save</button>
</div>
</div>
<!-- SABnzbd -->
<div class="card">
<div class="card-header"><span>⬇️</span><h2>SABnzbd</h2></div>
<div class="card-body">
<div class="field">
<label>API Key</label>
<input type="text" id="sabnzbd_api_key" value="{{ sabnzbd_api_key }}">
</div>
<button class="save-btn" onclick="saveService('sabnzbd')">Save</button>
</div>
</div>
<!-- Tautulli -->
<div class="card">
<div class="card-header"><span>▶️</span><h2>Tautulli</h2></div>
<div class="card-body">
<div class="field">
<label>API Key</label>
<input type="text" id="tautulli_api_key" value="{{ tautulli_api_key }}">
</div>
<button class="save-btn" onclick="saveService('tautulli')">Save</button>
</div>
</div>
<!-- Prowlarr -->
<div class="card">
<div class="card-header"><span>🔍</span><h2>Prowlarr</h2></div>
<div class="card-body">
<div class="field">
<label>API Key</label>
<input type="text" id="prowlarr_api_key" value="{{ prowlarr_api_key }}">
</div>
<button class="save-btn" onclick="saveService('prowlarr')">Save</button>
</div>
</div>
<!-- Ombi -->
<div class="card">
<div class="card-header"><span>🎟️</span><h2>Ombi</h2></div>
<div class="card-body">
<div class="field">
<label>API Key</label>
<input type="text" id="ombi_api_key" value="{{ ombi_api_key }}">
</div>
<button class="save-btn" onclick="saveService('ombi')">Save</button>
</div>
</div>
</div>
</main>
<div class="toast" id="toast">✓ Saved</div>
<script>
let _selectedTheme = '{{ theme }}';
function showToast(msg, isError) {
const t = document.getElementById('toast');
t.textContent = msg || '✓ Saved';
t.classList.toggle('error', !!isError);
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2800);
}
async function saveGeneral() {
const payload = {
timezone: document.getElementById('timezone').value,
arr_host: document.getElementById('arr_host').value.trim(),
};
const resp = await fetch('/api/settings', {
method: 'POST', headers: {'Content-Type': 'application/json'},
credentials: 'include', body: JSON.stringify(payload)
});
if (resp.ok) showToast('✓ General saved — reload dashboard for clock update');
else showToast('✗ Save failed', true);
}
async function saveService(name) {
const keyEl = document.getElementById(name + '_api_key');
const payload = { [name + '_api_key']: keyEl.value.trim() };
const resp = await fetch('/api/settings', {
method: 'POST', headers: {'Content-Type': 'application/json'},
credentials: 'include', body: JSON.stringify(payload)
});
if (resp.ok) showToast('✓ ' + name.charAt(0).toUpperCase() + name.slice(1) + ' saved — restart container to apply');
else showToast('✗ Save failed', true);
}
function selectTheme(el) {
document.querySelectorAll('.theme-chip').forEach(c => c.classList.remove('active'));
el.classList.add('active');
_selectedTheme = el.dataset.theme;
}
async function saveTheme() {
const resp = await fetch('/api/settings', {
method: 'POST', headers: {'Content-Type': 'application/json'},
credentials: 'include', body: JSON.stringify({ theme: _selectedTheme })
});
if (resp.ok) {
showToast('✓ Theme applied! Heading to dashboard…');
setTimeout(() => { window.location.href = '/'; }, 900);
} else {
showToast('✗ Save failed', true);
}
}
async function changePassword() {
const current = document.getElementById('pw_current').value;
const newPw = document.getElementById('pw_new').value;
const confirm = document.getElementById('pw_confirm').value;
const errEl = document.getElementById('pw-error');
errEl.style.display = 'none';
const resp = await fetch('/api/change-password', {
method: 'POST', headers: {'Content-Type': 'application/json'},
credentials: 'include', body: JSON.stringify({ current, new: newPw, confirm })
});
const data = await resp.json();
if (resp.ok) {
document.getElementById('pw_current').value = '';
document.getElementById('pw_new').value = '';
document.getElementById('pw_confirm').value = '';
showToast('✓ Password updated successfully');
} else {
errEl.textContent = data.error || 'Failed to update password';
errEl.style.display = 'block';
showToast('✗ ' + (data.error || 'Failed'), true);
}
}
</script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -3,148 +3,200 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in — Media Centre</title> <title>Greendale Media Centre — Login</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23e07a35'/><path d='M40 32 L72 50 L40 68 Z' fill='%23121110'/></svg>"> <link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link href="https://fonts.googleapis.com/css2?family=Special+Elite&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> <style>
:root { :root {
--bg: #121110; --gold: #e8b84b;
--surface: #1a1816; --gold-dk: #c49a2a;
--surface-2: #211e1b; --teal: #2a9d8f;
--border: #2c2823; --teal-lt: #3dbdb0;
--text: #f0ebe4; --navy: #1b2a3b;
--text-2: #b8ada0; --navy-lt: #243447;
--text-3: #948879; --navy-mid:#1e3050;
--accent: #e07a35; --panel: #192433;
--danger: #e08a6e; --border: #2e4460;
--danger-dim: rgba(224,138,110,0.1); --text: #d8e4f0;
--fs-xs: 0.75rem; --muted: #7a93b0;
--fs-sm: 0.8125rem; --red: #e84b4b;
--fs-base: 0.9375rem; --green: #27c98f;
--fs-lg: 1.125rem;
--radius: 10px;
--radius-sm: 7px;
--tap: 44px;
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
} }
*, *::before, *::after { box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
margin: 0; font-family: 'Inter', sans-serif;
font-family: var(--font); background: var(--navy);
font-size: var(--fs-base);
background: var(--bg);
color: var(--text); color: var(--text);
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 1.5rem; padding: 2rem;
} }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } .login-wrap {
width: 100%;
.wrap { width: 100%; max-width: 360px; } max-width: 400px;
}
/* ── Logo / brand ── */
.brand { .brand {
display: flex; text-align: center;
align-items: center; margin-bottom: 2rem;
justify-content: center; }
gap: 0.6rem; .brand .logo-badge {
margin-bottom: 1.75rem; display: inline-block;
background: var(--gold);
color: var(--navy);
font-family: 'Special Elite', cursive;
font-size: 1.3rem;
font-weight: 700;
padding: 0.5rem 1.2rem;
border-radius: 8px;
letter-spacing: 1px;
margin-bottom: 0.85rem;
} }
.brand h1 { .brand h1 {
margin: 0; font-family: 'Special Elite', cursive;
font-size: var(--fs-lg); font-size: 1.6rem;
font-weight: 600; color: var(--gold);
letter-spacing: -0.01em; letter-spacing: 1px;
}
.brand p {
font-size: 0.8rem;
color: var(--muted);
margin-top: 0.3rem;
font-style: italic;
} }
.card { /* ── Card ── */
background: var(--surface); .login-card {
background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-top: 3px solid var(--gold);
padding: 1.5rem; border-radius: 12px;
padding: 2rem 2rem 1.75rem;
} }
label { .login-card h2 {
font-family: 'Special Elite', cursive;
font-size: 1rem;
color: var(--muted);
text-align: center;
margin-bottom: 1.5rem;
letter-spacing: 0.5px;
}
/* ── Form fields ── */
.field { margin-bottom: 1.1rem; }
.field label {
display: block; display: block;
font-size: var(--fs-xs); font-size: 0.72rem;
font-weight: 600; font-weight: 700;
color: var(--text-2); text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
margin-bottom: 0.4rem; margin-bottom: 0.4rem;
} }
input { .field input {
width: 100%; width: 100%;
min-height: var(--tap); background: var(--navy-lt);
padding: 0 0.85rem;
background: var(--surface-2);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: 8px;
padding: 0.7rem 1rem;
font-size: 0.95rem;
color: var(--text); color: var(--text);
font-family: inherit; font-family: 'Inter', sans-serif;
font-size: 16px; /* prevents iOS zoom on focus */
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
input:focus { border-color: var(--accent); } .field input:focus { border-color: var(--gold); }
input::placeholder { color: var(--text-3); } .field input::placeholder { color: var(--muted); }
/* ── Error ── */
.error-msg { .error-msg {
background: var(--danger-dim); background: rgba(232,75,75,0.1);
border: 1px solid rgba(224,138,110,0.35); border: 1px solid rgba(232,75,75,0.4);
border-radius: var(--radius-sm); border-radius: 8px;
padding: 0.7rem 0.85rem; padding: 0.7rem 1rem;
color: var(--danger); color: #ff8080;
font-size: var(--fs-sm); font-size: 0.83rem;
margin-bottom: 1rem; margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
} }
button { /* ── Submit button ── */
.btn-login {
width: 100%; width: 100%;
min-height: var(--tap); background: var(--gold);
margin-top: 1rem; color: var(--navy);
background: var(--accent);
color: var(--bg);
border: none; border: none;
border-radius: var(--radius-sm); border-radius: 8px;
font-family: inherit; padding: 0.8rem;
font-size: var(--fs-sm); font-size: 0.95rem;
font-weight: 600; font-weight: 700;
font-family: 'Special Elite', cursive;
letter-spacing: 0.5px;
cursor: pointer; cursor: pointer;
transition: opacity 0.15s; transition: opacity 0.15s, transform 0.1s;
margin-top: 0.4rem;
}
.btn-login:hover { opacity: 0.88; }
.btn-login:active { transform: scale(0.98); }
/* ── Footer quote ── */
.login-footer {
text-align: center;
margin-top: 1.5rem;
font-family: 'Special Elite', cursive;
font-size: 0.72rem;
color: var(--muted);
font-style: italic;
} }
button:hover { opacity: 0.88; }
</style> </style>
</head> </head>
<body> <body>
<div class="wrap"> <div class="login-wrap">
<div class="brand"> <div class="brand">
<svg width="30" height="30" viewBox="0 0 100 100" aria-hidden="true"> <img src="{{ url_for('static', filename='logo.png') }}" alt="Greendale Community College" style="width:90px; height:90px; object-fit:contain; margin-bottom:0.75rem; filter:drop-shadow(0 2px 8px rgba(0,0,0,0.4));">
<rect width="100" height="100" rx="22" fill="var(--accent)"/> <h1>Greendale Media Centre</h1>
<path d="M40 32 L72 50 L40 68 Z" fill="var(--bg)"/> <p>Human beings welcome. Robots too.</p>
</svg>
<h1>Media Centre</h1>
</div> </div>
<div class="card"> <div class="login-card">
<h2>🔒 &nbsp;Study Room F — Access Required</h2>
{% if error %} {% if error %}
<div class="error-msg" role="alert">{{ error }}</div> <div class="error-msg">⚠️ {{ error }}</div>
{% endif %} {% endif %}
<form method="POST" action="{{ url_for('login', next=request.args.get('next', '')) }}"> <form method="POST" action="{{ url_for('login', next=request.args.get('next', '')) }}">
<label for="password">Password</label> <div class="field">
<input type="password" id="password" name="password" <label for="password">Password</label>
placeholder="Enter password" autofocus autocomplete="current-password"> <input
<button type="submit">Sign in</button> type="password"
id="password"
name="password"
placeholder="Enter password"
autofocus
autocomplete="current-password"
>
</div>
<button type="submit" class="btn-login">Enter Study Room →</button>
</form> </form>
</div> </div>
<div class="login-footer">
"I discovered at a young age that I have… a gift." — Jeff Winger
</div>
</div> </div>
</body> </body>

View file

@ -3,207 +3,245 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings — Media Centre</title> {% if theme == 'office' %}<title>Settings — Dunder Mifflin Media Centre</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23e07a35'/><path d='M40 32 L72 50 L40 68 Z' fill='%23121110'/></svg>"> {% elif theme == 'parks' %}<title>Settings — Pawnee Parks Media Centre</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> {% elif theme == 'potter' %}<title>Settings — Hogwarts Media Vault</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> {% elif theme == 'starwars' %}<title>Settings — Rebel Alliance Media Archive</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> {% else %}<title>Settings — Greendale Media Centre</title>{% endif %}
{% if theme == 'office' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📎</text></svg>">
{% elif theme == 'parks' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌳</text></svg>">
{% elif theme == 'potter' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
{% elif theme == 'starwars' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
{% else %}<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">{% endif %}
{% if theme == 'office' %}
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
{% elif theme == 'parks' %}
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
{% elif theme == 'potter' %}
<link href="https://fonts.googleapis.com/css2?family=MedievalSharp&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
{% elif theme == 'starwars' %}
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
{% else %}
<link href="https://fonts.googleapis.com/css2?family=Special+Elite&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
{% endif %}
<style> <style>
:root { :root {
--bg: #121110; --gold: #e8b84b;
--surface: #1a1816; --teal: #2a9d8f;
--surface-2: #211e1b; --teal-lt: #3dbdb0;
--border: #2c2823; --navy: #1b2a3b;
--border-lt: #3a342c; --navy-lt: #243447;
--text: #f0ebe4; --navy-mid:#1e3050;
--text-2: #b8ada0; --panel: #192433;
--text-3: #948879; --border: #2e4460;
--accent: #e07a35; --text: #d8e4f0;
--ok: #8bc48a; --muted: #7a93b0;
--danger: #e08a6e; --red: #e84b4b;
--fs-xs: 0.75rem; --green: #27c98f;
--fs-sm: 0.8125rem; --brand-font: 'Special Elite', cursive;
--fs-base: 0.9375rem;
--fs-lg: 1.125rem;
--gap: 1rem;
--radius: 10px;
--radius-sm: 7px;
--tap: 44px;
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
} }
{% if theme == 'office' %}
*, *::before, *::after { box-sizing: border-box; } :root {
--gold: #4a90d9; --gold-dk: #357abd; --teal: #6c757d; --teal-lt: #8e9aa8;
body { --navy: #1c2128; --navy-lt: #252d38; --navy-mid: #1a2232; --panel: #1e2530;
margin: 0; --border: #334155; --text: #dce6f0; --muted: #7a8fa6; --red: #e05252; --green: #3aad74;
font-family: var(--font); --brand-font: 'Libre Baskerville', serif;
font-size: var(--fs-base);
background: var(--bg);
color: var(--text);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
} }
{% elif theme == 'parks' %}
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } :root {
--gold: #5aad4e; --gold-dk: #3d8c33; --teal: #c8842a; --teal-lt: #e09a3a;
--navy: #1a2213; --navy-lt: #222e19; --navy-mid: #1d2a16; --panel: #1a2615;
--border: #2d4220; --text: #d8edd0; --muted: #7a9a72; --red: #e84b4b; --green: #5aad4e;
--brand-font: 'Playfair Display', serif;
}
{% elif theme == 'potter' %}
:root {
--gold: #d4af37; --gold-dk: #b8961e; --teal: #7b2d8b; --teal-lt: #9b3dab;
--navy: #1a1228; --navy-lt: #231934; --navy-mid: #1d1530; --panel: #1a1228;
--border: #3d2a5a; --text: #e8dff8; --muted: #9080b8; --red: #8b1a1a; --green: #2d6e3a;
--brand-font: 'MedievalSharp', cursive;
}
{% elif theme == 'starwars' %}
:root {
--gold: #ffe81f; --gold-dk: #d4c000; --teal: #4fc3f7; --teal-lt: #81d4fa;
--navy: #080c14; --navy-lt: #0f1620; --navy-mid: #0a1018; --panel: #0d1422;
--border: #1e2d42; --text: #d0e8ff; --muted: #607d9e; --red: #e84b4b; --green: #00e676;
--brand-font: 'Orbitron', sans-serif;
}
{% endif %}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: var(--navy); color: var(--text); min-height: 100vh; }
header { header {
background: var(--surface); background: var(--navy-mid);
border-bottom: 1px solid var(--border); border-bottom: 3px solid var(--gold);
padding: 0.75rem 1.5rem; padding: 1.2rem 2rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.65rem; gap: 1rem;
position: sticky;
top: 0;
z-index: 100;
}
header h1 {
margin: 0;
font-size: var(--fs-lg);
font-weight: 600;
letter-spacing: -0.01em;
} }
header img { width: 40px; height: 40px; object-fit: contain; }
header h1 { font-family: var(--brand-font); font-size: 1.3rem; color: var(--gold); flex: 1; }
.back-btn { .back-btn {
margin-left: auto; background: var(--navy-lt);
display: inline-flex;
align-items: center;
min-height: var(--tap);
padding: 0 0.9rem;
background: var(--surface-2);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); color: var(--muted);
color: var(--text-2); border-radius: 8px;
font-size: var(--fs-sm); padding: 0.45rem 0.9rem;
font-size: 0.82rem;
text-decoration: none; text-decoration: none;
white-space: nowrap; transition: border-color 0.2s;
transition: color 0.2s, border-color 0.2s;
} }
.back-btn:hover { color: var(--text); border-color: var(--border-lt); } .back-btn:hover { border-color: var(--gold); color: var(--gold); }
main { padding: 1.5rem; max-width: 900px; margin: 0 auto; } main { padding: 2rem; max-width: 900px; margin: 0 auto; }
.grid { .settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
display: grid; @media (max-width: 700px) { .settings-grid { grid-template-columns: 1fr; } }
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--gap);
}
.full { grid-column: 1 / -1; }
.card { .card {
background: var(--surface); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: 12px;
overflow: hidden;
} }
.card-header { .card-header {
padding: 0.85rem 1.1rem; padding: 0.9rem 1.25rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 0.6rem;
} }
.card-header h2 { .card-header h2 {
margin: 0; font-family: var(--brand-font);
font-size: var(--fs-sm); font-size: 1rem;
font-weight: 600; color: var(--gold);
} }
.card-body { padding: 1.1rem; } .card-body { padding: 1.1rem 1.25rem; display: flex; flex-direction: column; gap: 0.85rem; }
.field { margin-bottom: 0.9rem; } .field label {
label {
display: block; display: block;
font-size: var(--fs-xs); font-size: 0.7rem;
font-weight: 600; font-weight: 700;
color: var(--text-2); text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
} }
input, select { .field input, .field select {
width: 100%; width: 100%;
min-height: var(--tap); background: var(--navy-lt);
padding: 0 0.8rem;
background: var(--surface-2);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: 8px;
padding: 0.6rem 0.9rem;
font-size: 0.88rem;
color: var(--text); color: var(--text);
font-family: inherit; font-family: 'Inter', sans-serif;
font-size: 16px; /* prevents iOS zoom on focus */
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
} }
input:focus, select:focus { border-color: var(--accent); } .field input:focus, .field select:focus { border-color: var(--gold); }
input::placeholder { color: var(--text-3); } .field select option { background: var(--navy-mid); }
.hint { .save-btn {
font-size: var(--fs-xs);
color: var(--text-3);
margin-top: 0.35rem;
}
button.save {
width: 100%; width: 100%;
min-height: var(--tap); background: var(--gold);
background: var(--accent); color: var(--navy);
color: var(--bg);
border: none; border: none;
border-radius: var(--radius-sm); border-radius: 8px;
font-family: inherit; padding: 0.65rem;
font-size: var(--fs-sm); font-size: 0.9rem;
font-weight: 600; font-weight: 700;
font-family: var(--brand-font);
cursor: pointer; cursor: pointer;
transition: opacity 0.15s; transition: opacity 0.15s;
margin-top: 0.25rem;
} }
button.save:hover:not(:disabled) { opacity: 0.88; } .save-btn:hover { opacity: 0.88; }
button.save:disabled { background: var(--border); color: var(--text-3); cursor: default; } .save-btn:disabled { background: var(--border); color: var(--muted); cursor: default; }
.error-inline { .error-inline {
font-size: var(--fs-xs); font-size: 0.78rem;
color: var(--danger); color: var(--red);
display: none; display: none;
margin-top: 0.35rem; margin-top: -0.3rem;
} }
.toast { .toast {
position: fixed; position: fixed; bottom: 1.5rem; right: 1.5rem;
bottom: 1.5rem; background: var(--green); color: #fff;
right: 1.5rem; padding: 0.7rem 1.2rem; border-radius: 10px;
background: var(--surface); font-size: 0.88rem; font-weight: 700;
border: 1px solid var(--ok); opacity: 0; transition: opacity 0.3s;
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; pointer-events: none;
max-width: calc(100vw - 3rem);
} }
.toast.error { border-color: var(--danger); color: var(--danger); } .toast.error { background: var(--red); }
.toast.show { opacity: 1; } .toast.show { opacity: 1; }
@media (max-width: 600px) { .section-label {
header { padding: 0.7rem 1rem; } font-family: var(--brand-font);
main { padding: 1rem; } font-size: 0.8rem;
.toast { left: 1rem; right: 1rem; bottom: 1rem; } color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 1rem;
margin-top: 0.5rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--border);
}
/* Theme preview chips */
.theme-options {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.75rem;
}
.theme-chip {
border: 2px solid var(--border);
border-radius: 10px;
padding: 0.75rem 0.6rem 0.6rem;
cursor: pointer;
text-align: center;
transition: border-color 0.2s, background 0.2s;
background: var(--navy-lt);
}
.theme-chip:hover { border-color: var(--gold); }
.theme-chip.active { border-color: var(--gold); background: rgba(232,184,75,0.08); }
.theme-chip .tc-emoji { font-size: 1.6rem; margin-bottom: 0.35rem; }
.theme-chip .tc-name {
font-size: 0.72rem;
font-weight: 700;
color: var(--text);
font-family: var(--brand-font);
letter-spacing: 0.3px;
}
.theme-chip .tc-sub {
font-size: 0.65rem;
color: var(--muted);
margin-top: 0.15rem;
} }
</style> </style>
</head> </head>
<body> <body>
<header> <header>
<svg width="30" height="30" viewBox="0 0 100 100" aria-hidden="true"> <img src="{{ url_for('static', filename='logo.png') }}" alt="Greendale" id="header-logo">
<rect width="100" height="100" rx="22" fill="var(--accent)"/>
<path d="M40 32 L72 50 L40 68 Z" fill="var(--bg)"/>
</svg>
<h1>Settings</h1> <h1>Settings</h1>
<a href="{{ url_for('dashboard') }}" class="back-btn">← Dashboard</a> <a href="/" class="back-btn">← Dashboard</a>
</header> </header>
<main> <main>
<div class="grid"> <div class="settings-grid">
<!-- General --> <!-- General -->
<div class="card full"> <div class="card" style="grid-column: 1 / -1;">
<div class="card-header"><h2>General</h2></div> <div class="card-header">
<span>🌍</span>
<h2>General</h2>
</div>
<div class="card-body"> <div class="card-body">
<div class="field"> <div class="field">
<label for="timezone">Timezone</label> <label>Timezone</label>
<select id="timezone"> <select id="timezone">
{% set tzs = [ {% set tzs = [
'America/New_York','America/Chicago','America/Denver','America/Los_Angeles', 'America/New_York','America/Chicago','America/Denver','America/Los_Angeles',
@ -219,119 +257,228 @@
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label for="arr_host">Host IP (ARR_HOST)</label> <label>Host IP (ARR_HOST)</label>
<input type="text" id="arr_host" value="{{ arr_host }}" placeholder="e.g. 192.168.86.33"> <input type="text" id="arr_host" value="{{ arr_host }}" placeholder="e.g. 192.168.86.33">
<p class="hint">LAN address of the machine running Sonarr, Radarr, and the rest.</p>
</div> </div>
<button class="save" onclick="saveGeneral()">Save general</button> <button class="save-btn" onclick="saveGeneral()">Save General</button>
</div> </div>
</div> </div>
<!-- Change password --> <!-- Theme -->
<div class="card full"> <div class="card" style="grid-column: 1 / -1;">
<div class="card-header"><h2>Change password</h2></div> <div class="card-header">
<span>🎨</span>
<h2>Theme</h2>
</div>
<div class="card-body">
<div class="theme-options" id="theme-options">
<div class="theme-chip {% if theme == 'community' %}active{% endif %}" data-theme="community" onclick="selectTheme(this)">
<div class="tc-emoji">🏫</div>
<div class="tc-name">Community</div>
<div class="tc-sub">Greendale College</div>
</div>
<div class="theme-chip {% if theme == 'office' %}active{% endif %}" data-theme="office" onclick="selectTheme(this)">
<div class="tc-emoji">📎</div>
<div class="tc-name">The Office</div>
<div class="tc-sub">Dunder Mifflin</div>
</div>
<div class="theme-chip {% if theme == 'parks' %}active{% endif %}" data-theme="parks" onclick="selectTheme(this)">
<div class="tc-emoji">🌳</div>
<div class="tc-name">Parks & Rec</div>
<div class="tc-sub">Pawnee, Indiana</div>
</div>
<div class="theme-chip {% if theme == 'potter' %}active{% endif %}" data-theme="potter" onclick="selectTheme(this)">
<div class="tc-emoji">⚡</div>
<div class="tc-name">Harry Potter</div>
<div class="tc-sub">Hogwarts</div>
</div>
<div class="theme-chip {% if theme == 'starwars' %}active{% endif %}" data-theme="starwars" onclick="selectTheme(this)">
<div class="tc-emoji">🌌</div>
<div class="tc-name">Star Wars</div>
<div class="tc-sub">A long time ago…</div>
</div>
</div>
<button class="save-btn" onclick="saveTheme()">Apply Theme</button>
</div>
</div>
<!-- Change Password -->
<div class="card" style="grid-column: 1 / -1;">
<div class="card-header">
<span>🔐</span>
<h2>Change Password</h2>
</div>
<div class="card-body"> <div class="card-body">
<div class="field"> <div class="field">
<label for="pw_current">Current password</label> <label>Current Password</label>
<input type="password" id="pw_current" autocomplete="current-password"> <input type="password" id="pw_current" placeholder="Enter current password">
</div> </div>
<div class="field"> <div class="field">
<label for="pw_new">New password</label> <label>New Password</label>
<input type="password" id="pw_new" placeholder="At least 6 characters" autocomplete="new-password"> <input type="password" id="pw_new" placeholder="At least 6 characters">
</div> </div>
<div class="field"> <div class="field">
<label for="pw_confirm">Confirm new password</label> <label>Confirm New Password</label>
<input type="password" id="pw_confirm" autocomplete="new-password"> <input type="password" id="pw_confirm" placeholder="Repeat new password">
<div class="error-inline" id="pw-error" role="alert"></div> <div class="error-inline" id="pw-error"></div>
</div> </div>
<button class="save" onclick="changePassword()">Update password</button> <button class="save-btn" onclick="changePassword()">Update Password</button>
</div> </div>
</div> </div>
<!-- Service API keys --> <!-- Sonarr -->
{% for svc, label in [
('sonarr','Sonarr'), ('radarr','Radarr'), ('sabnzbd','SABnzbd'),
('tautulli','Tautulli'), ('prowlarr','Prowlarr'), ('ombi','Ombi')
] %}
<div class="card"> <div class="card">
<div class="card-header"><h2>{{ label }}</h2></div> <div class="card-header"><span>📺</span><h2>Sonarr</h2></div>
<div class="card-body"> <div class="card-body">
<div class="field"> <div class="field">
<label for="{{ svc }}_api_key">API key</label> <label>API Key</label>
<input type="password" id="{{ svc }}_api_key" <input type="text" id="sonarr_api_key" value="{{ sonarr_api_key }}">
value="{{ {'sonarr': sonarr_api_key, 'radarr': radarr_api_key,
'sabnzbd': sabnzbd_api_key, 'tautulli': tautulli_api_key,
'prowlarr': prowlarr_api_key, 'ombi': ombi_api_key}[svc] }}"
autocomplete="off">
</div> </div>
<button class="save" onclick="saveService('{{ svc }}')">Save</button> <button class="save-btn" onclick="saveService('sonarr')">Save</button>
</div>
</div>
<!-- Radarr -->
<div class="card">
<div class="card-header"><span>🎬</span><h2>Radarr</h2></div>
<div class="card-body">
<div class="field">
<label>API Key</label>
<input type="text" id="radarr_api_key" value="{{ radarr_api_key }}">
</div>
<button class="save-btn" onclick="saveService('radarr')">Save</button>
</div>
</div>
<!-- SABnzbd -->
<div class="card">
<div class="card-header"><span>⬇️</span><h2>SABnzbd</h2></div>
<div class="card-body">
<div class="field">
<label>API Key</label>
<input type="text" id="sabnzbd_api_key" value="{{ sabnzbd_api_key }}">
</div>
<button class="save-btn" onclick="saveService('sabnzbd')">Save</button>
</div>
</div>
<!-- Tautulli -->
<div class="card">
<div class="card-header"><span>▶️</span><h2>Tautulli</h2></div>
<div class="card-body">
<div class="field">
<label>API Key</label>
<input type="text" id="tautulli_api_key" value="{{ tautulli_api_key }}">
</div>
<button class="save-btn" onclick="saveService('tautulli')">Save</button>
</div>
</div>
<!-- Prowlarr -->
<div class="card">
<div class="card-header"><span>🔍</span><h2>Prowlarr</h2></div>
<div class="card-body">
<div class="field">
<label>API Key</label>
<input type="text" id="prowlarr_api_key" value="{{ prowlarr_api_key }}">
</div>
<button class="save-btn" onclick="saveService('prowlarr')">Save</button>
</div>
</div>
<!-- Ombi -->
<div class="card">
<div class="card-header"><span>🎟️</span><h2>Ombi</h2></div>
<div class="card-body">
<div class="field">
<label>API Key</label>
<input type="text" id="ombi_api_key" value="{{ ombi_api_key }}">
</div>
<button class="save-btn" onclick="saveService('ombi')">Save</button>
</div> </div>
</div> </div>
{% endfor %}
</div> </div>
</main> </main>
<div class="toast" id="toast" role="status" aria-live="polite"></div> <div class="toast" id="toast">✓ Saved</div>
<script> <script>
let _selectedTheme = '{{ theme }}';
function showToast(msg, isError) { function showToast(msg, isError) {
const t = document.getElementById('toast'); const t = document.getElementById('toast');
t.textContent = msg; t.textContent = msg || '✓ Saved';
t.classList.toggle('error', !!isError); t.classList.toggle('error', !!isError);
t.classList.add('show'); t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2800); 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() { async function saveGeneral() {
try { const payload = {
const resp = await post('/api/settings', { timezone: document.getElementById('timezone').value,
timezone: document.getElementById('timezone').value, arr_host: document.getElementById('arr_host').value.trim(),
arr_host: document.getElementById('arr_host').value.trim() };
}); const resp = await fetch('/api/settings', {
showToast(resp.ok ? 'General settings saved — reload the dashboard to apply.' method: 'POST', headers: {'Content-Type': 'application/json'},
: 'Save failed.', !resp.ok); credentials: 'include', body: JSON.stringify(payload)
} catch (e) { showToast('Network error — not saved.', true); } });
if (resp.ok) showToast('✓ General saved — reload dashboard for clock update');
else showToast('✗ Save failed', true);
} }
async function saveService(name) { async function saveService(name) {
try { const keyEl = document.getElementById(name + '_api_key');
const key = document.getElementById(name + '_api_key').value.trim(); const payload = { [name + '_api_key']: keyEl.value.trim() };
const resp = await post('/api/settings', { [name + '_api_key']: key }); const resp = await fetch('/api/settings', {
const label = name.charAt(0).toUpperCase() + name.slice(1); method: 'POST', headers: {'Content-Type': 'application/json'},
showToast(resp.ok ? label + ' key saved — restart the container to apply.' credentials: 'include', body: JSON.stringify(payload)
: 'Save failed.', !resp.ok); });
} catch (e) { showToast('Network error — not saved.', true); } if (resp.ok) showToast('✓ ' + name.charAt(0).toUpperCase() + name.slice(1) + ' saved — restart container to apply');
else showToast('✗ Save failed', true);
}
function selectTheme(el) {
document.querySelectorAll('.theme-chip').forEach(c => c.classList.remove('active'));
el.classList.add('active');
_selectedTheme = el.dataset.theme;
}
async function saveTheme() {
const resp = await fetch('/api/settings', {
method: 'POST', headers: {'Content-Type': 'application/json'},
credentials: 'include', body: JSON.stringify({ theme: _selectedTheme })
});
if (resp.ok) {
showToast('✓ Theme applied! Heading to dashboard…');
setTimeout(() => { window.location.href = '/'; }, 900);
} else {
showToast('✗ Save failed', true);
}
} }
async function changePassword() { async function changePassword() {
const errEl = document.getElementById('pw-error'); const current = document.getElementById('pw_current').value;
const newPw = document.getElementById('pw_new').value;
const confirm = document.getElementById('pw_confirm').value;
const errEl = document.getElementById('pw-error');
errEl.style.display = 'none'; errEl.style.display = 'none';
try {
const resp = await post('/api/change-password', { const resp = await fetch('/api/change-password', {
current: document.getElementById('pw_current').value, method: 'POST', headers: {'Content-Type': 'application/json'},
new: document.getElementById('pw_new').value, credentials: 'include', body: JSON.stringify({ current, new: newPw, confirm })
confirm: document.getElementById('pw_confirm').value });
}); const data = await resp.json();
const data = await resp.json(); if (resp.ok) {
if (resp.ok) { document.getElementById('pw_current').value = '';
['pw_current', 'pw_new', 'pw_confirm'].forEach(id => document.getElementById(id).value = ''); document.getElementById('pw_new').value = '';
showToast('Password updated.'); document.getElementById('pw_confirm').value = '';
} else { showToast('✓ Password updated successfully');
errEl.textContent = data.error || 'Failed to update password.'; } else {
errEl.style.display = 'block'; errEl.textContent = data.error || 'Failed to update password';
showToast(data.error || 'Failed to update password.', true); errEl.style.display = 'block';
} showToast('✗ ' + (data.error || 'Failed'), true);
} catch (e) { showToast('Network error — password not changed.', true); } }
} }
</script> </script>