Server web UI: modern themeable layout with dark mode, timezone moved into config #1
+4
-4
@@ -2,10 +2,10 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# tzdata: python:3.12-slim doesn't include it by default, so a TZ
|
# tzdata: python:3.12-slim doesn't include it by default, so the zoneinfo
|
||||||
# environment variable (see docker-compose.yml.example -- used by the
|
# database backing the web UI's "Timezone" setting (used by "Quiet hours")
|
||||||
# "Quiet hours" setting) would silently fail to resolve and fall back to
|
# would have no named zones to resolve without this -- ZoneInfo() would
|
||||||
# UTC without this.
|
# raise for anything other than "UTC".
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
|
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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)
|
upcoming grid -- not Immich URL/API key, see Setup above)
|
||||||
- `GET /api/albums` -- lists Immich albums (used by the config UI)
|
- `GET /api/albums` -- lists Immich albums (used by the config UI)
|
||||||
- `POST /api/config` -- saves
|
- `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`,
|
`orientation` (`landscape`, `portrait`, `landscape_flipped`,
|
||||||
`portrait_flipped`) matches how the frame is physically hung: photos
|
`portrait_flipped`) matches how the frame is physically hung: photos
|
||||||
are composed/cropped for that shape (portrait crops at 480x800), then
|
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
|
(`"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
|
the device at all -- purely a server decision about what
|
||||||
`refresh_interval_s` to hand back from `GET /frame/config` below,
|
`refresh_interval_s` to hand back from `GET /frame/config` below,
|
||||||
computed in `_effective_refresh_interval_s`. Uses the server's local
|
computed in `_effective_refresh_interval_s`. Interpreted in the
|
||||||
timezone (`TZ` in `docker-compose.yml.example` -- the image needs
|
`timezone` set from the web UI's "Timezone" dropdown (an IANA zone
|
||||||
`tzdata` for a named zone to actually resolve, already installed in
|
name, e.g. `America/New_York`; defaults to `UTC`) -- no
|
||||||
the provided `Dockerfile`). The device can still land one wake right
|
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
|
at the start of the window (nothing server-side can prevent that
|
||||||
without touching the firmware, since the device doesn't know wall-clock
|
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
|
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"
|
order: str = "sequential" # or "shuffle"
|
||||||
refresh_interval_s: int = 3600
|
refresh_interval_s: int = 3600
|
||||||
# Quiet hours: no point waking the device overnight just to swap a
|
# 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
|
# photo nobody's looking at. Times are "HH:MM" interpreted in
|
||||||
# timezone (see docker-compose.yml.example's TZ note) and may wrap
|
# `timezone` below and may wrap past midnight (e.g. start=22:00,
|
||||||
# past midnight (e.g. start=22:00, end=07:00). Purely a server-side
|
# end=07:00). Purely a server-side decision -- the device is unaware,
|
||||||
# decision -- the device is unaware, it just gets told a longer
|
# it just gets told a longer refresh_interval_s by GET /frame/config
|
||||||
# refresh_interval_s by GET /frame/config while quiet hours are in
|
# while quiet hours are in effect (see main.py's
|
||||||
# effect (see main.py's _effective_refresh_interval_s).
|
# _effective_refresh_interval_s).
|
||||||
quiet_hours_enabled: bool = False
|
quiet_hours_enabled: bool = False
|
||||||
quiet_hours_start: str = "22:00"
|
quiet_hours_start: str = "22:00"
|
||||||
quiet_hours_end: str = "07: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
|
smart_crop_faces: bool = True
|
||||||
# How the physical frame is hung: landscape (native), portrait,
|
# How the physical frame is hung: landscape (native), portrait,
|
||||||
# landscape_flipped, portrait_flipped. Purely a server-side render
|
# landscape_flipped, portrait_flipped. Purely a server-side render
|
||||||
|
|||||||
+24
-2
@@ -7,6 +7,7 @@ import io
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo, available_timezones
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import Depends, FastAPI, File, HTTPException, Form, Request, UploadFile
|
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")
|
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"
|
MANAGEMENT_TOKEN_COOKIE = "mgmt_token"
|
||||||
|
|
||||||
# Battery-history / estimate tuning (see /frame/battery and _battery_estimate).
|
# Battery-history / estimate tuning (see /frame/battery and _battery_estimate).
|
||||||
@@ -54,6 +60,17 @@ def _valid_hhmm(s: str) -> bool:
|
|||||||
return False
|
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]:
|
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
|
"""Whether `now` falls inside the quiet-hours window, and the next
|
||||||
boundary: if inside, when it ends; if outside, when it next starts.
|
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."""
|
sleep exactly until the window ends."""
|
||||||
if not cfg.quiet_hours_enabled:
|
if not cfg.quiet_hours_enabled:
|
||||||
return cfg.refresh_interval_s
|
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)
|
in_quiet, boundary = _quiet_hours_state(now, cfg.quiet_hours_start, cfg.quiet_hours_end)
|
||||||
if boundary is None:
|
if boundary is None:
|
||||||
return cfg.refresh_interval_s
|
return cfg.refresh_interval_s
|
||||||
@@ -199,7 +216,9 @@ def index(request: Request):
|
|||||||
"token_prompt.html", {"request": request, "wrong": supplied is not None}
|
"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")
|
supplied = request.query_params.get("token")
|
||||||
if cfg.management_token and supplied == cfg.management_token:
|
if cfg.management_token and supplied == cfg.management_token:
|
||||||
# Query-param access (typically the manage-menu QR code) earns a
|
# 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_enabled: bool = Form(False),
|
||||||
quiet_hours_start: str = Form("22:00"),
|
quiet_hours_start: str = Form("22:00"),
|
||||||
quiet_hours_end: str = Form("07: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
|
# Immich URL/API key are env-var only (IMMICH_URL/IMMICH_API_KEY, see
|
||||||
# docker-compose.yml.example) -- config.load() already applies them,
|
# docker-compose.yml.example) -- config.load() already applies them,
|
||||||
@@ -261,6 +281,8 @@ def api_config_save(
|
|||||||
cfg.quiet_hours_start = quiet_hours_start
|
cfg.quiet_hours_start = quiet_hours_start
|
||||||
if _valid_hhmm(quiet_hours_end):
|
if _valid_hhmm(quiet_hours_end):
|
||||||
cfg.quiet_hours_end = quiet_hours_end
|
cfg.quiet_hours_end = quiet_hours_end
|
||||||
|
if timezone in ALL_TIMEZONES:
|
||||||
|
cfg.timezone = timezone
|
||||||
config.save(cfg)
|
config.save(cfg)
|
||||||
return {"status": "saved"}
|
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>
|
{% extends "base.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; }
|
|
||||||
|
|
||||||
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; margin-top: 8px; }
|
{% block subtitle %}
|
||||||
.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>
|
|
||||||
<p class="sub">Point your frame's "Tools Server" field at this server's <code>host:port</code>.</p>
|
<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 %}
|
{% if cfg.immich_url %}
|
||||||
<div class="info-box">Immich: <code>{{ cfg.immich_url }}</code> (API key configured). Set via
|
<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
|
<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>
|
<code>docker-compose.yml.example</code>) and restart the server.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<section class="card config-panel">
|
||||||
|
<h2 class="card-title">Settings</h2>
|
||||||
<form id="config-form">
|
<form id="config-form">
|
||||||
<label>Album
|
<label>Album
|
||||||
<select id="album_id">
|
<select id="album_id">
|
||||||
@@ -109,11 +56,17 @@
|
|||||||
<label>Quiet hours end
|
<label>Quiet hours end
|
||||||
<input type="time" id="quiet_hours_end" value="{{ cfg.quiet_hours_end }}">
|
<input type="time" id="quiet_hours_end" value="{{ cfg.quiet_hours_end }}">
|
||||||
</label>
|
</label>
|
||||||
<p class="sub" style="margin-top: 8px;">Uses the server's local timezone
|
<label>Timezone
|
||||||
(see <code>TZ</code> in <code>docker-compose.yml.example</code>). The
|
<select id="timezone">
|
||||||
device may still wake once right at the start of quiet hours -- it
|
{% for tz in timezones %}
|
||||||
can't know ahead of time -- but goes right back to sleep until they
|
<option value="{{ tz }}" {% if tz == cfg.timezone %}selected{% endif %}>{{ tz }}</option>
|
||||||
end.</p>
|
{% 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
|
<label>Upcoming photos to show
|
||||||
<select id="queue_target_len">
|
<select id="queue_target_len">
|
||||||
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
|
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
|
||||||
@@ -125,31 +78,46 @@
|
|||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="result"></div>
|
<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>
|
<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>
|
<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>
|
<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">
|
<p class="sub" id="firmware-available">
|
||||||
{% if cfg.firmware_available_version %}Uploaded: v{{ cfg.firmware_available_version }} -- the frame
|
{% 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 %}
|
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<input type="file" id="firmware-file" accept=".bin">
|
<input type="file" id="firmware-file" accept=".bin">
|
||||||
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
|
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</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
|
<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
|
normal scroll still works), "Show next" to jump it to the front, or
|
||||||
the × to remove it from rotation entirely.</p>
|
the × to remove it from rotation entirely.</p>
|
||||||
<div id="upcoming-grid" class="photo-grid"></div>
|
<div id="upcoming-grid" class="photo-grid"></div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
const resultEl = document.getElementById('result');
|
const resultEl = document.getElementById('result');
|
||||||
|
|
||||||
@@ -169,6 +137,7 @@
|
|||||||
quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked),
|
quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked),
|
||||||
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
|
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
|
||||||
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
|
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
|
||||||
|
timezone: document.getElementById('timezone').value || 'UTC',
|
||||||
});
|
});
|
||||||
const resp = await fetch('/api/config', {
|
const resp = await fetch('/api/config', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -472,7 +441,7 @@
|
|||||||
const p = document.createElement('p');
|
const p = document.createElement('p');
|
||||||
p.className = 'sub';
|
p.className = 'sub';
|
||||||
if (alert) {
|
if (alert) {
|
||||||
p.style.color = '#991b1b';
|
p.style.color = 'var(--danger-text)';
|
||||||
p.style.fontWeight = '600';
|
p.style.fontWeight = '600';
|
||||||
}
|
}
|
||||||
p.textContent = `${label}: ${value}`;
|
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) {
|
function drawBatteryChart(log) {
|
||||||
|
lastBatteryLog = log;
|
||||||
const wrap = document.getElementById('battery-chart-wrap');
|
const wrap = document.getElementById('battery-chart-wrap');
|
||||||
if (!log || log.length < 2) {
|
if (!log || log.length < 2) {
|
||||||
wrap.innerHTML = '<p class="sub">Not enough data yet.</p>';
|
wrap.innerHTML = '<p class="sub">Not enough data yet.</p>';
|
||||||
@@ -560,11 +539,16 @@
|
|||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
canvas.style.border = '1px solid #e5e7eb';
|
canvas.style.display = 'block';
|
||||||
canvas.style.borderRadius = '4px';
|
canvas.style.border = `1px solid ${themeColor('--border')}`;
|
||||||
|
canvas.style.borderRadius = '8px';
|
||||||
wrap.appendChild(canvas);
|
wrap.appendChild(canvas);
|
||||||
const ctx = canvas.getContext('2d');
|
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 pad = { left: 28, right: 8, top: 10, bottom: 20 };
|
||||||
const plotW = width - pad.left - pad.right;
|
const plotW = width - pad.left - pad.right;
|
||||||
const plotH = height - pad.top - pad.bottom;
|
const plotH = height - pad.top - pad.bottom;
|
||||||
@@ -577,8 +561,8 @@
|
|||||||
const x = (t) => pad.left + ((t - minT) / spanT) * plotW;
|
const x = (t) => pad.left + ((t - minT) / spanT) * plotW;
|
||||||
const y = (pct) => pad.top + (1 - pct / 100) * plotH;
|
const y = (pct) => pad.top + (1 - pct / 100) * plotH;
|
||||||
|
|
||||||
ctx.strokeStyle = '#e5e7eb';
|
ctx.strokeStyle = gridColor;
|
||||||
ctx.fillStyle = '#999';
|
ctx.fillStyle = mutedColor;
|
||||||
ctx.font = '10px system-ui, sans-serif';
|
ctx.font = '10px system-ui, sans-serif';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
@@ -591,7 +575,7 @@
|
|||||||
ctx.fillText(String(pct), 2, yy + 3);
|
ctx.fillText(String(pct), 2, yy + 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.strokeStyle = '#2563eb';
|
ctx.strokeStyle = accentColor;
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
log.forEach((p, i) => {
|
log.forEach((p, i) => {
|
||||||
@@ -603,7 +587,7 @@
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
const fmt = (t) => new Date(t * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
const fmt = (t) => new Date(t * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
ctx.fillStyle = '#666';
|
ctx.fillStyle = mutedColor;
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillText(fmt(minT), pad.left, height - 4);
|
ctx.fillText(fmt(minT), pad.left, height - 4);
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'right';
|
||||||
@@ -628,6 +612,18 @@
|
|||||||
loadQueue();
|
loadQueue();
|
||||||
loadBatteryLog();
|
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
|
// Fast tick: re-renders "Last seen"/"On battery for" etc. from the
|
||||||
// already-fetched device data every second, so they count up smoothly
|
// already-fetched device data every second, so they count up smoothly
|
||||||
// (1s ago, 5s ago, 1m ago...) without hitting the server that often.
|
// (1s ago, 5s ago, 1m ago...) without hitting the server that often.
|
||||||
@@ -642,5 +638,4 @@
|
|||||||
// refresh. Skipped mid-drag (see loadQueue above).
|
// refresh. Skipped mid-drag (see loadQueue above).
|
||||||
setInterval(loadQueue, 10000);
|
setInterval(loadQueue, 10000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
{% endblock %}
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,23 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "base.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
{% block page_class %}page-narrow{% endblock %}
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
{% block subtitle %}
|
||||||
<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>
|
|
||||||
<p class="sub">This management page needs an access token.</p>
|
<p class="sub">This management page needs an access token.</p>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="card">
|
||||||
{% if wrong %}
|
{% if wrong %}
|
||||||
<div class="status err">Invalid token.</div>
|
<div class="status err">Invalid token.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -26,5 +16,5 @@
|
|||||||
<input type="text" id="token" name="token" autofocus autocomplete="off">
|
<input type="text" id="token" name="token" autofocus autocomplete="off">
|
||||||
<button type="submit">Continue</button>
|
<button type="submit">Continue</button>
|
||||||
</form>
|
</form>
|
||||||
</body>
|
</section>
|
||||||
</html>
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ services:
|
|||||||
- CONFIG_PATH=/data/config.json
|
- CONFIG_PATH=/data/config.json
|
||||||
- IMMICH_URL=http://your-immich-host:2283
|
- IMMICH_URL=http://your-immich-host:2283
|
||||||
- IMMICH_API_KEY=your-immich-api-key-here
|
- 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
|
# Optional: gates the entire server -- the web UI (/, /api/*) AND
|
||||||
# every device-facing /frame/* endpoint -- behind this shared secret.
|
# every device-facing /frame/* endpoint -- behind this shared secret.
|
||||||
# Leave unset to keep it all open on a trusted LAN, same as before.
|
# Leave unset to keep it all open on a trusted LAN, same as before.
|
||||||
|
|||||||
Reference in New Issue
Block a user