Compare commits
2 commits
951914dd69
...
018bc58bbf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
018bc58bbf | ||
|
|
339b9f3310 |
8 changed files with 1484 additions and 4828 deletions
25
CLAUDE.md
25
CLAUDE.md
|
|
@ -3,8 +3,9 @@
|
||||||
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 "Greendale Media Centre." Runs as one Docker container on the
|
Branded "Media Centre" — a single neutral, professional dark theme (the
|
||||||
user's Unraid home server. Server-side project directory is named
|
earlier five novelty themes were removed). Runs as one Docker container on
|
||||||
|
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
|
||||||
|
|
@ -33,10 +34,20 @@ user's Unraid home server. Server-side project directory is 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`); duplicated at the top level too (`dashboard.html` etc.
|
`settings.html`). Stale duplicates of these once sat at the repo root;
|
||||||
outside `templates/`) — appears to be a leftover from an earlier project
|
they were never rendered (Flask loads from `templates/`) and were deleted
|
||||||
layout or the `export-to-unraid.sh` tooling. Left as-is rather than
|
during the redesign.
|
||||||
restructured without confirming with the user first.
|
- All three share one token set defined on `:root` — surfaces, a 6-step
|
||||||
|
`--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
|
||||||
|
|
@ -78,5 +89,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, theme, changed login password) also persists
|
`settings.json` (timezone, changed login password) also persists
|
||||||
server-side outside git.
|
server-side outside git.
|
||||||
|
|
|
||||||
6
app.py
6
app.py
|
|
@ -1001,7 +1001,6 @@ 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 = {
|
||||||
|
|
@ -1020,7 +1019,6 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1243,7 +1241,6 @@ 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"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1252,7 +1249,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", "theme"}
|
"tautulli_api_key", "prowlarr_api_key", "ombi_api_key", "arr_host"}
|
||||||
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})
|
||||||
|
|
@ -1264,7 +1261,6 @@ 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
1964
dashboard.html
File diff suppressed because it is too large
Load diff
203
login.html
203
login.html
|
|
@ -1,203 +0,0 @@
|
||||||
<!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>🔒 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
486
settings.html
|
|
@ -1,486 +0,0 @@
|
||||||
<!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
|
|
@ -3,200 +3,148 @@
|
||||||
<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>Greendale Media Centre — Login</title>
|
<title>Sign in — Media Centre</title>
|
||||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
<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 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.googleapis.com">
|
||||||
|
<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 {
|
||||||
--gold: #e8b84b;
|
--bg: #121110;
|
||||||
--gold-dk: #c49a2a;
|
--surface: #1a1816;
|
||||||
--teal: #2a9d8f;
|
--surface-2: #211e1b;
|
||||||
--teal-lt: #3dbdb0;
|
--border: #2c2823;
|
||||||
--navy: #1b2a3b;
|
--text: #f0ebe4;
|
||||||
--navy-lt: #243447;
|
--text-2: #b8ada0;
|
||||||
--navy-mid:#1e3050;
|
--text-3: #948879;
|
||||||
--panel: #192433;
|
--accent: #e07a35;
|
||||||
--border: #2e4460;
|
--danger: #e08a6e;
|
||||||
--text: #d8e4f0;
|
--danger-dim: rgba(224,138,110,0.1);
|
||||||
--muted: #7a93b0;
|
--fs-xs: 0.75rem;
|
||||||
--red: #e84b4b;
|
--fs-sm: 0.8125rem;
|
||||||
--green: #27c98f;
|
--fs-base: 0.9375rem;
|
||||||
|
--fs-lg: 1.125rem;
|
||||||
|
--radius: 10px;
|
||||||
|
--radius-sm: 7px;
|
||||||
|
--tap: 44px;
|
||||||
|
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', sans-serif;
|
margin: 0;
|
||||||
background: var(--navy);
|
font-family: var(--font);
|
||||||
|
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: 2rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-wrap {
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
.wrap { width: 100%; max-width: 360px; }
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Logo / brand ── */
|
|
||||||
.brand {
|
.brand {
|
||||||
text-align: center;
|
display: flex;
|
||||||
margin-bottom: 2rem;
|
align-items: center;
|
||||||
}
|
justify-content: center;
|
||||||
.brand .logo-badge {
|
gap: 0.6rem;
|
||||||
display: inline-block;
|
margin-bottom: 1.75rem;
|
||||||
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 {
|
||||||
font-family: 'Special Elite', cursive;
|
margin: 0;
|
||||||
font-size: 1.6rem;
|
font-size: var(--fs-lg);
|
||||||
color: var(--gold);
|
font-weight: 600;
|
||||||
letter-spacing: 1px;
|
letter-spacing: -0.01em;
|
||||||
}
|
|
||||||
.brand p {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-top: 0.3rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Card ── */
|
.card {
|
||||||
.login-card {
|
background: var(--surface);
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-top: 3px solid var(--gold);
|
border-radius: var(--radius);
|
||||||
border-radius: 12px;
|
padding: 1.5rem;
|
||||||
padding: 2rem 2rem 1.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card h2 {
|
label {
|
||||||
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: 0.72rem;
|
font-size: var(--fs-xs);
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
color: var(--text-2);
|
||||||
letter-spacing: 0.6px;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
.field input {
|
input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--navy-lt);
|
min-height: var(--tap);
|
||||||
|
padding: 0 0.85rem;
|
||||||
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.7rem 1rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: inherit;
|
||||||
|
font-size: 16px; /* prevents iOS zoom on focus */
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
.field input:focus { border-color: var(--gold); }
|
input:focus { border-color: var(--accent); }
|
||||||
.field input::placeholder { color: var(--muted); }
|
input::placeholder { color: var(--text-3); }
|
||||||
|
|
||||||
/* ── Error ── */
|
|
||||||
.error-msg {
|
.error-msg {
|
||||||
background: rgba(232,75,75,0.1);
|
background: var(--danger-dim);
|
||||||
border: 1px solid rgba(232,75,75,0.4);
|
border: 1px solid rgba(224,138,110,0.35);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.7rem 1rem;
|
padding: 0.7rem 0.85rem;
|
||||||
color: #ff8080;
|
color: var(--danger);
|
||||||
font-size: 0.83rem;
|
font-size: var(--fs-sm);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Submit button ── */
|
button {
|
||||||
.btn-login {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--gold);
|
min-height: var(--tap);
|
||||||
color: var(--navy);
|
margin-top: 1rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.8rem;
|
font-family: inherit;
|
||||||
font-size: 0.95rem;
|
font-size: var(--fs-sm);
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
font-family: 'Special Elite', cursive;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.15s, transform 0.1s;
|
transition: opacity 0.15s;
|
||||||
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="login-wrap">
|
<div class="wrap">
|
||||||
|
|
||||||
<div class="brand">
|
<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));">
|
<svg width="30" height="30" viewBox="0 0 100 100" aria-hidden="true">
|
||||||
<h1>Greendale Media Centre</h1>
|
<rect width="100" height="100" rx="22" fill="var(--accent)"/>
|
||||||
<p>Human beings welcome. Robots too.</p>
|
<path d="M40 32 L72 50 L40 68 Z" fill="var(--bg)"/>
|
||||||
|
</svg>
|
||||||
|
<h1>Media Centre</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="login-card">
|
<div class="card">
|
||||||
<h2>🔒 Study Room F — Access Required</h2>
|
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="error-msg">⚠️ {{ error }}</div>
|
<div class="error-msg" role="alert">{{ 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', '')) }}">
|
||||||
<div class="field">
|
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input
|
<input type="password" id="password" name="password"
|
||||||
type="password"
|
placeholder="Enter password" autofocus autocomplete="current-password">
|
||||||
id="password"
|
<button type="submit">Sign in</button>
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -3,245 +3,207 @@
|
||||||
<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">
|
||||||
{% if theme == 'office' %}<title>Settings — Dunder Mifflin Media Centre</title>
|
<title>Settings — Media Centre</title>
|
||||||
{% elif theme == 'parks' %}<title>Settings — Pawnee Parks 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 == 'potter' %}<title>Settings — Hogwarts Media Vault</title>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
{% elif theme == 'starwars' %}<title>Settings — Rebel Alliance Media Archive</title>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
{% else %}<title>Settings — Greendale Media Centre</title>{% endif %}
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
{% 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 {
|
||||||
--gold: #e8b84b;
|
--bg: #121110;
|
||||||
--teal: #2a9d8f;
|
--surface: #1a1816;
|
||||||
--teal-lt: #3dbdb0;
|
--surface-2: #211e1b;
|
||||||
--navy: #1b2a3b;
|
--border: #2c2823;
|
||||||
--navy-lt: #243447;
|
--border-lt: #3a342c;
|
||||||
--navy-mid:#1e3050;
|
--text: #f0ebe4;
|
||||||
--panel: #192433;
|
--text-2: #b8ada0;
|
||||||
--border: #2e4460;
|
--text-3: #948879;
|
||||||
--text: #d8e4f0;
|
--accent: #e07a35;
|
||||||
--muted: #7a93b0;
|
--ok: #8bc48a;
|
||||||
--red: #e84b4b;
|
--danger: #e08a6e;
|
||||||
--green: #27c98f;
|
--fs-xs: 0.75rem;
|
||||||
--brand-font: 'Special Elite', cursive;
|
--fs-sm: 0.8125rem;
|
||||||
|
--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' %}
|
|
||||||
:root {
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
--gold: #4a90d9; --gold-dk: #357abd; --teal: #6c757d; --teal-lt: #8e9aa8;
|
|
||||||
--navy: #1c2128; --navy-lt: #252d38; --navy-mid: #1a2232; --panel: #1e2530;
|
body {
|
||||||
--border: #334155; --text: #dce6f0; --muted: #7a8fa6; --red: #e05252; --green: #3aad74;
|
margin: 0;
|
||||||
--brand-font: 'Libre Baskerville', serif;
|
font-family: var(--font);
|
||||||
|
font-size: var(--fs-base);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
{% elif theme == 'parks' %}
|
|
||||||
:root {
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||||
--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(--navy-mid);
|
background: var(--surface);
|
||||||
border-bottom: 3px solid var(--gold);
|
border-bottom: 1px solid var(--border);
|
||||||
padding: 1.2rem 2rem;
|
padding: 0.75rem 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
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;
|
||||||
}
|
}
|
||||||
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 {
|
||||||
background: var(--navy-lt);
|
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: 1px solid var(--border);
|
||||||
color: var(--muted);
|
border-radius: var(--radius-sm);
|
||||||
border-radius: 8px;
|
color: var(--text-2);
|
||||||
padding: 0.45rem 0.9rem;
|
font-size: var(--fs-sm);
|
||||||
font-size: 0.82rem;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: border-color 0.2s;
|
white-space: nowrap;
|
||||||
|
transition: color 0.2s, border-color 0.2s;
|
||||||
}
|
}
|
||||||
.back-btn:hover { border-color: var(--gold); color: var(--gold); }
|
.back-btn:hover { color: var(--text); border-color: var(--border-lt); }
|
||||||
|
|
||||||
main { padding: 2rem; max-width: 900px; margin: 0 auto; }
|
main { padding: 1.5rem; max-width: 900px; margin: 0 auto; }
|
||||||
|
|
||||||
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
.grid {
|
||||||
@media (max-width: 700px) { .settings-grid { grid-template-columns: 1fr; } }
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: var(--gap);
|
||||||
|
}
|
||||||
|
.full { grid-column: 1 / -1; }
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--panel);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.card-header {
|
.card-header {
|
||||||
padding: 0.9rem 1.25rem;
|
padding: 0.85rem 1.1rem;
|
||||||
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 {
|
||||||
font-family: var(--brand-font);
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: var(--fs-sm);
|
||||||
color: var(--gold);
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.card-body { padding: 1.1rem 1.25rem; display: flex; flex-direction: column; gap: 0.85rem; }
|
.card-body { padding: 1.1rem; }
|
||||||
|
|
||||||
.field label {
|
.field { margin-bottom: 0.9rem; }
|
||||||
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.7rem;
|
font-size: var(--fs-xs);
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
color: var(--text-2);
|
||||||
letter-spacing: 0.6px;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
.field input, .field select {
|
input, select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--navy-lt);
|
min-height: var(--tap);
|
||||||
|
padding: 0 0.8rem;
|
||||||
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.6rem 0.9rem;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: inherit;
|
||||||
|
font-size: 16px; /* prevents iOS zoom on focus */
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
.field input:focus, .field select:focus { border-color: var(--gold); }
|
input:focus, select:focus { border-color: var(--accent); }
|
||||||
.field select option { background: var(--navy-mid); }
|
input::placeholder { color: var(--text-3); }
|
||||||
|
|
||||||
.save-btn {
|
.hint {
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.save {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--gold);
|
min-height: var(--tap);
|
||||||
color: var(--navy);
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.65rem;
|
font-family: inherit;
|
||||||
font-size: 0.9rem;
|
font-size: var(--fs-sm);
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
font-family: var(--brand-font);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
}
|
||||||
.save-btn:hover { opacity: 0.88; }
|
button.save:hover:not(:disabled) { opacity: 0.88; }
|
||||||
.save-btn:disabled { background: var(--border); color: var(--muted); cursor: default; }
|
button.save:disabled { background: var(--border); color: var(--text-3); cursor: default; }
|
||||||
|
|
||||||
.error-inline {
|
.error-inline {
|
||||||
font-size: 0.78rem;
|
font-size: var(--fs-xs);
|
||||||
color: var(--red);
|
color: var(--danger);
|
||||||
display: none;
|
display: none;
|
||||||
margin-top: -0.3rem;
|
margin-top: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed; bottom: 1.5rem; right: 1.5rem;
|
position: fixed;
|
||||||
background: var(--green); color: #fff;
|
bottom: 1.5rem;
|
||||||
padding: 0.7rem 1.2rem; border-radius: 10px;
|
right: 1.5rem;
|
||||||
font-size: 0.88rem; font-weight: 700;
|
background: var(--surface);
|
||||||
opacity: 0; transition: opacity 0.3s;
|
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;
|
pointer-events: none;
|
||||||
|
max-width: calc(100vw - 3rem);
|
||||||
}
|
}
|
||||||
.toast.error { background: var(--red); }
|
.toast.error { border-color: var(--danger); color: var(--danger); }
|
||||||
.toast.show { opacity: 1; }
|
.toast.show { opacity: 1; }
|
||||||
|
|
||||||
.section-label {
|
@media (max-width: 600px) {
|
||||||
font-family: var(--brand-font);
|
header { padding: 0.7rem 1rem; }
|
||||||
font-size: 0.8rem;
|
main { padding: 1rem; }
|
||||||
color: var(--muted);
|
.toast { left: 1rem; right: 1rem; bottom: 1rem; }
|
||||||
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>
|
||||||
<img src="{{ url_for('static', filename='logo.png') }}" alt="Greendale" id="header-logo">
|
<svg width="30" height="30" viewBox="0 0 100 100" aria-hidden="true">
|
||||||
|
<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="/" class="back-btn">← Dashboard</a>
|
<a href="{{ url_for('dashboard') }}" class="back-btn">← Dashboard</a>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="settings-grid">
|
<div class="grid">
|
||||||
|
|
||||||
<!-- General -->
|
<!-- General -->
|
||||||
<div class="card" style="grid-column: 1 / -1;">
|
<div class="card full">
|
||||||
<div class="card-header">
|
<div class="card-header"><h2>General</h2></div>
|
||||||
<span>🌍</span>
|
|
||||||
<h2>General</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Timezone</label>
|
<label for="timezone">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',
|
||||||
|
|
@ -257,228 +219,119 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Host IP (ARR_HOST)</label>
|
<label for="arr_host">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-btn" onclick="saveGeneral()">Save General</button>
|
<button class="save" onclick="saveGeneral()">Save general</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Theme -->
|
<!-- Change password -->
|
||||||
<div class="card" style="grid-column: 1 / -1;">
|
<div class="card full">
|
||||||
<div class="card-header">
|
<div class="card-header"><h2>Change password</h2></div>
|
||||||
<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>Current Password</label>
|
<label for="pw_current">Current password</label>
|
||||||
<input type="password" id="pw_current" placeholder="Enter current password">
|
<input type="password" id="pw_current" autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>New Password</label>
|
<label for="pw_new">New password</label>
|
||||||
<input type="password" id="pw_new" placeholder="At least 6 characters">
|
<input type="password" id="pw_new" placeholder="At least 6 characters" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Confirm New Password</label>
|
<label for="pw_confirm">Confirm new password</label>
|
||||||
<input type="password" id="pw_confirm" placeholder="Repeat new password">
|
<input type="password" id="pw_confirm" autocomplete="new-password">
|
||||||
<div class="error-inline" id="pw-error"></div>
|
<div class="error-inline" id="pw-error" role="alert"></div>
|
||||||
</div>
|
</div>
|
||||||
<button class="save-btn" onclick="changePassword()">Update Password</button>
|
<button class="save" onclick="changePassword()">Update password</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sonarr -->
|
<!-- Service API keys -->
|
||||||
|
{% 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"><span>📺</span><h2>Sonarr</h2></div>
|
<div class="card-header"><h2>{{ label }}</h2></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>API Key</label>
|
<label for="{{ svc }}_api_key">API key</label>
|
||||||
<input type="text" id="sonarr_api_key" value="{{ sonarr_api_key }}">
|
<input type="password" id="{{ svc }}_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-btn" onclick="saveService('sonarr')">Save</button>
|
<button class="save" onclick="saveService('{{ svc }}')">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">✓ Saved</div>
|
<div class="toast" id="toast" role="status" aria-live="polite"></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 || '✓ Saved';
|
t.textContent = msg;
|
||||||
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 saveGeneral() {
|
async function post(url, payload) {
|
||||||
const payload = {
|
return fetch(url, {
|
||||||
timezone: document.getElementById('timezone').value,
|
method: 'POST',
|
||||||
arr_host: document.getElementById('arr_host').value.trim(),
|
headers: { 'Content-Type': 'application/json' },
|
||||||
};
|
credentials: 'include',
|
||||||
const resp = await fetch('/api/settings', {
|
body: JSON.stringify(payload)
|
||||||
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 saveGeneral() {
|
||||||
|
try {
|
||||||
|
const resp = await post('/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) {
|
async function saveService(name) {
|
||||||
const keyEl = document.getElementById(name + '_api_key');
|
try {
|
||||||
const payload = { [name + '_api_key']: keyEl.value.trim() };
|
const key = document.getElementById(name + '_api_key').value.trim();
|
||||||
const resp = await fetch('/api/settings', {
|
const resp = await post('/api/settings', { [name + '_api_key']: key });
|
||||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
const label = name.charAt(0).toUpperCase() + name.slice(1);
|
||||||
credentials: 'include', body: JSON.stringify(payload)
|
showToast(resp.ok ? label + ' key saved — restart the container to apply.'
|
||||||
});
|
: 'Save failed.', !resp.ok);
|
||||||
if (resp.ok) showToast('✓ ' + name.charAt(0).toUpperCase() + name.slice(1) + ' saved — restart container to apply');
|
} catch (e) { showToast('Network error — not saved.', true); }
|
||||||
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 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');
|
const errEl = document.getElementById('pw-error');
|
||||||
errEl.style.display = 'none';
|
errEl.style.display = 'none';
|
||||||
|
try {
|
||||||
const resp = await fetch('/api/change-password', {
|
const resp = await post('/api/change-password', {
|
||||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
current: document.getElementById('pw_current').value,
|
||||||
credentials: 'include', body: JSON.stringify({ current, new: newPw, confirm })
|
new: document.getElementById('pw_new').value,
|
||||||
|
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 = '';
|
|
||||||
showToast('✓ Password updated successfully');
|
|
||||||
} else {
|
} else {
|
||||||
errEl.textContent = data.error || 'Failed to update password';
|
errEl.textContent = data.error || 'Failed to update password.';
|
||||||
errEl.style.display = 'block';
|
errEl.style.display = 'block';
|
||||||
showToast('✗ ' + (data.error || 'Failed'), true);
|
showToast(data.error || 'Failed to update password.', true);
|
||||||
}
|
}
|
||||||
|
} catch (e) { showToast('Network error — password not changed.', true); }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue