Redesign phase D: sidebar app shell, per-frame tabs, namespaced API
Build and push server image / build-and-push (push) Successful in 43s
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:
+18
-11
@@ -121,8 +121,14 @@ two-step setup screen:
|
||||
The config page asks for your home WiFi SSID/password, the "Tools
|
||||
Server" address (`host:port` of the [server](../server/) -- **not** your
|
||||
Immich server; see below for the `https://` form), and an optional
|
||||
"Access Token" (see below). Saving reboots the device, which then
|
||||
connects to your home network and starts its normal fetch/sleep cycle.
|
||||
"Access Token" (see below -- usually blank). Saving hands your browser
|
||||
off to the server's claim page (after ~7 seconds, giving your phone
|
||||
time to rejoin its normal WiFi while the device reboots) so the frame
|
||||
gets linked to your account; the device meanwhile connects to your home
|
||||
network and starts its normal fetch/sleep cycle. The frame identifies
|
||||
itself to the server by `?id=` (derived from its WiFi MAC) on every
|
||||
request, and the server issues it a private per-frame token on first
|
||||
contact -- no manual token handling involved.
|
||||
|
||||
## HTTP vs HTTPS
|
||||
|
||||
@@ -179,15 +185,16 @@ perfectly valid cert for a different name.
|
||||
|
||||
## Access token
|
||||
|
||||
If the server has `MANAGEMENT_TOKEN` set (see
|
||||
[`server/README.md`](../server/README.md)), it requires that same value
|
||||
on every request -- the web UI *and* every device-facing request the
|
||||
frame itself makes. Paste it into the captive portal's "Access Token"
|
||||
field and the device sends it (`?token=...`) on every request
|
||||
automatically, and bakes it into the manage-menu/share QR codes so
|
||||
scanning them just works too. Leave it blank if the server has no
|
||||
`MANAGEMENT_TOKEN` configured -- the default, unauthenticated-on-a-
|
||||
trusted-LAN behavior from before.
|
||||
Usually blank. Current servers issue each frame its own private token
|
||||
automatically on first contact (delivered via `GET /frame/config`,
|
||||
persisted in NVS, preferred by `build_url()` from then on -- and baked
|
||||
into the manage-menu/share QR codes so scanning them just works). The
|
||||
captive portal's "Access Token" field only matters when pointing this
|
||||
firmware at an *older* (pre-multi-frame) server whose `MANAGEMENT_TOKEN`
|
||||
is set: paste that shared value and the device sends it (`?token=...`)
|
||||
until a newer server replaces it with a per-frame one. Re-provisioning
|
||||
clears any stored per-frame token -- a fresh identity handshake with
|
||||
whatever server you point it at next.
|
||||
|
||||
## Skipping to the next photo
|
||||
|
||||
|
||||
+138
-208
@@ -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
|
||||
|
||||
@@ -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
@@ -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}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 × 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 × 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user