Redesign phase D: sidebar app shell, per-frame tabs, namespaced API
Build and push server image / build-and-push (push) Successful in 43s

The web UI grows into the multi-frame world: a left sidebar lists the
user's frames (with an online dot driven by the same overdue math as
the Device panel; collapsible off-canvas with a hamburger on mobile),
and each frame gets three tabs -- Photos (album picker, now displaying,
the drag-to-reorder upcoming grid), Configuration (name/order/
orientation/refresh/quiet hours/timezone/smart crop + the firmware
card), and Stats (device telemetry, lifetime counters, battery chart).
Settings and Admin adopt the same shell. / becomes a routing hub:
first frame, empty-state onboarding page, setup/login, or the
manage-QR redirect.

The JSON API moves to /api/frames/{id}/... behind require_frame_view /
require_frame_control: any linked user (admins see all) can view; 404
for frames outside your view so ids aren't confirmed; mutations 409
with the holder's name unless you hold the soft control lock, and
POST take-control always flips it to you. Config saves are now partial
updates -- each tab posts only its own fields (checkboxes always sent
explicitly), so the split forms can't clobber each other.

All CSS moves to static/theme.css and the old 680-line inline script
block splits into static/*.js -- the Pointer Events drag-drop state
machine and the canvas battery chart ported intact, not rewritten. The
CSRF fetch wrapper now reads a <meta> tag. No build step, still vanilla.

Verified end-to-end: page/static/API suites, control-lock handoff in
both directions, partial-save field preservation, non-admin frame
isolation, and the legacy-device curl suite (still byte-identical
responses for the deployed frame).
This commit is contained in:
2026-07-21 23:56:18 -04:00
parent 683e3881b1
commit 8ac3fc0de3
25 changed files with 2147 additions and 1612 deletions
+138 -208
View File
@@ -8,218 +8,150 @@ algorithm itself -- it just streams the response straight to the panel.
## Setup
1. **Get an Immich API key**: in Immich, go to Account Settings -> API Keys
-> New API Key. Needs read access to albums/assets/faces, plus
`sharedLink.create` (for the manage overlay's "scan to download" QR,
which creates a temporary public share link) -- a plain read-only key
will 403 on that one specific feature while everything else works.
2. **Copy the compose file and fill in your Immich details**:
1. **Copy the compose file and run the server**:
```
cp docker-compose.yml.example docker-compose.yml
```
Edit `docker-compose.yml` and set `IMMICH_URL`/`IMMICH_API_KEY` under
`environment:`. `docker-compose.yml` is gitignored (it'll hold your real
API key) -- `docker-compose.yml.example` is the one that's committed.
3. **Run the server**:
```
docker compose up -d
```
4. Open `http://<this-machine>:8420/` in a browser, click **Load Albums**,
pick one, and **Save**. (The Immich URL/API key fields will already be
populated from the environment; changing them in the UI has no effect
as long as the env vars are set -- they win on every load.)
5. On the ESP32's captive portal setup form, set the **Tools Server** field
to `<this-machine>:8420`. This server always speaks plain HTTP itself --
for HTTPS, put a TLS-terminating reverse proxy (e.g. nginx) in front of
it and enter the proxy's `https://` address instead (see
`firmware/README.md`'s HTTPS section for what the ESP32 side needs).
6. **Optional: set `MANAGEMENT_TOKEN`** in `docker-compose.yml` to gate
the *entire server* -- the web UI (`/`, `/api/*`) and every
device-facing `/frame/*` endpoint -- behind a shared secret (leave
unset to keep it all open, the previous default -- fine on a trusted
LAN). If set, paste the same value into the ESP32's captive portal
setup form's **Access Token** field: the device then sends it on
every request it makes, and the manage-menu/share QR codes embed it
automatically (`?token=...`) so scanning them just works. Visiting
the web UI without a valid token in the URL shows a plain token-entry
prompt instead of the config UI; `/health` stays open regardless
(pure liveness, nothing sensitive in it).
7. **Optional: auto-update firmware from Gitea releases.** If you're
2. **First-run setup**: open `http://<this-machine>:8420/` -- you'll be
walked through creating the admin account. Every user has their own
login; the admin can enroll more from the Admin page (family members
can also self-enroll through the frame-claim flow, below).
3. **Connect your Immich library** (per user, in Settings): your Immich
URL and an API key. The key needs read access to
albums/assets/faces, plus `sharedLink.create` (for the on-frame
"scan to download" QR, which creates a temporary public share link)
-- a plain read-only key will 403 on that one feature while
everything else works. Frames you own pull from *your* library.
(`IMMICH_URL`/`IMMICH_API_KEY` env vars in `docker-compose.yml` still
work as an operator-level fallback and seed the first admin's
settings when migrating an older deployment.)
4. **Provision a frame**: power it on, join its `ESPRESSO_XXXXXX` WiFi
(instructions show on the panel), fill in your WiFi details and this
server's address (**Tools Server**, e.g. `<this-machine>:8420`).
After saving, your browser is redirected to this server's claim page
and the frame links to your account -- creating an account on the
spot if you don't have one (a valid frame is the invitation). The
server speaks plain HTTP itself -- for HTTPS, put a TLS-terminating
reverse proxy in front and enter the proxy's `https://` address
instead (see `firmware/README.md`'s HTTPS section).
5. **Each frame gets its own device token automatically** -- the server
issues it on the frame's first check-in, so there's nothing to
configure. The captive portal's **Access Token** field only matters
when pointing new firmware at an old (pre-multi-frame) server.
`MANAGEMENT_TOKEN` in `docker-compose.yml` is likewise now only the
*migration* credential: a frame flashed with pre-multi-frame
firmware authenticates with it until it's updated and bound (the
Admin page shows the migration state per frame and a "Close legacy
window" button for when it's done).
6. **Optional: auto-update firmware from Gitea releases.** If you're
pushing this repo to a Gitea instance, `.gitea/workflows/firmware-release-build.yml`
builds both supported boards and publishes them as release assets
(`firmware-xiao.bin`/`firmware-devkit.bin`) whenever `firmware/version.txt`
changes on `main`. In the web UI's "Firmware update" card, set the
**Gitea repo URL** to that repo (e.g. `https://git.example.com/owner/repo`);
if the repo is private, also set `GITEA_FIRMWARE_TOKEN` (a read-only
PAT) in `docker-compose.yml`. Which board's build to fetch is learned
from the frame itself (its `X-Frame-Board` header, `CONFIG_FRAME_BOARD_NAME`
on the firmware side) -- nothing to pick by hand, though the frame
does need to have checked in at least once first. The server then
changes on `main`. In a frame's **Configuration** tab, set the
**Gitea repo URL**; if the repo is private, also set
`GITEA_FIRMWARE_TOKEN` (a read-only PAT) in `docker-compose.yml`.
Which board's build to fetch is learned from the frame itself (its
`X-Frame-Board` header) -- nothing to pick by hand. The server then
periodically checks for a newer release and either shows an "Update
frame" button or, with **Automatically apply updates** checked,
stages it itself -- either way the frame only actually updates on its
own next wake (see `POST /api/firmware` above).
stages it itself -- either way the frame only actually updates on
its own next wake.
## Users, frames, and control
- **Users** log in with a session cookie; passwords are scrypt-hashed;
mutating requests are CSRF-protected. Sign-up paths: first-run setup
(admin #1), admin enrollment (Admin page), or the claim flow (a valid
unclaimed frame's `device_id` gates self-service signup).
- **Frames** identify themselves by `?id=` (MAC-derived) on every
request and authenticate with a per-frame device token the server
issues at first check-in. Unknown frames self-register as unclaimed;
claiming (via `/claim?device_id=...`) sets the owner -- whose Immich
library the frame renders from -- and links the account. Admins can
link additional users to any frame; every linked user sees it in
their sidebar.
- **Control** is a soft lock per frame: everyone linked can *view*;
changing settings/queue requires holding control, and "Take control"
always succeeds (the 409 error names the current holder). The
physical buttons on the frame ignore all of this.
- **The on-frame manage QR** opens a limited no-login page (`/m/<token>`):
view current + upcoming, "show next", advance, back -- nothing else.
The share QR stays public (it creates a 30-minute Immich share link
for exactly the photo shown).
## Endpoints
- `GET /` -- config UI (album, order, refresh interval, face-aware crop
toggle, upcoming-photos count, now-displaying + drag-to-reorder
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_*/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
rotated into the panel's native 800x480 byte layout server-side --
the device never knows. Note the device-side manage-menu overlay
(QRs, text, battery indicator, face labels) still renders in native
panel orientation, so on a portrait-hung frame it appears rotated
90° to the viewer -- QR codes scan fine at any rotation, but the text
reads sideways. A known limitation, not planned to change soon.
`quiet_hours_enabled`/`quiet_hours_start`/`quiet_hours_end`
(`"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`. 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
window ends. The "overdue" indicator in `/api/queue`'s `device` object
also accounts for this -- it won't falsely flag a device that's
legitimately sleeping through a long quiet-hours window
- `GET /frame/image` -- returns the current photo pre-processed into the
panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format
Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
`/admin`, `/frames/{id}` (Photos), `/frames/{id}/config`,
`/frames/{id}/stats`, `/m/{manage_token}`.
### Device protocol (`/frame/*` -- paths frozen; auth = `?id=` + `?token=`)
- `GET /frame/image` -- the frame's current image, pre-processed into
the panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format
(`application/octet-stream`, exactly 192,000 bytes). **Side-effect-free**
by default: it only actually advances to the next photo once
`refresh_interval_s` has elapsed since the current one was set, so
calling it repeatedly (e.g. the device rebooting unexpectedly) just
redisplays the same photo instead of skipping ahead.
- `POST /frame/advance` -- forces an immediate advance to the next photo,
ignoring `refresh_interval_s`, and resets the interval clock from now.
Same response shape as `/frame/image`. Used by the device's next-photo
button (see `firmware/README.md`). Every photo actually displayed this
way (or via the normal timer-based advance) is pushed onto a bounded
history (`app/photo_queue.py`, last 20) that `/frame/back` below can
return to.
- `POST /frame/back` -- returns to the previously-current photo (the
exact mirror of `/frame/advance`), and resets the interval clock from
now. A no-op (still 200, same photo) if there's no history yet.
Pressing advance afterwards returns to where you were before going
back -- it displaces the current photo onto the front of the upcoming
queue rather than discarding it. Same response shape as
`/frame/image`. Used by the device's back-photo button.
- `GET /frame/config` -- `{"refresh_interval_s": ..., "firmware_version": "1.2.3" | null}`,
polled by the frame each wake alongside its reachability check.
`firmware_version` is whatever's currently uploaded via
`POST /api/firmware` below (`null` if nothing's been uploaded) -- the
device compares it against its own running version
(`esp_app_get_description()->version`, sent as an `X-Frame-Version`
request header, stored as `device_firmware_version`) to decide whether
to OTA. The device also sends an `X-Frame-Board` header
(`CONFIG_FRAME_BOARD_NAME`, e.g. `"xiao"`), stored as
`device_board_variant` -- how the Gitea auto-update feature below
learns which board to fetch a release for, instead of a user picking
it
- `GET /frame/photo-info` -- `{"asset_id": ..., "location_line1": ... |
null, "location_line2": ... | null, "taken_at": ... | null}` for the
current photo (same idempotent current-photo semantics as
`/frame/image`). `location_line1`/`location_line2` are `city` /
`state-or-country` if Immich reverse-geocoded the photo's GPS EXIF
(both `null` if not) -- for US/Canada, the region line is the
abbreviated state/province (`"CA"`, `"ON"`); elsewhere it's the full
country name. `taken_at` is `MM/DD/YY` from the photo's EXIF capture
date, else `null`. Used by the device's manage button to build its
overlay text
- `GET /frame/share/{asset_id}` -- creates a 30-minute public, view-only
Immich share link for `asset_id` and redirects (302) to it. Only works
for the photo currently showing or in the upcoming queue on this frame
-- not any arbitrary Immich asset. The link is created on first hit
(i.e. when someone actually scans the manage overlay's share QR), not
when the button's pressed, so the 30-minute window starts when it's
actually used
- `GET /frame/face-labels` -- `{"count": N, "name_0": ..., "x_0": ...,
"y_0": ..., ...}` (up to 4 slots) -- named people from Immich's face
recognition, positioned in final 800x480 frame pixel space. Only faces
Immich already has an identified name for are included (no face
detection/recognition happens in this project, see
`app/face_labels.py`); `count: 0` if none are named. Used by the
device manage button's escalated second menu level
- `POST /frame/battery` -- `{"percent": 0-100}`; the device's last
battery reading, stored with a timestamp plus two histories: a
per-discharge-cycle one (reset whenever a report jumps up enough to
look like a recharge) feeding the "on battery for"/estimate numbers,
and a permanent, never-reset log (capped at `BATTERY_LOG_MAX`, ~2
years at hourly reports) feeding the web UI's battery graph. Only sent
when the device is actually running on battery (see
`firmware/README.md`'s Battery section) -- a frame on mains power
never reports
- `GET /api/battery-log` -- `{"log": [[timestamp, percent], ...]}`, the
full permanent battery history above; used by the web UI's "Battery
history" chart
- `GET /api/stats` -- lifetime, never-reset counters: `first_seen`,
`device_wakes`, `photos_displayed`, `photos_removed`,
`battery_reports`, `recharge_cycles`, `ota_updates_applied`,
`config_saves` (see `FrameStats` in `app/config.py`). Purely
informational -- nothing else reads these back -- shown in a
collapsed "Stats" section in the web UI
- `POST /api/firmware` -- multipart upload (`file`) of a built
`espresso_frame.bin`. Parses the embedded `esp_app_desc_t` (rejects
anything that isn't a valid image for this project) and stores it as
the available firmware; devices pick it up via `GET /frame/config`
above on their next wake
- `GET /frame/firmware` -- streams back whatever was last uploaded via
`POST /api/firmware`, for the device's OTA fetch. 404 if nothing's
been uploaded yet
- `GET /api/firmware/check` -- throttled (`gitea_releases.UPDATE_CHECK_INTERVAL_S`,
15 min) check of the configured Gitea repo's latest release for the
frame's board variant (learned from the device, see `device_board_variant`
below -- not user-configured). `?force=true` (the "Check now" button)
bypasses the throttle. `{"enabled": false}` if no repo URL is
configured; otherwise `{"enabled": true, "board": "xiao" | null,
"latest_version": "1.2.3" | null, "staged_version": "1.2.2" | null,
"update_available": bool}`. `update_available` stays false until the
board is known, regardless of what Gitea has. If "Automatically apply
updates" is on and a newer release is found, this call also stages it
immediately (same effect as a manual upload) -- otherwise the web UI
shows an "Update frame" button
- `POST /api/firmware/apply-latest` -- the "Update frame" button: pulls
and stages the latest Gitea release right now, bypassing the check
throttle. 400 if no repo is configured or no device has checked in
yet (board unknown); 404 if the repo has no releases, or the latest
release has no asset for the frame's board
- `GET /api/queue` -- `{"current": {...} | null, "upcoming": [...],
"device": {"last_seen": ts | null, "overdue": bool,
"firmware_version": "1.2.3" | null, "firmware_available": "1.2.4" | null,
"battery": {"percent": N, "as_of": ts} | null, "on_battery_since": ts | null,
"battery_estimate_s": N | null}}`, each queue entry an asset id +
thumbnail URL; used by the config UI's "Device" panel
- `POST /api/queue/reorder` -- reorders the upcoming queue; body is
`{"queue": [asset_id, ...]}`. Tolerant of drift from the queue having
changed server-side since the client's last fetch (e.g. a top-up/trim)
-- unrecognized IDs in the body are dropped, and any currently-queued
photo missing from the body is appended rather than lost, instead of
rejecting the whole request
- `POST /api/queue/promote` -- moves one photo to the front of the queue;
body is `{"asset_id": "..."}`. Used by "Show next" in the web UI --
unlike `/reorder`, doesn't depend on the client knowing the queue's
full current order, so it can't fail from staleness
- `POST /api/queue/remove` -- permanently excludes a photo from this
frame's rotation; body is `{"asset_id": "..."}`. Doesn't touch Immich
or the album -- the photo just stops being selected by this frame
again (`app/photo_queue.py`'s `excluded_asset_ids`/`remove_from_rotation()`).
Works on the current photo too, in which case it immediately advances
to a different one (without recording the removed photo in history --
going back to a photo you just removed wouldn't make sense). Used by
the "×" button in the web UI on both the current-photo thumbnail and
each upcoming card
- `GET /api/photo-thumbnail/{asset_id}` -- proxies an Immich thumbnail so
the browser never needs the Immich API key directly
- `GET /health` -- liveness check
by default: it only actually advances once `refresh_interval_s` has
elapsed since the current photo was set, so an unexpected reboot just
redisplays the same photo. An unclaimed or not-yet-configured frame
gets a rendered instruction placeholder (with a claim QR) instead of
an error, so a fresh device never error-loops.
- `POST /frame/advance` / `POST /frame/back` -- the next/back photo
buttons: force an immediate move (mirror images of each other; back
pops a bounded 20-entry history and pushes the displaced photo onto
the front of the queue). Same response shape as `/frame/image`.
- `GET /frame/config` -- `{"refresh_interval_s": ..., "firmware_version":
... | null, "device_token": ...?}`, polled each wake. Captures the
`X-Frame-Version`/`X-Frame-Board` headers (running firmware + board
variant). `device_token` appears only during the one-time identity
handshake -- until the device authenticates with its issued token
once -- and the flat firmware parser's 512-byte buffer bounds how big
this response may grow.
- `GET /frame/photo-info` -- location/date overlay text for the manage
menu (city + abbreviated US/CAN region or country, `MM/DD/YY`).
- `GET /frame/share/{asset_id}` -- creates a 30-minute public Immich
share link and 302s to it; scoped to the photo currently showing or
queued on *this* frame only.
- `GET /frame/face-labels` -- up to 4 named faces with 800x480
positions, flattened (`name_0`/`x_0`/`y_0`, ...) for the device's
flat-scalar parser.
- `POST /frame/battery` -- `{"percent": 0-100}`; per-discharge-cycle
history (feeds the runtime estimate) plus a permanent per-frame
battery log (the Stats chart). Only sent on battery power.
- `GET /frame/firmware` -- streams the frame's staged OTA image.
### Web API (`/api/frames/{id}/...` -- session auth; *view* for reads, *control* for writes)
- `GET .../queue` -- current + upcoming (each entry id + thumbnail
URL), the control state (`{"controller": name, "you": bool}`), and
the device telemetry block (`last_seen`, `overdue` -- quiet-hours
aware -- firmware versions, battery + runtime estimate).
- `POST .../queue/reorder|promote|remove` -- reorder is drift-tolerant
(stale ids dropped, missing ids appended); promote is "Show next";
remove permanently excludes from this frame's rotation (never touches
Immich) and advances if it was current.
- `GET .../albums` -- the owner's Immich albums.
- `POST .../config` -- **partial** update: only provided fields change
(`name`, `album_id` -- resets queue/history on change --, `order`,
`refresh_interval_s`, `smart_crop_faces`, `queue_target_len`,
`orientation` (composed logically then rotated server-side; the
on-device manage overlay still renders native, a known limitation),
`quiet_hours_*` + `timezone` (a pure server-side decision shaping
what `refresh_interval_s` gets handed to the device),
`firmware_update_repo_url`, `firmware_auto_update`).
- `POST .../take-control` -- always succeeds for a linked user.
- `GET .../stats`, `GET .../battery-log`, `GET .../thumbnail/{asset_id}`.
- `POST .../firmware` (manual .bin upload, esp_app_desc_t-validated),
`GET .../firmware/check` (throttled 15 min; `?force=true` bypasses),
`POST .../firmware/apply-latest`.
### Manage-QR API (`/api/m/{manage_token}/...` -- token in path, no login)
- `GET queue`, `POST promote`, `POST advance`, `POST back`,
`GET thumbnail/{asset_id}` (scoped to this frame's current/queued
photos). Nothing else.
- `GET /health` -- liveness check, always open.
## Notes
@@ -239,15 +171,13 @@ algorithm itself -- it just streams the response straight to the panel.
in sequential or shuffle order per the Order setting. Dragging photos
in the web UI (or using "Show next") only rearranges what's already in
that lookahead; it doesn't add or remove photos from the album.
- Every endpoint except `/` and `/health` -- the web UI's `/api/*` and
every device-facing `/frame/*` -- requires `?token=` (or the
`mgmt_token` cookie the web UI sets after a valid one) once
`MANAGEMENT_TOKEN` is set (see Setup above); unset, everything stays
open like before, which is still fine on a trusted home LAN. `/frame/share`
additionally stays scoped to only ever create a link for a photo this
frame is actually showing or has queued, not any Immich asset ID
someone might guess -- a second layer a leaked token alone wouldn't
bypass.
- Auth in one breath: browsers use sessions (+CSRF), devices use
per-frame tokens (`?id=` + `?token=`), the manage QR uses its own
limited token, and `MANAGEMENT_TOKEN` survives only as the migration
credential for pre-multi-frame firmware. `/frame/share` stays scoped
to photos this frame is actually showing or has queued, not any
Immich asset ID someone might guess -- a second layer a leaked device
token alone wouldn't bypass.
- The 6-color palette RGB values in `app/image_pipeline.py` are
approximations, not measured values (Waveshare doesn't publish exact
color primaries for this panel) -- tune them once you can compare a
+57
View File
@@ -156,6 +156,63 @@ def require_admin_api(request: Request, db: Session = Depends(get_db)) -> User:
return user
def user_frames(db: Session, user: User) -> list[Frame]:
"""The frames this user sees in their sidebar: linked ones, or all of
them for an admin (admins are the household operators -- they see
unclaimed/new frames too, that's how those get adopted)."""
if user.is_admin:
return list(db.scalars(select(Frame).order_by(Frame.id)))
return list(
db.scalars(
select(Frame)
.join(UserFrame, UserFrame.frame_id == Frame.id)
.where(UserFrame.user_id == user.id)
.order_by(Frame.id)
)
)
def can_view_frame(db: Session, user: User, frame: Frame) -> bool:
return user.is_admin or db.get(UserFrame, (user.id, frame.id)) is not None
def require_frame_view(
frame_id: int, request: Request, db: Session = Depends(get_db)
) -> Frame:
"""JSON-API dependency: a logged-in user who is linked to this frame
(or an admin). 404 -- not 403 -- for frames outside the user's view,
so the API doesn't confirm which frame ids exist."""
user = require_user_api(request, db)
frame = db.get(Frame, frame_id)
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
return frame
def require_frame_control(
frame_id: int, request: Request, db: Session = Depends(get_db)
) -> Frame:
"""View access plus the soft control lock: only the user currently
holding control may mutate settings/queue. The 409 payload names the
holder so the UI can offer "take control" instead of a dead end.
Physical device buttons don't go through this -- device actions are
device actions."""
user = require_user_api(request, db)
frame = db.get(Frame, frame_id)
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
if frame.controlled_by_user_id != user.id:
holder = frame.controlled_by
raise HTTPException(
409,
{
"error": "not_controller",
"holder": (holder.display_name or holder.username) if holder else None,
},
)
return frame
def management_token() -> str:
"""The legacy shared secret. Env-only, never stored -- same as the old
server, where the env var overrode anything on disk on every load."""
+29 -38
View File
@@ -1,11 +1,14 @@
"""ESPresso Frame server: pulls photos from Immich, pre-processes them
for the panel, and serves ESP32 frames ready-to-display images.
This module is assembly only -- routes live in app/routers/ (device.py
for the firmware-facing /frame/* protocol, api.py for the web UI's
/api/*, pages.py for setup/login/settings/admin), storage in SQLite via
models.py/db.py, with migration.py importing a pre-database config.json
deployment on first boot."""
This module is assembly only -- routes live in app/routers/:
device.py the firmware-facing /frame/* protocol (paths frozen)
api_frames.py the web UI's JSON API, /api/frames/{id}/...
frame_pages.py the per-frame Photos/Configuration/Stats pages
pages.py setup/login/claim/settings/admin
manage.py the limited manage-QR surface (/m/, /api/m/)
Storage is SQLite via models.py/db.py; migration.py imports a
pre-database config.json deployment on first boot."""
from __future__ import annotations
@@ -13,23 +16,22 @@ import logging
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from . import migration
from .auth import (
browser_token_valid,
current_session,
current_user,
management_token,
user_frames,
users_exist,
)
from .db import SessionLocal
from .models import Frame
from .quiet_hours import ALL_TIMEZONES
from .routers import api, device, manage, pages
from .routers.common import default_frame, immich_creds
from .routers import api_frames, device, frame_pages, manage, pages
from .routers.common import shell_context
logger = logging.getLogger(__name__)
@@ -39,17 +41,25 @@ migration.run_migrations()
app = FastAPI(title="ESPresso Frame Server")
templates = Jinja2Templates(directory="app/templates")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(device.router)
app.include_router(api.router)
app.include_router(api_frames.router)
app.include_router(frame_pages.router)
app.include_router(pages.router)
app.include_router(manage.router)
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None:
"""The on-frame manage QR points at the server root with the device's
own credentials (new firmware: ?id=&token=; deployed firmware:
?token=<legacy shared token>). Those scans get the frame's limited
manage page -- never the full UI, which now requires a login.
manage page -- never the full UI, which requires a login.
allow_legacy is False before /setup has run: at that point a bare
?token= hit is the admin coming through the token prompt to do
first-run setup, not a QR scan."""
@@ -68,19 +78,11 @@ def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str
return None
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
"""The web UI (still the single-frame page until Phase D). Access:
a user session (the normal path once /setup has run), or -- only
while no users exist AND no MANAGEMENT_TOKEN is configured -- fully
open, the original trusted-LAN default. A hit carrying device
credentials (the on-frame manage QR) redirects to that frame's
limited manage page instead."""
"""Routing hub: manage-QR scans go to the limited manage page, users
land on their first frame (or an empty-state page), and everyone
else is walked through setup/login."""
with SessionLocal() as db:
have_users = users_exist(db)
manage_redirect = _device_credential_redirect(request, db, allow_legacy=have_users)
@@ -88,8 +90,6 @@ def index(request: Request):
return RedirectResponse(manage_redirect, status_code=303)
user = current_user(request, db)
session = current_session(request, db) if user else None
if user is None:
if not have_users:
if management_token() and not browser_token_valid(request):
@@ -101,16 +101,7 @@ def index(request: Request):
return RedirectResponse("/setup", status_code=303)
return RedirectResponse("/login", status_code=303)
frame = default_frame(db)
immich_url, _ = immich_creds(frame)
return templates.TemplateResponse(
"index.html",
{
"request": request,
"cfg": frame,
"immich_url": immich_url,
"timezones": ALL_TIMEZONES,
"user": user,
"csrf_token": session.csrf_token if session else None,
},
)
frames = user_frames(db, user)
if frames:
return RedirectResponse(f"/frames/{frames[0].id}", status_code=303)
return templates.TemplateResponse("frames_empty.html", shell_context(request, db, user))
@@ -1,7 +1,17 @@
"""Browser-facing /api/* routes -- Phase A keeps the old single-frame
paths, resolved to the default frame (frame #1), behind the legacy
shared-token gate. Phase B moves auth to sessions; Phase D moves paths
to /api/frames/{id}/... together with the new multi-frame UI."""
"""The web UI's JSON API, namespaced per frame: /api/frames/{id}/...
Auth: session-only (require_frame_view for reads, require_frame_control
for mutations -- the "take control" soft lock). The limited manage-QR
surface lives separately under /api/m/ (routers/manage.py), and device
traffic under /frame/* (routers/device.py).
Config saves are PARTIAL updates: each page's form posts only its own
fields (the old single Settings form split across the Photos and
Configuration tabs), so every field is optional and only provided ones
are touched. Checkboxes are sent explicitly as "true"/"false" strings by
the page JS -- an absent field means "not this form's field", never
"unchecked".
"""
from __future__ import annotations
@@ -9,21 +19,20 @@ import logging
import time
import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import gitea_releases, photo_queue, quiet_hours
from ..auth import require_browser
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame
from .common import (
OVERDUE_FACTOR,
battery_estimate_s,
default_frame,
immich_client_for,
immich_creds,
list_assets,
@@ -32,7 +41,7 @@ from .common import (
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_browser)])
router = APIRouter()
MIN_REFRESH_INTERVAL_S = 60
MAX_REFRESH_INTERVAL_S = 86400
@@ -42,12 +51,11 @@ MAX_QUEUE_TARGET_LEN = 5000
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
@router.get("/api/albums")
def api_albums(db: Session = Depends(get_db)):
frame = default_frame(db)
@router.get("/api/frames/{frame_id}/albums")
def api_albums(frame: Frame = Depends(require_frame_view)):
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
raise HTTPException(400, "The frame owner hasn't set up their Immich connection yet (Settings)")
try:
albums = immich_client_for(frame).list_albums()
except httpx.HTTPError as e:
@@ -55,57 +63,82 @@ def api_albums(db: Session = Depends(get_db)):
return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]
@router.post("/api/config")
@router.post("/api/frames/{frame_id}/config")
def api_config_save(
album_id: str = Form(""),
order: str = Form("sequential"),
refresh_interval_s: int = Form(3600),
smart_crop_faces: bool = Form(True),
queue_target_len: int = Form(20),
orientation: str = Form("landscape"),
quiet_hours_enabled: bool = Form(False),
quiet_hours_start: str = Form("22:00"),
quiet_hours_end: str = Form("07:00"),
timezone: str = Form("UTC"),
firmware_update_repo_url: str = Form(""),
firmware_auto_update: bool = Form(False),
name: str | None = Form(None),
album_id: str | None = Form(None),
order: str | None = Form(None),
refresh_interval_s: int | None = Form(None),
smart_crop_faces: bool | None = Form(None),
queue_target_len: int | None = Form(None),
orientation: str | None = Form(None),
quiet_hours_enabled: bool | None = Form(None),
quiet_hours_start: str | None = Form(None),
quiet_hours_end: str | None = Form(None),
timezone: str | None = Form(None),
firmware_update_repo_url: str | None = Form(None),
firmware_auto_update: bool | None = Form(None),
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
# Immich creds are per-user (Phase B) / env-fallback -- this handler
# deliberately never touches them, same as the old env-only rule.
frame = default_frame(db)
with frame_locked(db, frame.id) as cfg:
if album_id != cfg.album_id:
# A newly selected album starts clean -- the old current photo and
# queue don't mean anything in the new album's context.
if name is not None:
cfg.name = name.strip()[:64] or cfg.name
if album_id is not None and album_id != cfg.album_id:
# A newly selected album starts clean -- the old current photo
# and queue don't mean anything in the new album's context.
cfg.current_asset_id = ""
cfg.current_asset_set_at = 0.0
cfg.queue = []
cfg.queue_cursor = 0
cfg.history = []
cfg.excluded_asset_ids = []
cfg.album_id = album_id
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
cfg.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s))
cfg.smart_crop_faces = smart_crop_faces
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
cfg.quiet_hours_enabled = quiet_hours_enabled
if quiet_hours.valid_hhmm(quiet_hours_start):
cfg.album_id = album_id
if order is not None:
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
if refresh_interval_s is not None:
cfg.refresh_interval_s = max(
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
)
if smart_crop_faces is not None:
cfg.smart_crop_faces = smart_crop_faces
if queue_target_len is not None:
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
if orientation is not None:
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
if quiet_hours_enabled is not None:
cfg.quiet_hours_enabled = quiet_hours_enabled
if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start):
cfg.quiet_hours_start = quiet_hours_start
if quiet_hours.valid_hhmm(quiet_hours_end):
if quiet_hours_end is not None and quiet_hours.valid_hhmm(quiet_hours_end):
cfg.quiet_hours_end = quiet_hours_end
if timezone in quiet_hours.ALL_TIMEZONES:
if timezone is not None and timezone in quiet_hours.ALL_TIMEZONES:
cfg.timezone = timezone
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
cfg.firmware_auto_update = firmware_auto_update
if firmware_update_repo_url is not None:
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
if firmware_auto_update is not None:
cfg.firmware_auto_update = firmware_auto_update
cfg.stats_config_saves += 1
return {"status": "saved"}
@router.get("/api/stats")
def api_stats(db: Session = Depends(get_db)):
frame = default_frame(db)
@router.post("/api/frames/{frame_id}/take-control")
def api_take_control(
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
"""Always succeeds for any linked user -- the lock is deliberately
soft. The previous holder just sees who has it now."""
user = require_user_api(request, db)
previous = frame.controlled_by
frame.controlled_by_user_id = user.id
db.commit()
logger.info("User '%s' took control of frame #%d (from %s)", user.username, frame.id,
previous.username if previous else "nobody")
return {"status": "saved", "controller": user.display_name or user.username}
@router.get("/api/frames/{frame_id}/stats")
def api_stats(frame: Frame = Depends(require_frame_view)):
return {
"first_seen": frame.stats_first_seen,
"device_wakes": frame.stats_device_wakes,
@@ -118,9 +151,11 @@ def api_stats(db: Session = Depends(get_db)):
}
@router.get("/api/queue")
def api_queue(db: Session = Depends(get_db)):
frame = default_frame(db)
@router.get("/api/frames/{frame_id}/queue")
def api_queue(
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
user = require_user_api(request, db)
require_configured(frame)
client = immich_client_for(frame)
@@ -140,15 +175,25 @@ def api_queue(db: Session = Depends(get_db)):
"battery_as_of": cfg.battery_as_of,
"on_battery_since": cfg.battery_history[0][0] if cfg.battery_history else None,
"battery_estimate_s": battery_estimate_s(cfg),
"controller_id": cfg.controlled_by_user_id,
"controller": (
(cfg.controlled_by.display_name or cfg.controlled_by.username)
if cfg.controlled_by
else None
),
}
def entry(asset_id: str) -> dict:
return {"id": asset_id, "thumbnail_url": f"/api/photo-thumbnail/{asset_id}"}
return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/thumbnail/{asset_id}"}
now = time.time()
return {
"current": entry(snapshot["current_asset_id"]) if snapshot["current_asset_id"] else None,
"upcoming": [entry(asset_id) for asset_id in snapshot["queue"]],
"control": {
"controller": snapshot["controller"],
"you": snapshot["controller_id"] == user.id,
},
"device": {
"last_seen": snapshot["last_seen"] or None,
"overdue": bool(
@@ -167,9 +212,8 @@ def api_queue(db: Session = Depends(get_db)):
}
@router.get("/api/battery-log")
def api_battery_log(db: Session = Depends(get_db)):
frame = default_frame(db)
@router.get("/api/frames/{frame_id}/battery-log")
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
rows = db.execute(
select(BatteryLog.ts, BatteryLog.percent)
.where(BatteryLog.frame_id == frame.id)
@@ -182,15 +226,18 @@ class QueueReorderRequest(BaseModel):
queue: list[str]
@router.post("/api/queue/reorder")
def api_queue_reorder(body: QueueReorderRequest, db: Session = Depends(get_db)):
@router.post("/api/frames/{frame_id}/queue/reorder")
def api_queue_reorder(
body: QueueReorderRequest,
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Applies the client's requested order, tolerating drift between the
browser's last-fetched snapshot and the server's current queue (e.g.
a top-up/trim landed in between) instead of hard-rejecting: any ID
the client sent that's no longer actually queued is dropped, and any
ID the server has that the client didn't know about is appended
rather than lost."""
frame = default_frame(db)
with frame_locked(db, frame.id) as cfg:
current_set = set(cfg.queue)
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
@@ -203,14 +250,15 @@ class QueuePromoteRequest(BaseModel):
asset_id: str
@router.post("/api/queue/promote")
def api_queue_promote(body: QueuePromoteRequest, db: Session = Depends(get_db)):
"""Moves a single photo to the front of the queue -- "Show next" in
the web UI. Unlike /api/queue/reorder, this doesn't depend on the
client supplying a full, exactly-current snapshot of the queue at
all, so it can't fail due to the queue having shifted server-side
since the browser's last fetch."""
frame = default_frame(db)
@router.post("/api/frames/{frame_id}/queue/promote")
def api_queue_promote(
body: QueuePromoteRequest,
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Moves a single photo to the front of the queue -- "Show next".
Unlike reorder, doesn't depend on the client knowing the queue's
exact current order, so it can't fail from staleness."""
with frame_locked(db, frame.id) as cfg:
if body.asset_id not in cfg.queue:
raise HTTPException(400, "That photo is no longer in the upcoming queue")
@@ -222,14 +270,15 @@ class QueueRemoveRequest(BaseModel):
asset_id: str
@router.post("/api/queue/remove")
def api_queue_remove(body: QueueRemoveRequest, db: Session = Depends(get_db)):
"""Permanently removes a photo from this frame's rotation -- "Remove"
in the web UI, on either an upcoming card or the current photo. Does
NOT touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
frame = default_frame(db)
@router.post("/api/frames/{frame_id}/queue/remove")
def api_queue_remove(
body: QueueRemoveRequest,
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Permanently removes a photo from this frame's rotation. Does NOT
touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
@@ -238,9 +287,8 @@ def api_queue_remove(body: QueueRemoveRequest, db: Session = Depends(get_db)):
return {"status": "removed"}
@router.get("/api/photo-thumbnail/{asset_id}")
def api_photo_thumbnail(asset_id: str, db: Session = Depends(get_db)):
frame = default_frame(db)
@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
require_configured(frame)
client = immich_client_for(frame)
try:
@@ -250,13 +298,16 @@ def api_photo_thumbnail(asset_id: str, db: Session = Depends(get_db)):
return Response(content=content, media_type=content_type)
@router.post("/api/firmware")
def api_firmware_upload(file: UploadFile = File(...), db: Session = Depends(get_db)):
@router.post("/api/frames/{frame_id}/firmware")
def api_firmware_upload(
file: UploadFile = File(...),
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Uploads a firmware image for OTA. The version is parsed out of the
image itself (esp_app_desc_t) rather than trusted from a filename or
form field, and the project name is checked so an unrelated .bin
can't be pushed to the frame by mistake."""
frame = default_frame(db)
data = file.file.read()
version = parse_app_version(data)
path = firmware_path(frame.id)
@@ -278,11 +329,9 @@ def _fetch_latest_release(frame: Frame) -> dict | None:
def _apply_gitea_update(db: Session, frame: Frame) -> str:
"""Downloads the configured Gitea repo's latest release asset for this
frame's board variant and stages it exactly like a manual upload
would. The board comes from the device itself (device_board_variant,
learned from its X-Frame-Board header), not a user picker, so
there's nothing to fetch until the device has checked in at least
once. Network I/O happens before the lock is taken."""
frame's board variant (learned from the device's X-Frame-Board
header, never picked by hand) and stages it exactly like a manual
upload. Network I/O happens before the lock is taken."""
if not frame.device_board_variant:
raise HTTPException(400, "The frame hasn't checked in yet -- can't tell which board's build to fetch")
release = _fetch_latest_release(frame)
@@ -307,18 +356,15 @@ def _apply_gitea_update(db: Session, frame: Frame) -> str:
return version
@router.get("/api/firmware/check")
def api_firmware_check(force: bool = False, db: Session = Depends(get_db)):
@router.get("/api/frames/{frame_id}/firmware/check")
def api_firmware_check(
force: bool = False, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
"""Throttled check of the configured Gitea repo's latest release
(gitea_releases.UPDATE_CHECK_INTERVAL_S) -- cheap, since it only reads
the release's tag name, not its binaries. If firmware_auto_update is
(gitea_releases.UPDATE_CHECK_INTERVAL_S). If firmware_auto_update is
on and a newer version is found, applies it immediately; otherwise
just reports it so the web UI can offer the "Update frame" button.
force=true (the "Check now" button) bypasses the throttle and always
hits Gitea -- otherwise a genuinely new release can sit invisible in
the UI for up to the full throttle interval."""
frame = default_frame(db)
just reports it so the UI can offer the "Update frame" button.
force=true (the "Check now" button) bypasses the throttle."""
if not frame.firmware_update_repo_url:
return {"enabled": False}
@@ -351,13 +397,14 @@ def api_firmware_check(force: bool = False, db: Session = Depends(get_db)):
}
@router.post("/api/firmware/apply-latest")
def api_firmware_apply_latest(db: Session = Depends(get_db)):
@router.post("/api/frames/{frame_id}/firmware/apply-latest")
def api_firmware_apply_latest(
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
):
"""The "Update frame" button: applies the latest Gitea release right
now, bypassing the check throttle -- this is an explicit user action,
not a background poll."""
frame = default_frame(db)
now, bypassing the check throttle -- an explicit user action, not a
background poll."""
if not frame.firmware_update_repo_url:
raise HTTPException(400, "No Gitea firmware repo configured")
version = _apply_gitea_update(db, frame)
return {"status": "saved", "version": version}
return {"status": "saved", "version": version}
+26 -12
View File
@@ -106,15 +106,29 @@ def battery_estimate_s(frame: Frame) -> int | None:
return int(last_pct / rate)
def default_frame(db: Session) -> Frame:
"""Phase A only: the old single-frame /api/* routes all operate on
"the" frame -- the legacy one if flagged, else the lowest id.
Replaced by explicit /api/frames/{id}/ paths in Phase D."""
frame = db.scalars(
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
).first()
if frame is None:
frame = db.scalars(select(Frame).order_by(Frame.id).limit(1)).first()
if frame is None:
raise HTTPException(404, "No frame exists yet")
return frame
def shell_context(request, db: Session, user, active_frame: Frame | None = None,
active_nav: str | None = None) -> dict:
"""Template context every app-shell (sidebar) page needs: the user's
frame list with an online indicator, the active highlights, and the
session's CSRF token. Import here (not auth) keeps the router
modules' template plumbing in one place."""
import time as _time
from .. import quiet_hours
from ..auth import current_session, user_frames
session = current_session(request, db)
frames = user_frames(db, user)
now = _time.time()
for f in frames:
# Same "not overdue" definition the Device panel uses.
gap = quiet_hours.max_expected_gap_s(f) * OVERDUE_FACTOR
f.recently_seen = bool(f.last_seen and now - f.last_seen <= gap)
return {
"request": request,
"user": user,
"csrf_token": session.csrf_token if session else None,
"sidebar_frames": frames,
"active_frame": active_frame,
"active_nav": active_nav,
}
+49
View File
@@ -0,0 +1,49 @@
"""The per-frame HTML pages: Photos (/frames/{id}), Configuration, and
Stats tabs, all inside the sidebar app shell. Data loading happens
client-side against /api/frames/{id}/... (routers/api_frames.py); these
routes just authorize and render the scaffold."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from ..auth import can_view_frame, current_user
from ..db import get_db
from ..models import Frame
from ..quiet_hours import ALL_TIMEZONES
from .common import shell_context
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
def _frame_page(request: Request, db: Session, frame_id: int, template: str, tab: str, **extra):
user = current_user(request, db)
if user is None:
return RedirectResponse(f"/login?next=/frames/{frame_id}", status_code=303)
frame = db.get(Frame, frame_id)
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
ctx = shell_context(request, db, user, active_frame=frame)
ctx.update({"frame": frame, "active_tab": tab, **extra})
return templates.TemplateResponse(template, ctx)
@router.get("/frames/{frame_id}", response_class=HTMLResponse)
def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_photos.html", "photos")
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(
request, db, frame_id, "frame_config.html", "config", timezones=ALL_TIMEZONES
)
@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse)
def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_stats.html", "stats")
+21 -21
View File
@@ -326,15 +326,21 @@ def claim_signup(
return response
def _settings_context(request: Request, db: Session, user, saved: bool, error: str | None) -> dict:
from .common import shell_context
ctx = shell_context(request, db, user, active_nav="settings")
ctx.update({"saved": saved, "error": error})
return ctx
@router.get("/settings", response_class=HTMLResponse)
def settings_page(request: Request, db: Session = Depends(get_db)):
user = current_user(request, db)
if user is None:
return RedirectResponse("/login", status_code=303)
session = current_session(request, db)
return templates.TemplateResponse(
"settings.html",
{"request": request, "user": user, "csrf_token": session.csrf_token, "saved": False, "error": None},
"settings.html", _settings_context(request, db, user, saved=False, error=None)
)
@@ -353,7 +359,6 @@ def settings_submit(
if user is None:
return RedirectResponse("/login", status_code=303)
_check_form_csrf(request, db, csrf_token)
session = current_session(request, db)
error = None
user.display_name = display_name.strip() or user.username
@@ -374,9 +379,7 @@ def settings_submit(
db.commit()
return templates.TemplateResponse(
"settings.html",
{"request": request, "user": user, "csrf_token": session.csrf_token,
"saved": error is None, "error": error},
"settings.html", _settings_context(request, db, user, saved=error is None, error=error)
)
@@ -389,7 +392,8 @@ def _require_admin_page(request: Request, db: Session) -> User:
def _render_admin(request: Request, db: Session, admin: User, notice: str | None = None,
error: str | None = None) -> HTMLResponse:
session = current_session(request, db)
from .common import shell_context
users = list(db.scalars(select(User).order_by(User.id)))
frames = list(db.scalars(select(Frame).order_by(Frame.id)))
links = list(db.scalars(select(UserFrame)))
@@ -397,19 +401,15 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None
users_by_id = {u.id: u for u in users}
for link in links:
links_by_frame.setdefault(link.frame_id, []).append(users_by_id[link.user_id])
return templates.TemplateResponse(
"admin.html",
{
"request": request,
"user": admin,
"csrf_token": session.csrf_token,
"users": users,
"frames": frames,
"links_by_frame": links_by_frame,
"notice": notice,
"error": error,
},
)
ctx = shell_context(request, db, admin, active_nav="admin")
ctx.update({
"users": users,
"frames": frames,
"links_by_frame": links_by_frame,
"notice": notice,
"error": error,
})
return templates.TemplateResponse("admin.html", ctx)
@router.get("/admin", response_class=HTMLResponse)
+89
View File
@@ -0,0 +1,89 @@
// Hand-drawn canvas battery-history chart. Ported intact from the
// original single-page UI. Reads theme colors live so it redraws
// correctly on theme changes (see the themechange listener in
// frame_stats.js).
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>';
return;
}
wrap.innerHTML = '';
const width = wrap.clientWidth || 440;
const height = 180;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
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;
const times = log.map((p) => p[0]);
const minT = Math.min(...times);
const maxT = Math.max(...times);
const spanT = Math.max(1, maxT - minT);
const x = (t) => pad.left + ((t - minT) / spanT) * plotW;
const y = (pct) => pad.top + (1 - pct / 100) * plotH;
ctx.strokeStyle = gridColor;
ctx.fillStyle = mutedColor;
ctx.font = '10px system-ui, sans-serif';
ctx.lineWidth = 1;
ctx.textAlign = 'left';
[0, 25, 50, 75, 100].forEach((pct) => {
const yy = y(pct);
ctx.beginPath();
ctx.moveTo(pad.left, yy);
ctx.lineTo(width - pad.right, yy);
ctx.stroke();
ctx.fillText(String(pct), 2, yy + 3);
});
ctx.strokeStyle = accentColor;
ctx.lineWidth = 1.5;
ctx.beginPath();
log.forEach((p, i) => {
const px = x(p[0]);
const py = y(p[1]);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
const fmt = (t) => new Date(t * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
ctx.fillStyle = mutedColor;
ctx.textAlign = 'left';
ctx.fillText(fmt(minT), pad.left, height - 4);
ctx.textAlign = 'right';
ctx.fillText(fmt(maxT), width - pad.right, height - 4);
}
async function loadBatteryLog() {
const wrap = document.getElementById('battery-chart-wrap');
try {
const resp = await fetch(`${window.FRAME_API}/battery-log`);
if (!resp.ok) {
wrap.innerHTML = '<p class="sub">Could not load.</p>';
return;
}
const data = await resp.json();
drawBatteryChart(data.log);
} catch (e) {
wrap.innerHTML = '<p class="sub">Could not load.</p>';
}
}
+97
View File
@@ -0,0 +1,97 @@
// Shared plumbing for every page: CSRF-injecting fetch, theme toggle,
// sidebar toggle (mobile), and small formatting helpers. No framework,
// no build step -- plain scripts, load order handled by <script> tags.
// Session-cookie auth needs CSRF proof on mutating requests. Wrapping
// fetch once means no call site has to remember the header. The token
// rides a <meta> tag emitted only for session-authed pages.
(function () {
var meta = document.querySelector('meta[name="csrf-token"]');
if (!meta || !meta.content) return;
var CSRF = meta.content;
var origFetch = window.fetch;
window.fetch = function (input, init) {
init = init || {};
var method = (init.method || (input && input.method) || 'GET').toUpperCase();
var url = typeof input === 'string' ? input : (input && input.url) || '';
var sameOrigin = url.indexOf('://') === -1 || url.indexOf(location.origin) === 0;
if (sameOrigin && method !== 'GET' && method !== 'HEAD') {
init.headers = new Headers(init.headers || (input && input.headers) || {});
init.headers.set('X-CSRF-Token', CSRF);
}
return origFetch.call(this, input, init);
};
})();
// Theme toggle: explicit choice wins over the OS preference and is
// remembered; with no explicit choice, CSS falls back to
// prefers-color-scheme on its own. (The pre-paint snippet in the page
// <head> applies the stored theme before first render.)
(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';
}
btn.addEventListener('click', function () {
var theme = currentTheme() === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('theme', theme); } catch (e) { /* ignore */ }
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: theme } }));
});
})();
// Mobile sidebar: hamburger opens, backdrop or navigation closes.
(function () {
var shell = document.querySelector('.shell');
var toggle = document.getElementById('sidebar-toggle');
var backdrop = document.querySelector('.sidebar-backdrop');
if (!shell || !toggle) return;
toggle.addEventListener('click', function () { shell.classList.toggle('sidebar-open'); });
if (backdrop) {
backdrop.addEventListener('click', function () { shell.classList.remove('sidebar-open'); });
}
})();
function showStatus(ok, message) {
var el = document.getElementById('result');
if (!el) return;
el.innerHTML = '<div class="status ' + (ok ? 'ok' : 'err') + '"></div>';
el.firstChild.textContent = message;
}
// A 409 from a control-gated endpoint means someone else holds the
// frame's control lock -- surface who, plus how to take over.
async function apiError(resp) {
var text = await resp.text();
try {
var body = JSON.parse(text);
var detail = body.detail !== undefined ? body.detail : body;
if (detail && detail.error === 'not_controller') {
var holder = detail.holder || 'Someone else';
return holder + ' has control of this frame — use "Take control" to make changes.';
}
if (typeof detail === 'string') return detail;
} catch (e) { /* not JSON */ }
return text;
}
function formatDuration(seconds) {
var d = Math.floor(seconds / 86400);
var h = Math.floor((seconds % 86400) / 3600);
var m = Math.floor((seconds % 3600) / 60);
if (d > 0) return d + 'd ' + h + 'h';
if (h > 0) return h + 'h ' + m + 'm';
return m + 'm';
}
// Reads resolved colors from CSS custom properties rather than
// hardcoding hex values, so canvas drawing matches the current theme.
function themeColor(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
+201
View File
@@ -0,0 +1,201 @@
// Configuration tab: frame settings + firmware card + take control.
// window.FRAME_API is set by the template. Checkboxes are always sent
// explicitly as "true"/"false" -- the server treats absent fields as
// "leave unchanged", so a checkbox must never be simply omitted.
async function saveConfig() {
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
const body = new URLSearchParams({
name: document.getElementById('frame_name').value || '',
order: document.getElementById('order').value,
orientation: document.getElementById('orientation').value,
refresh_interval_s: String(minutes * 60),
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
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(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
}
document.getElementById('config-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await saveConfig();
showStatus(true, 'Saved.');
} catch (e) {
showStatus(false, e.message);
}
});
async function takeControl() {
try {
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'You have control now.');
loadControl();
} catch (e) {
showStatus(false, e.message);
}
}
async function loadControl() {
const banner = document.getElementById('control-banner');
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
if (!resp.ok) return; // unconfigured frame: control still works via 409s
const data = await resp.json();
if (data.control && !data.control.you) {
banner.style.display = 'flex';
document.getElementById('control-holder').textContent = data.control.controller
? `${data.control.controller} currently has control of this frame.`
: 'Nobody has control of this frame yet.';
} else {
banner.style.display = 'none';
}
} catch (e) { /* banner is best-effort */ }
}
document.getElementById('take-control').addEventListener('click', takeControl);
// ---- Firmware card ----
document.getElementById('firmware-upload').addEventListener('click', async () => {
const input = document.getElementById('firmware-file');
if (!input.files.length) {
showStatus(false, 'Pick a firmware .bin first.');
return;
}
const form = new FormData();
form.append('file', input.files[0]);
try {
const resp = await fetch(`${window.FRAME_API}/firmware`, { method: 'POST', body: form });
if (!resp.ok) {
throw new Error(await apiError(resp));
}
const result = await resp.json();
document.getElementById('firmware-available').textContent =
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
showStatus(true, `Firmware v${result.version} uploaded (${result.size} bytes).`);
} catch (e) {
showStatus(false, e.message);
}
});
function showRepoDisplayMode(url) {
document.getElementById('firmware-repo-text').textContent = url;
document.getElementById('firmware-repo-display').style.display = url ? 'block' : 'none';
document.getElementById('firmware-repo-edit').style.display = url ? 'none' : 'block';
}
document.getElementById('firmware-repo-edit-btn').addEventListener('click', () => {
document.getElementById('firmware-repo-display').style.display = 'none';
document.getElementById('firmware-repo-edit').style.display = 'block';
document.getElementById('firmware_update_repo_url').focus();
});
document.getElementById('firmware-settings-save').addEventListener('click', async () => {
try {
const body = new URLSearchParams({
firmware_update_repo_url: document.getElementById('firmware_update_repo_url').value || '',
firmware_auto_update: String(document.getElementById('firmware_auto_update').checked),
});
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim());
loadFirmwareCheck();
} catch (e) {
showStatus(false, e.message);
}
});
async function loadFirmwareCheck(force) {
const statusEl = document.getElementById('firmware-gitea-status');
const btn = document.getElementById('firmware-update-btn');
const boardEl = document.getElementById('firmware-board');
try {
const resp = await fetch(`${window.FRAME_API}/firmware/check` + (force ? '?force=true' : ''));
if (!resp.ok) {
if (force) {
showStatus(false, await apiError(resp));
}
return;
}
const data = await resp.json();
if (data.board) {
boardEl.textContent = `Detected board: ${data.board}`;
}
if (!data.enabled) {
statusEl.style.display = 'none';
btn.style.display = 'none';
if (force) {
showStatus(false, 'No Gitea repo URL configured.');
}
return;
}
statusEl.style.display = 'block';
if (!data.board) {
statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.';
btn.style.display = 'none';
} else if (data.update_available) {
statusEl.textContent = `Update available: v${data.latest_version}.`;
btn.style.display = 'inline-block';
} else if (data.latest_version) {
statusEl.textContent = `Up to date (v${data.latest_version}).`;
btn.style.display = 'none';
} else {
statusEl.textContent = 'No releases found yet.';
btn.style.display = 'none';
}
if (force) {
showStatus(true, 'Checked.');
}
} catch (e) {
// A failed passive poll is silent; an explicit "Check now" click
// still surfaces the error.
if (force) {
showStatus(false, e.message);
}
}
}
document.getElementById('firmware-check-now').addEventListener('click', () => loadFirmwareCheck(true));
document.getElementById('firmware-update-btn').addEventListener('click', async () => {
const btn = document.getElementById('firmware-update-btn');
btn.disabled = true;
try {
const resp = await fetch(`${window.FRAME_API}/firmware/apply-latest`, { method: 'POST' });
if (!resp.ok) {
throw new Error(await apiError(resp));
}
const result = await resp.json();
document.getElementById('firmware-available').textContent =
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
showStatus(true, `Firmware v${result.version} staged from Gitea.`);
loadFirmwareCheck();
} catch (e) {
showStatus(false, e.message);
} finally {
btn.disabled = false;
}
});
loadControl();
loadFirmwareCheck();
// The server throttles actual Gitea API calls itself, so this poll is
// cheap either way.
setInterval(loadFirmwareCheck, 60000);
+126
View File
@@ -0,0 +1,126 @@
// Photos tab: now-displaying, album picker, and the upcoming grid
// (rendering/drag logic in queue.js). window.FRAME_API is set by the
// template.
function renderControlBanner(control) {
const banner = document.getElementById('control-banner');
if (!banner) return;
if (!control || control.you) {
banner.style.display = 'none';
return;
}
banner.style.display = 'flex';
document.getElementById('control-holder').textContent = control.controller
? `${control.controller} currently has control of this frame.`
: 'Nobody has control of this frame yet.';
}
async function takeControl() {
try {
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'You have control now.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
}
async function loadQueue() {
if (dragState) {
return; // don't yank the grid out from under an in-progress drag
}
const currentEl = document.getElementById('current-thumb');
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
if (!resp.ok) {
currentEl.innerHTML =
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
renderUpcoming([]);
return;
}
const data = await resp.json();
currentEl.innerHTML = '';
if (data.current) {
const wrap = document.createElement('div');
wrap.className = 'thumb-wrap';
const img = document.createElement('img');
img.className = 'thumb';
img.src = data.current.thumbnail_url;
img.alt = '';
wrap.appendChild(img);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.title = 'Remove from rotation';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => removeAsset(data.current.id));
wrap.appendChild(removeBtn);
currentEl.appendChild(wrap);
} else {
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
}
renderControlBanner(data.control);
renderUpcoming(data.upcoming);
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
}
}
async function savePhotoSettings() {
const body = new URLSearchParams({
album_id: document.getElementById('album_id').value || '',
queue_target_len: document.getElementById('queue_target_len').value,
});
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
}
document.getElementById('load-albums').addEventListener('click', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/albums`);
if (!resp.ok) {
throw new Error(await apiError(resp));
}
const albums = await resp.json();
const select = document.getElementById('album_id');
select.innerHTML = '';
for (const a of albums) {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = `${a.name} (${a.count})`;
select.appendChild(opt);
}
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('photos-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await savePhotoSettings();
showStatus(true, 'Saved.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('take-control').addEventListener('click', takeControl);
loadQueue();
// Slow poll: picks up real changes (new photo displayed, queue edited
// from elsewhere) without a manual refresh. Skipped mid-drag.
setInterval(loadQueue, 10000);
+118
View File
@@ -0,0 +1,118 @@
// Stats tab: device status, lifetime counters, battery history chart
// (chart logic in battery_chart.js). window.FRAME_API set by template.
let lastDevice = null;
function renderDeviceStatus(device) {
const el = document.getElementById('device-status');
el.innerHTML = '';
if (!device || !device.last_seen) {
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
return;
}
const now = Date.now() / 1000;
const rows = [];
const ago = formatDuration(Math.max(0, now - device.last_seen));
rows.push(['Last seen', `${ago} ago`, device.overdue]);
if (device.firmware_version) {
let fw = `v${device.firmware_version}`;
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
fw += ` (v${device.firmware_available} waiting)`;
}
rows.push(['Firmware', fw, false]);
}
if (device.battery) {
rows.push(['Battery', `${device.battery.percent}%`, false]);
}
if (device.on_battery_since) {
rows.push(['On battery for', formatDuration(now - device.on_battery_since), false]);
}
if (device.battery_estimate_s !== null && device.battery_estimate_s !== undefined) {
rows.push(['Est. remaining', `~${formatDuration(device.battery_estimate_s)}`, false]);
}
for (const [label, value, alert] of rows) {
const p = document.createElement('p');
p.className = 'sub';
if (alert) {
p.style.color = 'var(--danger-text)';
p.style.fontWeight = '600';
}
p.textContent = `${label}: ${value}`;
el.appendChild(p);
}
}
async function loadDevice() {
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
if (!resp.ok) {
return;
}
const data = await resp.json();
lastDevice = data.device;
renderDeviceStatus(data.device);
} catch (e) { /* retried on the next poll */ }
}
function renderStats(stats) {
const el = document.getElementById('stats-box');
el.innerHTML = '';
const now = Date.now() / 1000;
const rows = [
['Running since', stats.first_seen ? formatDuration(Math.max(0, now - stats.first_seen)) + ' ago' : 'never checked in'],
['Wake cycles', stats.device_wakes],
['Photos displayed', stats.photos_displayed],
['Photos removed from rotation', stats.photos_removed],
['Battery reports received', stats.battery_reports],
['Battery recharge cycles', stats.recharge_cycles],
['OTA updates applied', stats.ota_updates_applied],
['Settings saved', stats.config_saves],
];
for (const [label, value] of rows) {
const p = document.createElement('p');
p.className = 'sub';
p.textContent = `${label}: ${value}`;
el.appendChild(p);
}
}
async function loadStats() {
const el = document.getElementById('stats-box');
try {
const resp = await fetch(`${window.FRAME_API}/stats`);
if (!resp.ok) {
el.innerHTML = '<p class="sub">Could not load.</p>';
return;
}
renderStats(await resp.json());
} catch (e) {
el.innerHTML = '<p class="sub">Could not load.</p>';
}
}
loadDevice();
loadStats();
loadBatteryLog();
// Redraw the canvas chart (and the "Last seen" alert color) with the
// new theme's colors as soon as the toggle 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" from already-
// fetched data every second so they count up smoothly without hitting
// the server that often.
setInterval(() => {
if (lastDevice) {
renderDeviceStatus(lastDevice);
}
}, 1000);
setInterval(loadDevice, 10000);
+240
View File
@@ -0,0 +1,240 @@
// The upcoming-photos grid: rendering plus drag-to-reorder. Ported
// intact from the original single-page UI -- the Pointer Events state
// machine below (hold-to-arm on touch so page scrolling still works) is
// battle-tested; treat changes with suspicion.
//
// Expects window.FRAME_API = '/api/frames/<id>' set by the page, and a
// loadQueue() global (frame_photos.js) to refetch authoritative state.
let upcomingItems = [];
// Pointer Events (not the native HTML5 Drag-and-Drop API) so the same
// code drives mouse, touch, and pen -- native drag-and-drop is
// mouse-only by spec and never fires at all on phones/tablets.
//
// On touch specifically, a card only "arms" for dragging after a
// brief hold (DRAG_HOLD_MS) with the finger roughly stationary --
// .photo-card's touch-action stays "pan-y" (native scroll allowed)
// the whole time up to that point, so a normal touch-and-swipe to
// scroll the page still works even though it starts on a card. Once
// armed, touch-action switches to "none" for the rest of that touch
// so drag tracking gets every pointermove reliably. Mouse skips the
// hold entirely (no scroll-vs-drag ambiguity with a mouse).
let dragState = null; // { pointerId, fromIndex, toIndex, moved, armed, holdTimer }
const DRAG_START_THRESHOLD_PX = 6; // ignore tiny jitter once armed, before committing to a drag
const DRAG_HOLD_MS = 250;
const HOLD_CANCEL_THRESHOLD_PX = 10; // movement before the hold timer fires -> treat as a scroll, not a drag
function vibrate(ms) {
// Chrome/Android only -- iOS Safari has no Vibration API. Wrapped so
// it's a harmless no-op everywhere else rather than needing a
// feature check at every call site.
try { navigator.vibrate?.(ms); } catch (e) { /* ignore */ }
}
function clearDragOverStyling() {
document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over'));
}
function endDrag(card) {
if (dragState && dragState.holdTimer) {
clearTimeout(dragState.holdTimer);
}
card.style.touchAction = '';
card.style.transform = '';
card.classList.remove('dragging', 'drag-armed');
clearDragOverStyling();
if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) {
vibrate(15);
moveItem(dragState.fromIndex, dragState.toIndex);
}
dragState = null;
}
function renderUpcoming(items) {
upcomingItems = items;
const grid = document.getElementById('upcoming-grid');
grid.innerHTML = '';
items.forEach((item, i) => {
const card = document.createElement('div');
card.className = 'photo-card';
card.dataset.index = String(i);
const img = document.createElement('img');
img.src = item.thumbnail_url;
img.alt = '';
card.appendChild(img);
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = String(i + 1);
card.appendChild(badge);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.title = 'Remove from rotation';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
removeAsset(item.id);
});
card.appendChild(removeBtn);
const nextBtn = document.createElement('button');
nextBtn.type = 'button';
nextBtn.className = 'show-next';
nextBtn.textContent = 'Show next';
nextBtn.addEventListener('click', (e) => {
e.stopPropagation();
showNext(i);
});
card.appendChild(nextBtn);
card.addEventListener('pointerdown', (e) => {
if (e.target.closest('.show-next') || e.target.closest('.remove-btn')) {
return; // let the button's own click handler run, don't start a drag
}
if (e.pointerType === 'mouse' && e.button !== 0) {
return; // left button only
}
dragState = {
pointerId: e.pointerId, fromIndex: i, toIndex: undefined, moved: false,
armed: e.pointerType !== 'touch', startX: e.clientX, startY: e.clientY, holdTimer: null,
};
if (e.pointerType === 'touch') {
dragState.holdTimer = setTimeout(() => {
if (dragState && dragState.pointerId === e.pointerId && !dragState.armed) {
dragState.armed = true;
card.classList.add('drag-armed');
card.style.touchAction = 'none';
vibrate(10);
try { card.setPointerCapture(e.pointerId); } catch (err) { /* pointer may have already left */ }
}
}, DRAG_HOLD_MS);
} else {
card.setPointerCapture(e.pointerId);
}
});
card.addEventListener('pointermove', (e) => {
if (!dragState || dragState.pointerId !== e.pointerId) {
return;
}
if (!dragState.armed) {
// Still deciding whether this is a hold-to-drag or a scroll --
// moving this much before the hold timer fires means scroll;
// bail out and let the browser's native pan-y handle it.
const dx0 = e.clientX - dragState.startX;
const dy0 = e.clientY - dragState.startY;
if (Math.hypot(dx0, dy0) > HOLD_CANCEL_THRESHOLD_PX) {
clearTimeout(dragState.holdTimer);
dragState = null;
}
return;
}
const dx = e.clientX - dragState.startX;
const dy = e.clientY - dragState.startY;
if (!dragState.moved) {
if (Math.hypot(dx, dy) < DRAG_START_THRESHOLD_PX) {
return;
}
dragState.moved = true;
card.classList.remove('drag-armed');
card.classList.add('dragging');
}
// Follows the finger 1:1 -- the actual "pick it up and carry it"
// feedback.
card.style.transform = `translate(${dx}px, ${dy}px) scale(1.06)`;
const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card');
clearDragOverStyling();
if (overCard && overCard !== card) {
overCard.classList.add('drag-over');
dragState.toIndex = Number(overCard.dataset.index);
} else {
dragState.toIndex = undefined;
}
});
card.addEventListener('pointerup', (e) => {
if (dragState && dragState.pointerId === e.pointerId) {
endDrag(card);
}
});
card.addEventListener('pointercancel', (e) => {
if (dragState && dragState.pointerId === e.pointerId) {
endDrag(card);
}
});
grid.appendChild(card);
});
}
function moveItem(fromIndex, toIndex) {
const items = upcomingItems.slice();
const [moved] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, moved);
renderUpcoming(items);
persistOrder(items);
}
async function showNext(index) {
const assetId = upcomingItems[index].id;
try {
const resp = await fetch(`${window.FRAME_API}/queue/promote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
} catch (e) {
showStatus(false, e.message);
}
loadQueue(); // always refetch the authoritative order rather than guessing locally
}
async function removeAsset(assetId) {
if (!confirm('Remove this photo from the rotation? It stays in Immich -- this frame just won\'t show it again.')) {
return;
}
try {
const resp = await fetch(`${window.FRAME_API}/queue/remove`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
} catch (e) {
showStatus(false, e.message);
}
loadQueue();
}
async function persistOrder(items) {
try {
const resp = await fetch(`${window.FRAME_API}/queue/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ queue: items.map((item) => item.id) }),
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
} catch (e) {
showStatus(false, e.message);
loadQueue();
}
}
+470
View File
@@ -0,0 +1,470 @@
: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); }
.topbar-actions { display: flex; align-items: center; gap: 14px; }
.topnav { display: flex; align-items: center; gap: 14px; font-size: 13.5px; }
.topnav a { color: var(--text-muted); text-decoration: none; }
.topnav a:hover { color: var(--text); }
.inline-form { display: inline; margin: 0; }
button.linklike {
background: none;
border: none;
padding: 0;
margin: 0;
color: var(--text-muted);
font-size: 13.5px;
font-weight: 400;
cursor: pointer;
box-shadow: none;
}
button.linklike:hover { color: var(--text); background: none; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.admin-table th { text-align: left; color: var(--text-muted); font-weight: 600; padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border); }
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
.admin-actions form { margin: 4px 0 0; }
.admin-actions details summary { cursor: pointer; color: var(--text-muted); font-size: 13px; }
.admin-actions input[type="password"] { margin-top: 6px; }
.admin-frame { border-bottom: 1px solid var(--border); padding: 10px 0; }
.admin-frame:last-child { border-bottom: none; }
.admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
h2.card-title, summary.card-title {
font-size: 14.5px;
font-weight: 650;
margin: 0 0 14px;
letter-spacing: 0.01em;
text-transform: uppercase;
color: var(--text-muted);
}
summary.card-title { cursor: pointer; margin-bottom: 0; }
details.card[open] summary.card-title { margin-bottom: 14px; }
details.card .sub { margin-top: 8px; }
.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.btn-inline {
margin-top: 0;
padding: 3px 10px;
font-size: 12px;
vertical-align: middle;
}
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;
/* Without this, a press-and-drag gesture also triggers the
browser's native text/content selection (the blue highlight) --
distracting, and on some browsers it fights the pointer-based
drag tracking below closely enough to break it outright. */
user-select: none;
-webkit-user-select: none;
}
.photo-card:hover { box-shadow: var(--shadow-hover); transform: translateY(-1px); }
.photo-card:active { cursor: grabbing; }
.photo-card.drag-armed {
box-shadow: 0 0 0 3px var(--focus-ring) inset;
transform: scale(0.97);
}
/* No transform transition here -- the JS drives transform on every
pointermove to track the finger 1:1, and the .15s base transition
would otherwise make it visibly lag behind a fast swipe. */
.photo-card.dragging {
z-index: 20;
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
transition: box-shadow .15s ease;
pointer-events: none; /* so elementFromPoint hits the card underneath, not this one */
cursor: grabbing;
}
.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;
}
.main-col, .side-col { display: flex; flex-direction: column; }
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
}
/* ------------------------------------------------------------------ */
/* App shell: left sidebar (frame list + account nav) + main content. */
/* Used by app_base.html for all logged-in pages; the narrow auth/ */
/* manage pages keep the simple centered .page layout above. */
/* ------------------------------------------------------------------ */
.shell { display: flex; min-height: 100vh; }
.sidebar {
width: 248px;
flex: none;
display: flex;
flex-direction: column;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 20px 14px 16px;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebar .brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 18px; }
.sidebar .brand h1 { font-size: 17px; }
.sidebar-section {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
padding: 14px 8px 6px;
}
.sidebar a.nav-item, .sidebar .nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border-radius: 8px;
color: var(--text);
text-decoration: none;
font-size: 14px;
transition: background-color .12s ease;
}
.sidebar a.nav-item:hover { background: var(--surface-alt); }
.sidebar a.nav-item.active { background: var(--surface-alt); font-weight: 600; }
.nav-item .frame-dot {
width: 8px; height: 8px; border-radius: 50%; flex: none;
background: var(--text-muted); opacity: 0.5;
}
.nav-item.online .frame-dot { background: #22c55e; opacity: 1; }
.nav-item .nav-sub { margin-left: auto; font-size: 11.5px; color: var(--text-muted); }
.sidebar-footer { margin-top: auto; padding-top: 14px; border-top: 1px solid var(--border); }
.sidebar-footer .nav-item { color: var(--text-muted); font-size: 13.5px; }
.sidebar-footer form { margin: 0; }
.sidebar-footer button.linklike {
display: block; width: 100%; text-align: left; padding: 9px 10px; border-radius: 8px;
}
.sidebar-footer button.linklike:hover { background: var(--surface-alt); }
.main {
flex: 1;
min-width: 0;
padding: 24px 28px 72px;
max-width: 1160px;
}
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.page-head h1 { font-size: 21px; margin: 0; letter-spacing: -0.01em; font-weight: 700; }
.page-head .head-actions { display: flex; align-items: center; gap: 10px; }
.tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.tabs a {
padding: 9px 14px;
font-size: 14px;
color: var(--text-muted);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color .12s ease;
}
.tabs a:hover { color: var(--text); }
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.control-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 18px;
padding: 10px 14px;
border-radius: 10px;
font-size: 13.5px;
background: var(--warn-bg);
color: var(--warn-text);
}
.control-banner button { margin: 0; }
/* Mobile: sidebar off-canvas, hamburger in a slim top bar. */
.mobile-bar { display: none; }
.sidebar-backdrop { display: none; }
@media (max-width: 860px) {
.mobile-bar {
display: flex;
align-items: center;
gap: 12px;
position: sticky;
top: 0;
z-index: 30;
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 10px 14px;
}
.mobile-bar .brand { display: flex; align-items: center; gap: 8px; }
.mobile-bar h1 { font-size: 16px; margin: 0; }
.mobile-bar .icon-btn { width: 34px; height: 34px; box-shadow: none; }
.mobile-bar .spacer { flex: 1; }
.shell { display: block; }
.sidebar {
position: fixed;
left: 0; top: 0; bottom: 0;
z-index: 50;
height: 100vh;
transform: translateX(-105%);
transition: transform .2s ease;
box-shadow: var(--shadow-hover);
}
.shell.sidebar-open .sidebar { transform: translateX(0); }
.sidebar-backdrop {
position: fixed; inset: 0; z-index: 40;
background: var(--overlay);
opacity: 0; pointer-events: none;
transition: opacity .2s ease;
}
.shell.sidebar-open .sidebar-backdrop { display: block; opacity: 1; pointer-events: auto; }
.sidebar-backdrop { display: block; }
.main { padding: 18px 14px 64px; }
}
+5
View File
@@ -0,0 +1,5 @@
<nav class="tabs">
<a href="/frames/{{ frame.id }}" class="{% if active_tab == 'photos' %}active{% endif %}">Photos</a>
<a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</a>
<a href="/frames/{{ frame.id }}/stats" class="{% if active_tab == 'stats' %}active{% endif %}">Stats</a>
</nav>
+3 -4
View File
@@ -1,8 +1,7 @@
{% extends "base.html" %}
{% extends "app_base.html" %}
{% block subtitle %}
<p class="sub">Administration</p>
{% endblock %}
{% block title %}Admin{% endblock %}
{% block page_title %}Administration{% endblock %}
{% block content %}
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
+80
View File
@@ -0,0 +1,80 @@
<!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>
{% if csrf_token %}<meta name="csrf-token" content="{{ csrf_token }}">{% endif %}
<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>
<link rel="stylesheet" href="/static/theme.css">
{% block extra_head %}{% endblock %}
</head>
<body>
<div class="shell">
<div class="sidebar-backdrop"></div>
<aside class="sidebar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<h1>ESPresso Frame</h1>
</div>
<div class="sidebar-section">Frames</div>
{% for f in sidebar_frames %}
<a class="nav-item {% if active_frame and active_frame.id == f.id %}active{% endif %} {% if f.recently_seen %}online{% endif %}"
href="/frames/{{ f.id }}">
<span class="frame-dot" aria-hidden="true"></span>
{{ f.name or ("Frame " ~ f.id) }}
{% if f.owner_user_id is none %}<span class="nav-sub">unclaimed</span>{% endif %}
</a>
{% endfor %}
{% if not sidebar_frames %}
<p class="sub" style="padding: 0 10px;">No frames yet.</p>
{% endif %}
<div class="sidebar-footer">
<a class="nav-item {% if active_nav == 'settings' %}active{% endif %}" href="/settings">Settings</a>
{% if user.is_admin %}
<a class="nav-item {% if active_nav == 'admin' %}active{% endif %}" href="/admin">Admin</a>
{% endif %}
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="linklike">Log out</button>
</form>
</div>
</aside>
<div class="main-wrap" style="flex: 1; min-width: 0;">
<div class="mobile-bar">
<button type="button" id="sidebar-toggle" class="icon-btn" title="Menu" aria-label="Open menu"></button>
<div class="brand"><span aria-hidden="true"></span><h1>ESPresso Frame</h1></div>
<div class="spacer"></div>
</div>
<main class="main">
<div class="page-head">
<h1>{% block page_title %}{% endblock %}</h1>
<div class="head-actions">
{% block head_actions %}{% endblock %}
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
</div>
</div>
{% block tabs %}{% endblock %}
{% block content %}{% endblock %}
</main>
</div>
</div>
<script src="/static/common.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+4 -386
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}ESPresso Frame{% endblock %}</title>
{% if csrf_token %}<meta name="csrf-token" content="{{ csrf_token }}">{% endif %}
<script>
// Applied before first paint so there's no flash of the wrong theme.
(function () {
@@ -15,327 +16,7 @@
} 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); }
.topbar-actions { display: flex; align-items: center; gap: 14px; }
.topnav { display: flex; align-items: center; gap: 14px; font-size: 13.5px; }
.topnav a { color: var(--text-muted); text-decoration: none; }
.topnav a:hover { color: var(--text); }
.inline-form { display: inline; margin: 0; }
button.linklike {
background: none;
border: none;
padding: 0;
margin: 0;
color: var(--text-muted);
font-size: 13.5px;
font-weight: 400;
cursor: pointer;
box-shadow: none;
}
button.linklike:hover { color: var(--text); background: none; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.admin-table th { text-align: left; color: var(--text-muted); font-weight: 600; padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border); }
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
.admin-actions form { margin: 4px 0 0; }
.admin-actions details summary { cursor: pointer; color: var(--text-muted); font-size: 13px; }
.admin-actions input[type="password"] { margin-top: 6px; }
.admin-frame { border-bottom: 1px solid var(--border); padding: 10px 0; }
.admin-frame:last-child { border-bottom: none; }
.admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
h2.card-title, summary.card-title {
font-size: 14.5px;
font-weight: 650;
margin: 0 0 14px;
letter-spacing: 0.01em;
text-transform: uppercase;
color: var(--text-muted);
}
summary.card-title { cursor: pointer; margin-bottom: 0; }
details.card[open] summary.card-title { margin-bottom: 14px; }
details.card .sub { margin-top: 8px; }
.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.btn-inline {
margin-top: 0;
padding: 3px 10px;
font-size: 12px;
vertical-align: middle;
}
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;
/* Without this, a press-and-drag gesture also triggers the
browser's native text/content selection (the blue highlight) --
distracting, and on some browsers it fights the pointer-based
drag tracking below closely enough to break it outright. */
user-select: none;
-webkit-user-select: none;
}
.photo-card:hover { box-shadow: var(--shadow-hover); transform: translateY(-1px); }
.photo-card:active { cursor: grabbing; }
.photo-card.drag-armed {
box-shadow: 0 0 0 3px var(--focus-ring) inset;
transform: scale(0.97);
}
/* No transform transition here -- the JS drives transform on every
pointermove to track the finger 1:1, and the .15s base transition
would otherwise make it visibly lag behind a fast swipe. */
.photo-card.dragging {
z-index: 20;
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
transition: box-shadow .15s ease;
pointer-events: none; /* so elementFromPoint hits the card underneath, not this one */
cursor: grabbing;
}
.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;
}
.main-col, .side-col { display: flex; flex-direction: column; }
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
}
</style>
<link rel="stylesheet" href="/static/theme.css">
{% block extra_head %}{% endblock %}
</head>
<body>
@@ -348,76 +29,13 @@
{% block subtitle %}{% endblock %}
</div>
</div>
<div class="topbar-actions">
{% if user %}
<nav class="topnav">
<a href="/">Home</a>
<a href="/settings">Settings</a>
{% if user.is_admin %}<a href="/admin">Admin</a>{% endif %}
<form method="post" action="/logout" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="linklike">Log out</button>
</form>
</nav>
{% endif %}
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
</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>
{% if csrf_token %}
<script>
// Session-cookie auth needs CSRF proof on mutating requests. Rather
// than touching every fetch() call site in the page scripts, wrap
// fetch once: same-origin non-GET requests automatically carry the
// per-session token. (Legacy shared-token access renders without a
// csrf_token, so this block doesn't exist there at all.)
(function () {
var CSRF = {{ csrf_token | tojson }};
var origFetch = window.fetch;
window.fetch = function (input, init) {
init = init || {};
var method = (init.method || (input && input.method) || 'GET').toUpperCase();
var url = typeof input === 'string' ? input : (input && input.url) || '';
var sameOrigin = url.indexOf('://') === -1 || url.indexOf(location.origin) === 0;
if (sameOrigin && method !== 'GET' && method !== 'HEAD') {
init.headers = new Headers(init.headers || (input && input.headers) || {});
init.headers.set('X-CSRF-Token', CSRF);
}
return origFetch.call(this, input, init);
};
})();
</script>
{% endif %}
<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>
<script src="/static/common.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+111
View File
@@ -0,0 +1,111 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %}
<div id="control-banner" class="control-banner" style="display: none;">
<span id="control-holder"></span>
<button type="button" id="take-control" class="btn-inline">Take control</button>
</div>
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Display settings</h2>
<form id="config-form">
<label>Frame name
<input type="text" id="frame_name" maxlength="64" value="{{ frame.name }}">
</label>
<label>Order
<select id="order">
<option value="sequential" {% if frame.order == "sequential" %}selected{% endif %}>Sequential</option>
<option value="shuffle" {% if frame.order == "shuffle" %}selected{% endif %}>Shuffle</option>
</select>
</label>
<label>Orientation
<select id="orientation">
<option value="landscape" {% if frame.orientation == "landscape" %}selected{% endif %}>Landscape</option>
<option value="portrait" {% if frame.orientation == "portrait" %}selected{% endif %}>Portrait</option>
<option value="landscape_flipped" {% if frame.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
<option value="portrait_flipped" {% if frame.orientation == "portrait_flipped" %}selected{% endif %}>Portrait (flipped)</option>
</select>
</label>
<label>Refresh interval (minutes)
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
value="{{ (frame.refresh_interval_s // 60) or 60 }}" required>
</label>
<div class="checkbox-row">
<input type="checkbox" id="smart_crop_faces" {% if frame.smart_crop_faces %}checked{% endif %}>
<label for="smart_crop_faces">Center faces in crop</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="quiet_hours_enabled" {% if frame.quiet_hours_enabled %}checked{% endif %}>
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
</div>
<label>Quiet hours start
<input type="time" id="quiet_hours_start" value="{{ frame.quiet_hours_start }}">
</label>
<label>Quiet hours end
<input type="time" id="quiet_hours_end" value="{{ frame.quiet_hours_end }}">
</label>
<label>Timezone
<select id="timezone">
{% for tz in timezones %}
<option value="{{ tz }}" {% if tz == frame.timezone %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
</label>
<p class="sub" style="margin-top: 8px;">Quiet hours times 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>
<button type="submit">Save</button>
</form>
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Firmware update</h2>
<p class="sub" id="firmware-board">
{% if frame.device_board_variant %}Detected board: {{ frame.device_board_variant }}
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
</p>
<p class="sub" id="firmware-available">
{% if frame.firmware_available_version %}Uploaded: v{{ frame.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>
<div id="firmware-repo-display" style="margin-top: 16px; {% if not frame.firmware_update_repo_url %}display: none;{% endif %}">
<p class="sub">Gitea repo: <code id="firmware-repo-text">{{ frame.firmware_update_repo_url }}</code>
<button type="button" class="secondary btn-inline" id="firmware-repo-edit-btn">Edit</button>
</p>
</div>
<label id="firmware-repo-edit" style="{% if frame.firmware_update_repo_url %}display: none;{% endif %}">Gitea repo URL
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
value="{{ frame.firmware_update_repo_url }}">
</label>
<div class="checkbox-row">
<input type="checkbox" id="firmware_auto_update" {% if frame.firmware_auto_update %}checked{% endif %}>
<label for="firmware_auto_update">Automatically apply updates</label>
</div>
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
</section>
</div>
</div>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/frame_config.js"></script>
{% endblock %}
+60
View File
@@ -0,0 +1,60 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %}
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %}
<div id="control-banner" class="control-banner" style="display: none;">
<span id="control-holder"></span>
<button type="button" id="take-control" class="btn-inline">Take control</button>
</div>
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Album</h2>
<form id="photos-form">
<label>Album
<select id="album_id">
{% if frame.album_id %}<option value="{{ frame.album_id }}" selected>(current selection -- Load Albums to change)</option>{% endif %}
</select>
</label>
<label>Upcoming photos to show
<select id="queue_target_len">
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
<option value="{{ n }}" {% if frame.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
<button type="button" class="secondary" id="load-albums">Load Albums</button>
<button type="submit">Save</button>
</form>
</section>
</div>
<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>
</div>
</div>
<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 &times; to remove it from rotation entirely.</p>
<div id="upcoming-grid" class="photo-grid"></div>
</section>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/queue.js"></script>
<script src="/static/frame_photos.js"></script>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %}
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %}
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Battery history</h2>
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
</section>
<section class="card">
<h2 class="card-title">Lifetime stats</h2>
<div id="stats-box"><p class="sub">Loading...</p></div>
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Device</h2>
<div id="device-status"><p class="sub">Loading...</p></div>
</section>
</div>
</div>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/battery_chart.js"></script>
<script src="/static/frame_stats.js"></script>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "app_base.html" %}
{% block title %}ESPresso Frame{% endblock %}
{% block page_title %}Welcome{% endblock %}
{% block content %}
<section class="card">
<h2 class="card-title">No frames yet</h2>
<p class="sub">Set up a frame and it'll appear in the sidebar:</p>
<p class="sub">1. Power the frame on -- it opens a WiFi network named
<code>ESPRESSO_XXXXXX</code> and shows join instructions on its panel.</p>
<p class="sub">2. Join that network and fill in your WiFi details plus this
server's address.</p>
<p class="sub">3. Your browser lands on this server's claim page and links
the frame to your account automatically.</p>
<p class="sub" style="margin-top: 12px;">Already provisioned? Ask whoever
claimed it (or an admin) to link your account, or scan the frame's
on-panel manage QR.</p>
</section>
{% endblock %}
-828
View File
@@ -1,828 +0,0 @@
{% extends "base.html" %}
{% block subtitle %}
<p class="sub">Point your frame's "Tools Server" field at this server's <code>host:port</code>.</p>
{% endblock %}
{% block content %}
{% if immich_url %}
<div class="info-box">Immich: <code>{{ 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>docker-compose.yml.example</code>.</div>
{% else %}
<div class="info-box warn">Immich isn't configured yet. Set <code>IMMICH_URL</code> and
<code>IMMICH_API_KEY</code> in <code>docker-compose.yml</code> (copy
<code>docker-compose.yml.example</code>) and restart the server.</div>
{% endif %}
<div class="layout">
<div class="main-col">
<section class="card config-panel">
<h2 class="card-title">Settings</h2>
<form id="config-form">
<label>Album
<select id="album_id">
{% if cfg.album_id %}<option value="{{ cfg.album_id }}" selected>(current selection -- reload to rename)</option>{% endif %}
</select>
</label>
<label>Order
<select id="order">
<option value="sequential" {% if cfg.order == "sequential" %}selected{% endif %}>Sequential</option>
<option value="shuffle" {% if cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
</select>
</label>
<label>Orientation
<select id="orientation">
<option value="landscape" {% if cfg.orientation == "landscape" %}selected{% endif %}>Landscape</option>
<option value="portrait" {% if cfg.orientation == "portrait" %}selected{% endif %}>Portrait</option>
<option value="landscape_flipped" {% if cfg.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
<option value="portrait_flipped" {% if cfg.orientation == "portrait_flipped" %}selected{% endif %}>Portrait (flipped)</option>
</select>
</label>
<label>Refresh interval (minutes)
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
value="{{ (cfg.refresh_interval_s // 60) or 60 }}" required>
</label>
<div class="checkbox-row">
<input type="checkbox" id="smart_crop_faces" {% if cfg.smart_crop_faces %}checked{% endif %}>
<label for="smart_crop_faces">Center faces in crop</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="quiet_hours_enabled" {% if cfg.quiet_hours_enabled %}checked{% endif %}>
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
</div>
<label>Quiet hours start
<input type="time" id="quiet_hours_start" value="{{ cfg.quiet_hours_start }}">
</label>
<label>Quiet hours end
<input type="time" id="quiet_hours_end" value="{{ cfg.quiet_hours_end }}">
</label>
<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] %}
<option value="{{ n }}" {% if cfg.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
<button type="button" class="secondary" id="load-albums">Load Albums</button>
<button type="submit">Save</button>
</form>
<div id="result"></div>
</section>
<details class="card">
<summary class="card-title">Stats</summary>
<div id="stats-box"><p class="sub">Loading...</p></div>
</details>
</div>
<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>
<section class="card">
<h2 class="card-title">Device</h2>
<div id="device-status"><p class="sub">Loading...</p></div>
</section>
<section class="card">
<h2 class="card-title">Battery history</h2>
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
</section>
<section class="card">
<h2 class="card-title">Firmware update</h2>
<p class="sub" id="firmware-board">
{% if cfg.device_board_variant %}Detected board: {{ cfg.device_board_variant }}
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
</p>
<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>
<div id="firmware-repo-display" style="margin-top: 16px; {% if not cfg.firmware_update_repo_url %}display: none;{% endif %}">
<p class="sub">Gitea repo: <code id="firmware-repo-text">{{ cfg.firmware_update_repo_url }}</code>
<button type="button" class="secondary btn-inline" id="firmware-repo-edit-btn">Edit</button>
</p>
</div>
<label id="firmware-repo-edit" style="{% if cfg.firmware_update_repo_url %}display: none;{% endif %}">Gitea repo URL
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
value="{{ cfg.firmware_update_repo_url }}">
</label>
<div class="checkbox-row">
<input type="checkbox" id="firmware_auto_update" {% if cfg.firmware_auto_update %}checked{% endif %}>
<label for="firmware_auto_update">Automatically apply updates</label>
</div>
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
</section>
</div>
</div>
<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 &times; 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');
function showStatus(ok, message) {
resultEl.innerHTML = `<div class="status ${ok ? 'ok' : 'err'}">${message}</div>`;
}
async function saveConfig() {
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
const body = new URLSearchParams({
album_id: document.getElementById('album_id').value || '',
order: document.getElementById('order').value,
orientation: document.getElementById('orientation').value,
refresh_interval_s: String(minutes * 60),
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
queue_target_len: document.getElementById('queue_target_len').value,
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',
firmware_update_repo_url: document.getElementById('firmware_update_repo_url').value || '',
firmware_auto_update: String(document.getElementById('firmware_auto_update').checked),
});
const resp = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) {
throw new Error(await resp.text());
}
}
document.getElementById('load-albums').addEventListener('click', async () => {
try {
await saveConfig();
const resp = await fetch('/api/albums');
if (!resp.ok) {
throw new Error(await resp.text());
}
const albums = await resp.json();
const select = document.getElementById('album_id');
select.innerHTML = '';
for (const a of albums) {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = `${a.name} (${a.count})`;
select.appendChild(opt);
}
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('config-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await saveConfig();
showStatus(true, 'Saved.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
});
function showRepoDisplayMode(url) {
document.getElementById('firmware-repo-text').textContent = url;
document.getElementById('firmware-repo-display').style.display = url ? 'block' : 'none';
document.getElementById('firmware-repo-edit').style.display = url ? 'none' : 'block';
}
document.getElementById('firmware-repo-edit-btn').addEventListener('click', () => {
document.getElementById('firmware-repo-display').style.display = 'none';
document.getElementById('firmware-repo-edit').style.display = 'block';
document.getElementById('firmware_update_repo_url').focus();
});
document.getElementById('firmware-settings-save').addEventListener('click', async () => {
try {
await saveConfig();
showStatus(true, 'Saved.');
showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim());
loadFirmwareCheck();
} catch (e) {
showStatus(false, e.message);
}
});
let upcomingItems = [];
// Pointer Events (not the native HTML5 Drag-and-Drop API) so the same
// code drives mouse, touch, and pen -- native drag-and-drop is
// mouse-only by spec and never fires at all on phones/tablets.
//
// On touch specifically, a card only "arms" for dragging after a
// brief hold (DRAG_HOLD_MS) with the finger roughly stationary --
// .photo-card's touch-action stays "pan-y" (native scroll allowed)
// the whole time up to that point, so a normal touch-and-swipe to
// scroll the page still works even though it starts on a card. Once
// armed, touch-action switches to "none" for the rest of that touch
// so drag tracking gets every pointermove reliably. Mouse skips the
// hold entirely (no scroll-vs-drag ambiguity with a mouse).
let dragState = null; // { pointerId, fromIndex, toIndex, moved, armed, holdTimer }
const DRAG_START_THRESHOLD_PX = 6; // ignore tiny jitter once armed, before committing to a drag
const DRAG_HOLD_MS = 250;
const HOLD_CANCEL_THRESHOLD_PX = 10; // movement before the hold timer fires -> treat as a scroll, not a drag
function vibrate(ms) {
// Chrome/Android only -- iOS Safari has no Vibration API. Wrapped so
// it's a harmless no-op everywhere else rather than needing a
// feature check at every call site.
try { navigator.vibrate?.(ms); } catch (e) { /* ignore */ }
}
function clearDragOverStyling() {
document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over'));
}
function endDrag(card) {
if (dragState && dragState.holdTimer) {
clearTimeout(dragState.holdTimer);
}
card.style.touchAction = '';
card.style.transform = '';
card.classList.remove('dragging', 'drag-armed');
clearDragOverStyling();
if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) {
vibrate(15);
moveItem(dragState.fromIndex, dragState.toIndex);
}
dragState = null;
}
function renderUpcoming(items) {
upcomingItems = items;
const grid = document.getElementById('upcoming-grid');
grid.innerHTML = '';
items.forEach((item, i) => {
const card = document.createElement('div');
card.className = 'photo-card';
card.dataset.index = String(i);
const img = document.createElement('img');
img.src = item.thumbnail_url;
img.alt = '';
card.appendChild(img);
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = String(i + 1);
card.appendChild(badge);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.title = 'Remove from rotation';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
removeAsset(item.id);
});
card.appendChild(removeBtn);
const nextBtn = document.createElement('button');
nextBtn.type = 'button';
nextBtn.className = 'show-next';
nextBtn.textContent = 'Show next';
nextBtn.addEventListener('click', (e) => {
e.stopPropagation();
showNext(i);
});
card.appendChild(nextBtn);
card.addEventListener('pointerdown', (e) => {
if (e.target.closest('.show-next') || e.target.closest('.remove-btn')) {
return; // let the button's own click handler run, don't start a drag
}
if (e.pointerType === 'mouse' && e.button !== 0) {
return; // left button only
}
dragState = {
pointerId: e.pointerId, fromIndex: i, toIndex: undefined, moved: false,
armed: e.pointerType !== 'touch', startX: e.clientX, startY: e.clientY, holdTimer: null,
};
if (e.pointerType === 'touch') {
dragState.holdTimer = setTimeout(() => {
if (dragState && dragState.pointerId === e.pointerId && !dragState.armed) {
dragState.armed = true;
card.classList.add('drag-armed');
card.style.touchAction = 'none';
vibrate(10);
try { card.setPointerCapture(e.pointerId); } catch (err) { /* pointer may have already left */ }
}
}, DRAG_HOLD_MS);
} else {
card.setPointerCapture(e.pointerId);
}
});
card.addEventListener('pointermove', (e) => {
if (!dragState || dragState.pointerId !== e.pointerId) {
return;
}
if (!dragState.armed) {
// Still deciding whether this is a hold-to-drag or a scroll --
// moving this much before the hold timer fires means scroll;
// bail out and let the browser's native pan-y handle it.
const dx0 = e.clientX - dragState.startX;
const dy0 = e.clientY - dragState.startY;
if (Math.hypot(dx0, dy0) > HOLD_CANCEL_THRESHOLD_PX) {
clearTimeout(dragState.holdTimer);
dragState = null;
}
return;
}
const dx = e.clientX - dragState.startX;
const dy = e.clientY - dragState.startY;
if (!dragState.moved) {
if (Math.hypot(dx, dy) < DRAG_START_THRESHOLD_PX) {
return;
}
dragState.moved = true;
card.classList.remove('drag-armed');
card.classList.add('dragging');
}
// Follows the finger 1:1 -- the actual "pick it up and carry it"
// feedback that was missing before (the card used to just fade
// in place while a static outline highlighted the drop target).
card.style.transform = `translate(${dx}px, ${dy}px) scale(1.06)`;
const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card');
clearDragOverStyling();
if (overCard && overCard !== card) {
overCard.classList.add('drag-over');
dragState.toIndex = Number(overCard.dataset.index);
} else {
dragState.toIndex = undefined;
}
});
card.addEventListener('pointerup', (e) => {
if (dragState && dragState.pointerId === e.pointerId) {
endDrag(card);
}
});
card.addEventListener('pointercancel', (e) => {
if (dragState && dragState.pointerId === e.pointerId) {
endDrag(card);
}
});
grid.appendChild(card);
});
}
function moveItem(fromIndex, toIndex) {
const items = upcomingItems.slice();
const [moved] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, moved);
renderUpcoming(items);
persistOrder(items);
}
async function showNext(index) {
const assetId = upcomingItems[index].id;
try {
const resp = await fetch('/api/queue/promote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await resp.text());
}
} catch (e) {
showStatus(false, e.message);
}
loadQueue(); // always refetch the authoritative order rather than guessing locally
}
async function removeAsset(assetId) {
if (!confirm('Remove this photo from the rotation? It stays in Immich -- this frame just won\'t show it again.')) {
return;
}
try {
const resp = await fetch('/api/queue/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await resp.text());
}
} catch (e) {
showStatus(false, e.message);
}
loadQueue();
}
async function persistOrder(items) {
try {
const resp = await fetch('/api/queue/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ queue: items.map((item) => item.id) }),
});
if (!resp.ok) {
throw new Error(await resp.text());
}
} catch (e) {
showStatus(false, e.message);
loadQueue();
}
}
function formatDuration(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function renderDeviceStatus(device) {
const el = document.getElementById('device-status');
el.innerHTML = '';
if (!device || !device.last_seen) {
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
return;
}
const now = Date.now() / 1000;
const rows = [];
const ago = formatDuration(Math.max(0, now - device.last_seen));
rows.push([`Last seen`, `${ago} ago`, device.overdue]);
if (device.firmware_version) {
let fw = `v${device.firmware_version}`;
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
fw += ` (v${device.firmware_available} waiting)`;
}
rows.push(['Firmware', fw, false]);
}
if (device.battery) {
rows.push(['Battery', `${device.battery.percent}%`, false]);
}
if (device.on_battery_since) {
rows.push(['On battery for', formatDuration(now - device.on_battery_since), false]);
}
if (device.battery_estimate_s !== null && device.battery_estimate_s !== undefined) {
rows.push(['Est. remaining', `~${formatDuration(device.battery_estimate_s)}`, false]);
}
for (const [label, value, alert] of rows) {
const p = document.createElement('p');
p.className = 'sub';
if (alert) {
p.style.color = 'var(--danger-text)';
p.style.fontWeight = '600';
}
p.textContent = `${label}: ${value}`;
el.appendChild(p);
}
}
document.getElementById('firmware-upload').addEventListener('click', async () => {
const input = document.getElementById('firmware-file');
if (!input.files.length) {
showStatus(false, 'Pick a firmware .bin first.');
return;
}
const form = new FormData();
form.append('file', input.files[0]);
try {
const resp = await fetch('/api/firmware', { method: 'POST', body: form });
if (!resp.ok) {
throw new Error(await resp.text());
}
const result = await resp.json();
document.getElementById('firmware-available').textContent =
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
showStatus(true, `Firmware v${result.version} uploaded (${result.size} bytes).`);
} catch (e) {
showStatus(false, e.message);
}
});
async function loadFirmwareCheck(force) {
const statusEl = document.getElementById('firmware-gitea-status');
const btn = document.getElementById('firmware-update-btn');
const boardEl = document.getElementById('firmware-board');
try {
const resp = await fetch('/api/firmware/check' + (force ? '?force=true' : ''));
if (!resp.ok) {
if (force) {
showStatus(false, await resp.text());
}
return;
}
const data = await resp.json();
if (data.board) {
boardEl.textContent = `Detected board: ${data.board}`;
}
if (!data.enabled) {
statusEl.style.display = 'none';
btn.style.display = 'none';
if (force) {
showStatus(false, 'No Gitea repo URL configured.');
}
return;
}
statusEl.style.display = 'block';
if (!data.board) {
statusEl.textContent = "Waiting for the frame to check in before it can look up the right build.";
btn.style.display = 'none';
} else if (data.update_available) {
statusEl.textContent = `Update available: v${data.latest_version}.`;
btn.style.display = 'inline-block';
} else if (data.latest_version) {
statusEl.textContent = `Up to date (v${data.latest_version}).`;
btn.style.display = 'none';
} else {
statusEl.textContent = 'No releases found yet.';
btn.style.display = 'none';
}
if (force) {
showStatus(true, 'Checked.');
}
} catch (e) {
// A failed passive poll is silent -- the manual upload path still
// works regardless, and this just retries on the next poll. An
// explicit "Check now" click still surfaces the error.
if (force) {
showStatus(false, e.message);
}
}
}
document.getElementById('firmware-check-now').addEventListener('click', () => loadFirmwareCheck(true));
document.getElementById('firmware-update-btn').addEventListener('click', async () => {
const btn = document.getElementById('firmware-update-btn');
btn.disabled = true;
try {
const resp = await fetch('/api/firmware/apply-latest', { method: 'POST' });
if (!resp.ok) {
throw new Error(await resp.text());
}
const result = await resp.json();
document.getElementById('firmware-available').textContent =
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
showStatus(true, `Firmware v${result.version} staged from Gitea.`);
loadFirmwareCheck();
} catch (e) {
showStatus(false, e.message);
} finally {
btn.disabled = false;
}
});
let lastDevice = null;
async function loadQueue() {
if (dragState) {
return; // don't yank the grid out from under an in-progress drag
}
const currentEl = document.getElementById('current-thumb');
try {
const resp = await fetch('/api/queue');
if (!resp.ok) {
currentEl.innerHTML = '<p class="sub">Not available yet -- configure Immich and an album first.</p>';
renderUpcoming([]);
return;
}
const data = await resp.json();
currentEl.innerHTML = '';
if (data.current) {
const wrap = document.createElement('div');
wrap.className = 'thumb-wrap';
const img = document.createElement('img');
img.className = 'thumb';
img.src = data.current.thumbnail_url;
img.alt = '';
wrap.appendChild(img);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.title = 'Remove from rotation';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => removeAsset(data.current.id));
wrap.appendChild(removeBtn);
currentEl.appendChild(wrap);
} else {
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
}
lastDevice = data.device;
renderDeviceStatus(data.device);
renderUpcoming(data.upcoming);
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
}
}
// 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>';
return;
}
wrap.innerHTML = '';
const width = wrap.clientWidth || 440;
const height = 180;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
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;
const times = log.map((p) => p[0]);
const minT = Math.min(...times);
const maxT = Math.max(...times);
const spanT = Math.max(1, maxT - minT);
const x = (t) => pad.left + ((t - minT) / spanT) * plotW;
const y = (pct) => pad.top + (1 - pct / 100) * plotH;
ctx.strokeStyle = gridColor;
ctx.fillStyle = mutedColor;
ctx.font = '10px system-ui, sans-serif';
ctx.lineWidth = 1;
ctx.textAlign = 'left';
[0, 25, 50, 75, 100].forEach((pct) => {
const yy = y(pct);
ctx.beginPath();
ctx.moveTo(pad.left, yy);
ctx.lineTo(width - pad.right, yy);
ctx.stroke();
ctx.fillText(String(pct), 2, yy + 3);
});
ctx.strokeStyle = accentColor;
ctx.lineWidth = 1.5;
ctx.beginPath();
log.forEach((p, i) => {
const px = x(p[0]);
const py = y(p[1]);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
const fmt = (t) => new Date(t * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
ctx.fillStyle = mutedColor;
ctx.textAlign = 'left';
ctx.fillText(fmt(minT), pad.left, height - 4);
ctx.textAlign = 'right';
ctx.fillText(fmt(maxT), width - pad.right, height - 4);
}
async function loadBatteryLog() {
const wrap = document.getElementById('battery-chart-wrap');
try {
const resp = await fetch('/api/battery-log');
if (!resp.ok) {
wrap.innerHTML = '<p class="sub">Could not load.</p>';
return;
}
const data = await resp.json();
drawBatteryChart(data.log);
} catch (e) {
wrap.innerHTML = '<p class="sub">Could not load.</p>';
}
}
function renderStats(stats) {
const el = document.getElementById('stats-box');
el.innerHTML = '';
const now = Date.now() / 1000;
const rows = [
['Running since', stats.first_seen ? formatDuration(Math.max(0, now - stats.first_seen)) + ' ago' : 'never checked in'],
['Wake cycles', stats.device_wakes],
['Photos displayed', stats.photos_displayed],
['Photos removed from rotation', stats.photos_removed],
['Battery reports received', stats.battery_reports],
['Battery recharge cycles', stats.recharge_cycles],
['OTA updates applied', stats.ota_updates_applied],
['Settings saved', stats.config_saves],
];
for (const [label, value] of rows) {
const p = document.createElement('p');
p.className = 'sub';
p.textContent = `${label}: ${value}`;
el.appendChild(p);
}
}
async function loadStats() {
const el = document.getElementById('stats-box');
try {
const resp = await fetch('/api/stats');
if (!resp.ok) {
el.innerHTML = '<p class="sub">Could not load.</p>';
return;
}
renderStats(await resp.json());
} catch (e) {
el.innerHTML = '<p class="sub">Could not load.</p>';
}
}
loadQueue();
loadBatteryLog();
loadStats();
loadFirmwareCheck();
// 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.
setInterval(() => {
if (lastDevice) {
renderDeviceStatus(lastDevice);
}
}, 1000);
// Slow poll: picks up real changes (new photo displayed, queue edited
// from elsewhere, battery report, firmware version) without a manual
// refresh. Skipped mid-drag (see loadQueue above).
setInterval(loadQueue, 10000);
// Separate, slower poll for the Gitea release check -- cheap either
// way since the server itself throttles actual Gitea API calls to
// once per gitea_releases.UPDATE_CHECK_INTERVAL_S.
setInterval(loadFirmwareCheck, 60000);
</script>
{% endblock %}
+3 -6
View File
@@ -1,10 +1,7 @@
{% extends "base.html" %}
{% extends "app_base.html" %}
{% block page_class %}page-narrow{% endblock %}
{% block subtitle %}
<p class="sub">Your account</p>
{% endblock %}
{% block title %}Settings{% endblock %}
{% block page_title %}Your account{% endblock %}
{% block content %}
{% if saved %}<div class="status ok">Saved.</div>{% endif %}