Merge pull request 'Server web UI: modern themeable layout with dark mode, timezone moved into config' (#1) from worktree-golden-imagining-lightning into main
Build and push server image / build-and-push (push) Successful in 36s
Build and push server image / build-and-push (push) Successful in 36s
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
+4
-4
@@ -2,10 +2,10 @@ FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# tzdata: python:3.12-slim doesn't include it by default, so a TZ
|
||||
# environment variable (see docker-compose.yml.example -- used by the
|
||||
# "Quiet hours" setting) would silently fail to resolve and fall back to
|
||||
# UTC without this.
|
||||
# tzdata: python:3.12-slim doesn't include it by default, so the zoneinfo
|
||||
# database backing the web UI's "Timezone" setting (used by "Quiet hours")
|
||||
# would have no named zones to resolve without this -- ZoneInfo() would
|
||||
# raise for anything other than "UTC".
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
+6
-5
@@ -52,7 +52,7 @@ algorithm itself -- it just streams the response straight to the panel.
|
||||
upcoming grid -- not Immich URL/API key, see Setup above)
|
||||
- `GET /api/albums` -- lists Immich albums (used by the config UI)
|
||||
- `POST /api/config` -- saves
|
||||
album/order/orientation/refresh_interval_s/smart_crop_faces/queue_target_len/quiet_hours_*.
|
||||
album/order/orientation/refresh_interval_s/smart_crop_faces/queue_target_len/quiet_hours_*/timezone.
|
||||
`orientation` (`landscape`, `portrait`, `landscape_flipped`,
|
||||
`portrait_flipped`) matches how the frame is physically hung: photos
|
||||
are composed/cropped for that shape (portrait crops at 480x800), then
|
||||
@@ -66,10 +66,11 @@ algorithm itself -- it just streams the response straight to the panel.
|
||||
(`"HH:MM"`, may wrap past midnight, e.g. `22:00`-`07:00`) don't touch
|
||||
the device at all -- purely a server decision about what
|
||||
`refresh_interval_s` to hand back from `GET /frame/config` below,
|
||||
computed in `_effective_refresh_interval_s`. Uses the server's local
|
||||
timezone (`TZ` in `docker-compose.yml.example` -- the image needs
|
||||
`tzdata` for a named zone to actually resolve, already installed in
|
||||
the provided `Dockerfile`). The device can still land one wake right
|
||||
computed in `_effective_refresh_interval_s`. Interpreted in the
|
||||
`timezone` set from the web UI's "Timezone" dropdown (an IANA zone
|
||||
name, e.g. `America/New_York`; defaults to `UTC`) -- no
|
||||
docker-compose.yml edit or container restart needed to change it. The
|
||||
device can still land one wake right
|
||||
at the start of the window (nothing server-side can prevent that
|
||||
without touching the firmware, since the device doesn't know wall-clock
|
||||
time), but from that wake on it's told to sleep exactly until the
|
||||
|
||||
+11
-6
@@ -27,15 +27,20 @@ class FrameConfig(BaseModel):
|
||||
order: str = "sequential" # or "shuffle"
|
||||
refresh_interval_s: int = 3600
|
||||
# Quiet hours: no point waking the device overnight just to swap a
|
||||
# photo nobody's looking at. Times are "HH:MM" in the server's local
|
||||
# timezone (see docker-compose.yml.example's TZ note) and may wrap
|
||||
# past midnight (e.g. start=22:00, end=07:00). Purely a server-side
|
||||
# decision -- the device is unaware, it just gets told a longer
|
||||
# refresh_interval_s by GET /frame/config while quiet hours are in
|
||||
# effect (see main.py's _effective_refresh_interval_s).
|
||||
# photo nobody's looking at. Times are "HH:MM" interpreted in
|
||||
# `timezone` below and may wrap past midnight (e.g. start=22:00,
|
||||
# end=07:00). Purely a server-side decision -- the device is unaware,
|
||||
# it just gets told a longer refresh_interval_s by GET /frame/config
|
||||
# while quiet hours are in effect (see main.py's
|
||||
# _effective_refresh_interval_s).
|
||||
quiet_hours_enabled: bool = False
|
||||
quiet_hours_start: str = "22:00"
|
||||
quiet_hours_end: str = "07:00"
|
||||
# IANA zone name (e.g. "America/New_York") quiet_hours_start/end are
|
||||
# interpreted in. Set from the web UI rather than the container's TZ
|
||||
# environment variable, so it survives container recreation and
|
||||
# doesn't need a docker-compose.yml edit to change.
|
||||
timezone: str = "UTC"
|
||||
smart_crop_faces: bool = True
|
||||
# How the physical frame is hung: landscape (native), portrait,
|
||||
# landscape_flipped, portrait_flipped. Purely a server-side render
|
||||
|
||||
+24
-2
@@ -7,6 +7,7 @@ import io
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo, available_timezones
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, FastAPI, File, HTTPException, Form, Request, UploadFile
|
||||
@@ -32,6 +33,11 @@ MAX_QUEUE_TARGET_LEN = 5000
|
||||
|
||||
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
||||
|
||||
# Populated once from the OS's zoneinfo database (installed via the
|
||||
# `tzdata` apt package in the Dockerfile) and offered as a <select> in the
|
||||
# web UI's "Timezone" field -- see api_config_save/index below.
|
||||
ALL_TIMEZONES = sorted(available_timezones())
|
||||
|
||||
MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
|
||||
|
||||
# Battery-history / estimate tuning (see /frame/battery and _battery_estimate).
|
||||
@@ -54,6 +60,17 @@ def _valid_hhmm(s: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _zoneinfo(name: str) -> ZoneInfo:
|
||||
"""Falls back to UTC for an unrecognized zone name -- defensive only;
|
||||
api_config_save already validates against ALL_TIMEZONES before saving,
|
||||
so this only matters for a config.json hand-edited or written by an
|
||||
older version of this file."""
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except Exception:
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def _quiet_hours_state(now: datetime, start_str: str, end_str: str) -> tuple[bool, datetime | None]:
|
||||
"""Whether `now` falls inside the quiet-hours window, and the next
|
||||
boundary: if inside, when it ends; if outside, when it next starts.
|
||||
@@ -102,7 +119,7 @@ def _effective_refresh_interval_s(cfg: config.FrameConfig) -> int:
|
||||
sleep exactly until the window ends."""
|
||||
if not cfg.quiet_hours_enabled:
|
||||
return cfg.refresh_interval_s
|
||||
now = datetime.now()
|
||||
now = datetime.now(_zoneinfo(cfg.timezone))
|
||||
in_quiet, boundary = _quiet_hours_state(now, cfg.quiet_hours_start, cfg.quiet_hours_end)
|
||||
if boundary is None:
|
||||
return cfg.refresh_interval_s
|
||||
@@ -199,7 +216,9 @@ def index(request: Request):
|
||||
"token_prompt.html", {"request": request, "wrong": supplied is not None}
|
||||
)
|
||||
|
||||
response = templates.TemplateResponse("index.html", {"request": request, "cfg": cfg})
|
||||
response = templates.TemplateResponse(
|
||||
"index.html", {"request": request, "cfg": cfg, "timezones": ALL_TIMEZONES}
|
||||
)
|
||||
supplied = request.query_params.get("token")
|
||||
if cfg.management_token and supplied == cfg.management_token:
|
||||
# Query-param access (typically the manage-menu QR code) earns a
|
||||
@@ -234,6 +253,7 @@ def api_config_save(
|
||||
quiet_hours_enabled: bool = Form(False),
|
||||
quiet_hours_start: str = Form("22:00"),
|
||||
quiet_hours_end: str = Form("07:00"),
|
||||
timezone: str = Form("UTC"),
|
||||
):
|
||||
# Immich URL/API key are env-var only (IMMICH_URL/IMMICH_API_KEY, see
|
||||
# docker-compose.yml.example) -- config.load() already applies them,
|
||||
@@ -261,6 +281,8 @@ def api_config_save(
|
||||
cfg.quiet_hours_start = quiet_hours_start
|
||||
if _valid_hhmm(quiet_hours_end):
|
||||
cfg.quiet_hours_end = quiet_hours_end
|
||||
if timezone in ALL_TIMEZONES:
|
||||
cfg.timezone = timezone
|
||||
config.save(cfg)
|
||||
return {"status": "saved"}
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}ESPresso Frame{% endblock %}</title>
|
||||
<script>
|
||||
// Applied before first paint so there's no flash of the wrong theme.
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem('theme');
|
||||
if (stored === 'light' || stored === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', stored);
|
||||
}
|
||||
} catch (e) { /* localStorage unavailable (private mode, etc.) */ }
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f6f8;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f0f1f4;
|
||||
--border: #e3e5e9;
|
||||
--text: #16181d;
|
||||
--text-muted: #666d7a;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--focus-ring: rgba(37, 99, 235, 0.35);
|
||||
--success-bg: #dcfce7;
|
||||
--success-text: #166534;
|
||||
--danger-bg: #fee2e2;
|
||||
--danger-text: #991b1b;
|
||||
--warn-bg: #fef9c3;
|
||||
--warn-text: #854d0e;
|
||||
--shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06);
|
||||
--shadow-hover: 0 8px 20px rgba(16, 24, 40, 0.10);
|
||||
--overlay: rgba(0, 0, 0, 0.55);
|
||||
--overlay-hover: rgba(153, 27, 27, 0.85);
|
||||
--color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #0d1016;
|
||||
--surface: #161a22;
|
||||
--surface-alt: #1d222b;
|
||||
--border: #2a2f3a;
|
||||
--text: #e8eaed;
|
||||
--text-muted: #9aa2b1;
|
||||
--accent: #4c8dff;
|
||||
--accent-hover: #6ea1ff;
|
||||
--focus-ring: rgba(76, 141, 255, 0.4);
|
||||
--success-bg: rgba(34, 197, 94, 0.16);
|
||||
--success-text: #4ade80;
|
||||
--danger-bg: rgba(239, 68, 68, 0.16);
|
||||
--danger-text: #f87171;
|
||||
--warn-bg: rgba(234, 179, 8, 0.16);
|
||||
--warn-text: #fbbf24;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
|
||||
--overlay: rgba(0, 0, 0, 0.55);
|
||||
--overlay-hover: rgba(248, 113, 113, 0.35);
|
||||
--color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0d1016;
|
||||
--surface: #161a22;
|
||||
--surface-alt: #1d222b;
|
||||
--border: #2a2f3a;
|
||||
--text: #e8eaed;
|
||||
--text-muted: #9aa2b1;
|
||||
--accent: #4c8dff;
|
||||
--accent-hover: #6ea1ff;
|
||||
--focus-ring: rgba(76, 141, 255, 0.4);
|
||||
--success-bg: rgba(34, 197, 94, 0.16);
|
||||
--success-text: #4ade80;
|
||||
--danger-bg: rgba(239, 68, 68, 0.16);
|
||||
--danger-text: #f87171;
|
||||
--warn-bg: rgba(234, 179, 8, 0.16);
|
||||
--warn-text: #fbbf24;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
|
||||
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
|
||||
--overlay: rgba(0, 0, 0, 0.55);
|
||||
--overlay-hover: rgba(248, 113, 113, 0.35);
|
||||
--color-scheme: dark;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
color-scheme: var(--color-scheme);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
transition: background-color .15s ease, color .15s ease;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 20px 72px;
|
||||
}
|
||||
.page.page-narrow {
|
||||
max-width: 420px;
|
||||
padding-top: 88px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.brand { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.brand-mark {
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
}
|
||||
h1 { font-size: 21px; margin: 0; letter-spacing: -0.01em; font-weight: 700; }
|
||||
p.sub { color: var(--text-muted); font-size: 13.5px; margin: 5px 0 0; line-height: 1.5; }
|
||||
|
||||
.icon-btn {
|
||||
flex: none;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
transition: background-color .15s ease, border-color .15s ease, transform .1s ease;
|
||||
}
|
||||
.icon-btn:hover { background: var(--surface-alt); }
|
||||
.icon-btn:active { transform: scale(0.94); }
|
||||
|
||||
h2.card-title {
|
||||
font-size: 14.5px;
|
||||
font-weight: 650;
|
||||
margin: 0 0 14px;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 20px 22px 22px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.card + .card { margin-top: 20px; }
|
||||
|
||||
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
label:first-child { margin-top: 0; }
|
||||
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
transition: border-color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
||||
.checkbox-row input { width: auto; margin-top: 0; }
|
||||
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
||||
|
||||
button {
|
||||
margin-top: 20px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
transition: background-color .15s ease, transform .1s ease;
|
||||
}
|
||||
button:hover { background: var(--accent-hover); }
|
||||
button:active { transform: translateY(1px); }
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
margin-right: 8px;
|
||||
}
|
||||
button.secondary:hover { background: var(--surface-alt); }
|
||||
|
||||
.status { margin-top: 16px; padding: 10px 12px; border-radius: 8px; font-size: 14px; }
|
||||
.status.ok { background: var(--success-bg); color: var(--success-text); }
|
||||
.status.err { background: var(--danger-bg); color: var(--danger-text); }
|
||||
.info-box {
|
||||
margin-bottom: 20px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.info-box.warn { background: var(--warn-bg); color: var(--warn-text); border-color: transparent; }
|
||||
|
||||
code {
|
||||
background: var(--surface-alt);
|
||||
color: var(--text);
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.info-box code, .info-box.warn code { background: rgba(127, 127, 127, 0.18); color: inherit; }
|
||||
|
||||
.thumb { width: 160px; max-width: 100%; border-radius: 8px; display: block; }
|
||||
|
||||
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; margin-top: 8px; }
|
||||
.photo-card {
|
||||
position: relative; cursor: grab; border-radius: 8px; overflow: hidden; aspect-ratio: 1;
|
||||
background: var(--surface-alt); border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
transition: box-shadow .15s ease, transform .15s ease;
|
||||
/* pan-y (not none): lets a normal touch-scroll of the page work
|
||||
when you touch a card without meaning to drag it. Dragging on
|
||||
touch instead requires a brief hold first (see the JS below),
|
||||
which switches this to "none" for the rest of that touch --
|
||||
only once we're sure it's a deliberate drag, not a scroll. */
|
||||
touch-action: pan-y;
|
||||
}
|
||||
.photo-card:hover { box-shadow: var(--shadow-hover); transform: translateY(-1px); }
|
||||
.photo-card:active { cursor: grabbing; }
|
||||
.photo-card.dragging { opacity: 0.35; }
|
||||
.photo-card.drag-armed { box-shadow: 0 0 0 3px var(--focus-ring) inset; }
|
||||
.photo-card.drag-over { outline: 3px solid var(--accent); outline-offset: -3px; }
|
||||
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
|
||||
.photo-card .badge { position: absolute; top: 6px; left: 6px; background: var(--overlay); color: white; font-size: 10px; padding: 2px 6px; border-radius: 4px; }
|
||||
.photo-card .remove-btn, .thumb-wrap .remove-btn {
|
||||
position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; margin: 0; padding: 0;
|
||||
line-height: 20px; text-align: center; font-size: 13px; border-radius: 50%;
|
||||
background: var(--overlay); color: white;
|
||||
}
|
||||
.photo-card .remove-btn:hover, .thumb-wrap .remove-btn:hover { background: var(--overlay-hover); }
|
||||
.photo-card .show-next {
|
||||
position: absolute; bottom: 6px; left: 6px; right: 6px; margin: 0; padding: 5px 0;
|
||||
font-size: 11px; text-align: center; background: rgba(37, 99, 235, 0.85); border-radius: 4px;
|
||||
}
|
||||
.thumb-wrap { position: relative; display: inline-block; }
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
.side-col { display: flex; flex-direction: column; }
|
||||
@media (max-width: 860px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="page {% block page_class %}{% endblock %}">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark" aria-hidden="true">☕</span>
|
||||
<div>
|
||||
<h1>ESPresso Frame</h1>
|
||||
{% block subtitle %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
|
||||
</header>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Shared theme toggle: explicit choice wins over the OS preference and
|
||||
// is remembered; with no explicit choice, the CSS above falls back to
|
||||
// prefers-color-scheme on its own.
|
||||
(function () {
|
||||
var btn = document.getElementById('theme-toggle');
|
||||
if (!btn) return;
|
||||
|
||||
function currentTheme() {
|
||||
var stored = null;
|
||||
try { stored = localStorage.getItem('theme'); } catch (e) { /* ignore */ }
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function apply(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
try { localStorage.setItem('theme', theme); } catch (e) { /* ignore */ }
|
||||
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: theme } }));
|
||||
}
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
apply(currentTheme() === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,66 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ESPresso Frame</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 40px auto; padding: 0 16px; color: #222; }
|
||||
.config-panel { max-width: 480px; }
|
||||
h1 { font-size: 20px; }
|
||||
p.sub { color: #666; font-size: 14px; margin-top: -8px; }
|
||||
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; }
|
||||
input, select { width: 100%; padding: 8px; box-sizing: border-box; margin-top: 4px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
||||
.checkbox-row input { width: auto; margin-top: 0; }
|
||||
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
||||
button { margin-top: 20px; padding: 10px 16px; border: none; border-radius: 4px; background: #2563eb; color: white; cursor: pointer; font-size: 14px; }
|
||||
button:hover { background: #1d4ed8; }
|
||||
button.secondary { background: #6b7280; margin-right: 8px; }
|
||||
.status { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 14px; }
|
||||
.status.ok { background: #dcfce7; color: #166534; }
|
||||
.status.err { background: #fee2e2; color: #991b1b; }
|
||||
.info-box { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 13px; background: #f3f4f6; color: #444; }
|
||||
.info-box.warn { background: #fef9c3; color: #854d0e; }
|
||||
code { background: #f3f4f6; padding: 2px 5px; border-radius: 3px; }
|
||||
h2.section { font-size: 16px; margin-top: 28px; margin-bottom: 8px; }
|
||||
.thumb { width: 160px; max-width: 100%; border-radius: 4px; display: block; }
|
||||
{% extends "base.html" %}
|
||||
|
||||
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; margin-top: 8px; }
|
||||
.photo-card {
|
||||
position: relative; cursor: grab; border-radius: 6px; overflow: hidden; aspect-ratio: 1;
|
||||
background: #f3f4f6; border: 1px solid #e5e7eb;
|
||||
/* pan-y (not none): lets a normal touch-scroll of the page work
|
||||
when you touch a card without meaning to drag it. Dragging on
|
||||
touch instead requires a brief hold first (see the JS below),
|
||||
which switches this to "none" for the rest of that touch --
|
||||
only once we're sure it's a deliberate drag, not a scroll. */
|
||||
touch-action: pan-y;
|
||||
}
|
||||
.photo-card:active { cursor: grabbing; }
|
||||
.photo-card.dragging { opacity: 0.35; }
|
||||
.photo-card.drag-armed { box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.5) inset; }
|
||||
.photo-card.drag-over { outline: 3px solid #2563eb; outline-offset: -3px; }
|
||||
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
|
||||
.photo-card .badge { position: absolute; top: 6px; left: 6px; background: rgba(0, 0, 0, 0.6); color: white; font-size: 10px; padding: 2px 6px; border-radius: 3px; }
|
||||
.photo-card .remove-btn, .thumb-wrap .remove-btn {
|
||||
position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; margin: 0; padding: 0;
|
||||
line-height: 20px; text-align: center; font-size: 13px; border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.55); color: white;
|
||||
}
|
||||
.photo-card .remove-btn:hover, .thumb-wrap .remove-btn:hover { background: rgba(153, 27, 27, 0.85); }
|
||||
.photo-card .show-next {
|
||||
position: absolute; bottom: 6px; left: 6px; right: 6px; margin: 0; padding: 5px 0;
|
||||
font-size: 11px; text-align: center; background: rgba(37, 99, 235, 0.85); border-radius: 4px;
|
||||
}
|
||||
.thumb-wrap { position: relative; display: inline-block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>ESPresso Frame</h1>
|
||||
{% block subtitle %}
|
||||
<p class="sub">Point your frame's "Tools Server" field at this server's <code>host:port</code>.</p>
|
||||
{% endblock %}
|
||||
|
||||
<div class="config-panel">
|
||||
{% block content %}
|
||||
{% if cfg.immich_url %}
|
||||
<div class="info-box">Immich: <code>{{ cfg.immich_url }}</code> (API key configured). Set via
|
||||
<code>IMMICH_URL</code>/<code>IMMICH_API_KEY</code> in <code>docker-compose.yml</code> -- see
|
||||
@@ -71,6 +15,9 @@
|
||||
<code>docker-compose.yml.example</code>) and restart the server.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="layout">
|
||||
<section class="card config-panel">
|
||||
<h2 class="card-title">Settings</h2>
|
||||
<form id="config-form">
|
||||
<label>Album
|
||||
<select id="album_id">
|
||||
@@ -109,11 +56,17 @@
|
||||
<label>Quiet hours end
|
||||
<input type="time" id="quiet_hours_end" value="{{ cfg.quiet_hours_end }}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Uses the server's local timezone
|
||||
(see <code>TZ</code> in <code>docker-compose.yml.example</code>). The
|
||||
device may still wake once right at the start of quiet hours -- it
|
||||
can't know ahead of time -- but goes right back to sleep until they
|
||||
end.</p>
|
||||
<label>Timezone
|
||||
<select id="timezone">
|
||||
{% for tz in timezones %}
|
||||
<option value="{{ tz }}" {% if tz == cfg.timezone %}selected{% endif %}>{{ tz }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Quiet hours times above are
|
||||
interpreted in this timezone. The device may still wake once right
|
||||
at the start of quiet hours -- it can't know ahead of time -- but
|
||||
goes right back to sleep until they end.</p>
|
||||
<label>Upcoming photos to show
|
||||
<select id="queue_target_len">
|
||||
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
|
||||
@@ -125,31 +78,46 @@
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
<div id="result"></div>
|
||||
</section>
|
||||
|
||||
<h2 class="section">Now displaying</h2>
|
||||
<div class="side-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Now displaying</h2>
|
||||
<div id="current-thumb"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
|
||||
<h2 class="section">Device</h2>
|
||||
<section class="card">
|
||||
<h2 class="card-title">Device</h2>
|
||||
<div id="device-status"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
|
||||
<h2 class="section">Battery history</h2>
|
||||
<section class="card">
|
||||
<h2 class="card-title">Battery history</h2>
|
||||
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
|
||||
</section>
|
||||
|
||||
<h2 class="section">Firmware update</h2>
|
||||
<section class="card">
|
||||
<h2 class="card-title">Firmware update</h2>
|
||||
<p class="sub" id="firmware-available">
|
||||
{% if cfg.firmware_available_version %}Uploaded: v{{ cfg.firmware_available_version }} -- the frame
|
||||
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
|
||||
</p>
|
||||
<input type="file" id="firmware-file" accept=".bin">
|
||||
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section">Upcoming</h2>
|
||||
<section class="card" style="margin-top: 20px;">
|
||||
<h2 class="card-title">Upcoming</h2>
|
||||
<p class="sub">Drag a photo to reorder (on touch, hold briefly first so a
|
||||
normal scroll still works), "Show next" to jump it to the front, or
|
||||
the × to remove it from rotation entirely.</p>
|
||||
<div id="upcoming-grid" class="photo-grid"></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const resultEl = document.getElementById('result');
|
||||
|
||||
@@ -169,6 +137,7 @@
|
||||
quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked),
|
||||
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
|
||||
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
|
||||
timezone: document.getElementById('timezone').value || 'UTC',
|
||||
});
|
||||
const resp = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
@@ -472,7 +441,7 @@
|
||||
const p = document.createElement('p');
|
||||
p.className = 'sub';
|
||||
if (alert) {
|
||||
p.style.color = '#991b1b';
|
||||
p.style.color = 'var(--danger-text)';
|
||||
p.style.fontWeight = '600';
|
||||
}
|
||||
p.textContent = `${label}: ${value}`;
|
||||
@@ -548,7 +517,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Reads resolved colors from CSS custom properties rather than
|
||||
// hardcoding hex values, so the chart matches the current theme
|
||||
// (light/dark) without needing its own separate palette.
|
||||
function themeColor(name) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
}
|
||||
|
||||
let lastBatteryLog = null;
|
||||
|
||||
function drawBatteryChart(log) {
|
||||
lastBatteryLog = log;
|
||||
const wrap = document.getElementById('battery-chart-wrap');
|
||||
if (!log || log.length < 2) {
|
||||
wrap.innerHTML = '<p class="sub">Not enough data yet.</p>';
|
||||
@@ -560,11 +539,16 @@
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.style.border = '1px solid #e5e7eb';
|
||||
canvas.style.borderRadius = '4px';
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.border = `1px solid ${themeColor('--border')}`;
|
||||
canvas.style.borderRadius = '8px';
|
||||
wrap.appendChild(canvas);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const gridColor = themeColor('--border');
|
||||
const mutedColor = themeColor('--text-muted');
|
||||
const accentColor = themeColor('--accent');
|
||||
|
||||
const pad = { left: 28, right: 8, top: 10, bottom: 20 };
|
||||
const plotW = width - pad.left - pad.right;
|
||||
const plotH = height - pad.top - pad.bottom;
|
||||
@@ -577,8 +561,8 @@
|
||||
const x = (t) => pad.left + ((t - minT) / spanT) * plotW;
|
||||
const y = (pct) => pad.top + (1 - pct / 100) * plotH;
|
||||
|
||||
ctx.strokeStyle = '#e5e7eb';
|
||||
ctx.fillStyle = '#999';
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.fillStyle = mutedColor;
|
||||
ctx.font = '10px system-ui, sans-serif';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.textAlign = 'left';
|
||||
@@ -591,7 +575,7 @@
|
||||
ctx.fillText(String(pct), 2, yy + 3);
|
||||
});
|
||||
|
||||
ctx.strokeStyle = '#2563eb';
|
||||
ctx.strokeStyle = accentColor;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
log.forEach((p, i) => {
|
||||
@@ -603,7 +587,7 @@
|
||||
ctx.stroke();
|
||||
|
||||
const fmt = (t) => new Date(t * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.fillStyle = mutedColor;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(fmt(minT), pad.left, height - 4);
|
||||
ctx.textAlign = 'right';
|
||||
@@ -628,6 +612,18 @@
|
||||
loadQueue();
|
||||
loadBatteryLog();
|
||||
|
||||
// Redraw the canvas chart (and the "Last seen" alert color) with the
|
||||
// new theme's colors as soon as the toggle in the header is used --
|
||||
// canvas pixels don't repaint themselves the way CSS does.
|
||||
document.addEventListener('themechange', () => {
|
||||
if (lastBatteryLog) {
|
||||
drawBatteryChart(lastBatteryLog);
|
||||
}
|
||||
if (lastDevice) {
|
||||
renderDeviceStatus(lastDevice);
|
||||
}
|
||||
});
|
||||
|
||||
// Fast tick: re-renders "Last seen"/"On battery for" etc. from the
|
||||
// already-fetched device data every second, so they count up smoothly
|
||||
// (1s ago, 5s ago, 1m ago...) without hitting the server that often.
|
||||
@@ -642,5 +638,4 @@
|
||||
// refresh. Skipped mid-drag (see loadQueue above).
|
||||
setInterval(loadQueue, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ESPresso Frame</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 360px; margin: 80px auto; padding: 0 16px; color: #222; }
|
||||
h1 { font-size: 20px; }
|
||||
p.sub { color: #666; font-size: 14px; }
|
||||
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; }
|
||||
input { width: 100%; padding: 8px; box-sizing: border-box; margin-top: 4px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
|
||||
button { margin-top: 20px; padding: 10px 16px; border: none; border-radius: 4px; background: #2563eb; color: white; cursor: pointer; font-size: 14px; width: 100%; }
|
||||
button:hover { background: #1d4ed8; }
|
||||
.status.err { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 14px; background: #fee2e2; color: #991b1b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>ESPresso Frame</h1>
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block subtitle %}
|
||||
<p class="sub">This management page needs an access token.</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
{% if wrong %}
|
||||
<div class="status err">Invalid token.</div>
|
||||
{% endif %}
|
||||
@@ -26,5 +16,5 @@
|
||||
<input type="text" id="token" name="token" autofocus autocomplete="off">
|
||||
<button type="submit">Continue</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -10,10 +10,6 @@ services:
|
||||
- CONFIG_PATH=/data/config.json
|
||||
- IMMICH_URL=http://your-immich-host:2283
|
||||
- IMMICH_API_KEY=your-immich-api-key-here
|
||||
# Set this to your local timezone (e.g. America/New_York) if you use
|
||||
# the "Quiet hours" setting -- without it, the container defaults to
|
||||
# UTC, and quiet hours would run on UTC clock time instead of yours.
|
||||
- TZ=UTC
|
||||
# Optional: gates the entire server -- the web UI (/, /api/*) AND
|
||||
# every device-facing /frame/* endpoint -- behind this shared secret.
|
||||
# Leave unset to keep it all open on a trusted LAN, same as before.
|
||||
|
||||
Reference in New Issue
Block a user