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

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

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

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

Verified end-to-end: page/static/API suites, control-lock handoff in
both directions, partial-save field preservation, non-admin frame
isolation, and the legacy-device curl suite (still byte-identical
responses for the deployed frame).
This commit is contained in:
2026-07-21 23:56:18 -04:00
parent 683e3881b1
commit 8ac3fc0de3
25 changed files with 2147 additions and 1612 deletions
+57
View File
@@ -156,6 +156,63 @@ def require_admin_api(request: Request, db: Session = Depends(get_db)) -> User:
return user
def user_frames(db: Session, user: User) -> list[Frame]:
"""The frames this user sees in their sidebar: linked ones, or all of
them for an admin (admins are the household operators -- they see
unclaimed/new frames too, that's how those get adopted)."""
if user.is_admin:
return list(db.scalars(select(Frame).order_by(Frame.id)))
return list(
db.scalars(
select(Frame)
.join(UserFrame, UserFrame.frame_id == Frame.id)
.where(UserFrame.user_id == user.id)
.order_by(Frame.id)
)
)
def can_view_frame(db: Session, user: User, frame: Frame) -> bool:
return user.is_admin or db.get(UserFrame, (user.id, frame.id)) is not None
def require_frame_view(
frame_id: int, request: Request, db: Session = Depends(get_db)
) -> Frame:
"""JSON-API dependency: a logged-in user who is linked to this frame
(or an admin). 404 -- not 403 -- for frames outside the user's view,
so the API doesn't confirm which frame ids exist."""
user = require_user_api(request, db)
frame = db.get(Frame, frame_id)
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
return frame
def require_frame_control(
frame_id: int, request: Request, db: Session = Depends(get_db)
) -> Frame:
"""View access plus the soft control lock: only the user currently
holding control may mutate settings/queue. The 409 payload names the
holder so the UI can offer "take control" instead of a dead end.
Physical device buttons don't go through this -- device actions are
device actions."""
user = require_user_api(request, db)
frame = db.get(Frame, frame_id)
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
if frame.controlled_by_user_id != user.id:
holder = frame.controlled_by
raise HTTPException(
409,
{
"error": "not_controller",
"holder": (holder.display_name or holder.username) if holder else None,
},
)
return frame
def management_token() -> str:
"""The legacy shared secret. Env-only, never stored -- same as the old
server, where the env var overrode anything on disk on every load."""
+29 -38
View File
@@ -1,11 +1,14 @@
"""ESPresso Frame server: pulls photos from Immich, pre-processes them
for the panel, and serves ESP32 frames ready-to-display images.
This module is assembly only -- routes live in app/routers/ (device.py
for the firmware-facing /frame/* protocol, api.py for the web UI's
/api/*, pages.py for setup/login/settings/admin), storage in SQLite via
models.py/db.py, with migration.py importing a pre-database config.json
deployment on first boot."""
This module is assembly only -- routes live in app/routers/:
device.py the firmware-facing /frame/* protocol (paths frozen)
api_frames.py the web UI's JSON API, /api/frames/{id}/...
frame_pages.py the per-frame Photos/Configuration/Stats pages
pages.py setup/login/claim/settings/admin
manage.py the limited manage-QR surface (/m/, /api/m/)
Storage is SQLite via models.py/db.py; migration.py imports a
pre-database config.json deployment on first boot."""
from __future__ import annotations
@@ -13,23 +16,22 @@ import logging
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy import select
from . import migration
from .auth import (
browser_token_valid,
current_session,
current_user,
management_token,
user_frames,
users_exist,
)
from .db import SessionLocal
from .models import Frame
from .quiet_hours import ALL_TIMEZONES
from .routers import api, device, manage, pages
from .routers.common import default_frame, immich_creds
from .routers import api_frames, device, frame_pages, manage, pages
from .routers.common import shell_context
logger = logging.getLogger(__name__)
@@ -39,17 +41,25 @@ migration.run_migrations()
app = FastAPI(title="ESPresso Frame Server")
templates = Jinja2Templates(directory="app/templates")
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(device.router)
app.include_router(api.router)
app.include_router(api_frames.router)
app.include_router(frame_pages.router)
app.include_router(pages.router)
app.include_router(manage.router)
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None:
"""The on-frame manage QR points at the server root with the device's
own credentials (new firmware: ?id=&token=; deployed firmware:
?token=<legacy shared token>). Those scans get the frame's limited
manage page -- never the full UI, which now requires a login.
manage page -- never the full UI, which requires a login.
allow_legacy is False before /setup has run: at that point a bare
?token= hit is the admin coming through the token prompt to do
first-run setup, not a QR scan."""
@@ -68,19 +78,11 @@ def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str
return None
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
"""The web UI (still the single-frame page until Phase D). Access:
a user session (the normal path once /setup has run), or -- only
while no users exist AND no MANAGEMENT_TOKEN is configured -- fully
open, the original trusted-LAN default. A hit carrying device
credentials (the on-frame manage QR) redirects to that frame's
limited manage page instead."""
"""Routing hub: manage-QR scans go to the limited manage page, users
land on their first frame (or an empty-state page), and everyone
else is walked through setup/login."""
with SessionLocal() as db:
have_users = users_exist(db)
manage_redirect = _device_credential_redirect(request, db, allow_legacy=have_users)
@@ -88,8 +90,6 @@ def index(request: Request):
return RedirectResponse(manage_redirect, status_code=303)
user = current_user(request, db)
session = current_session(request, db) if user else None
if user is None:
if not have_users:
if management_token() and not browser_token_valid(request):
@@ -101,16 +101,7 @@ def index(request: Request):
return RedirectResponse("/setup", status_code=303)
return RedirectResponse("/login", status_code=303)
frame = default_frame(db)
immich_url, _ = immich_creds(frame)
return templates.TemplateResponse(
"index.html",
{
"request": request,
"cfg": frame,
"immich_url": immich_url,
"timezones": ALL_TIMEZONES,
"user": user,
"csrf_token": session.csrf_token if session else None,
},
)
frames = user_frames(db, user)
if frames:
return RedirectResponse(f"/frames/{frames[0].id}", status_code=303)
return templates.TemplateResponse("frames_empty.html", shell_context(request, db, user))
@@ -1,7 +1,17 @@
"""Browser-facing /api/* routes -- Phase A keeps the old single-frame
paths, resolved to the default frame (frame #1), behind the legacy
shared-token gate. Phase B moves auth to sessions; Phase D moves paths
to /api/frames/{id}/... together with the new multi-frame UI."""
"""The web UI's JSON API, namespaced per frame: /api/frames/{id}/...
Auth: session-only (require_frame_view for reads, require_frame_control
for mutations -- the "take control" soft lock). The limited manage-QR
surface lives separately under /api/m/ (routers/manage.py), and device
traffic under /frame/* (routers/device.py).
Config saves are PARTIAL updates: each page's form posts only its own
fields (the old single Settings form split across the Photos and
Configuration tabs), so every field is optional and only provided ones
are touched. Checkboxes are sent explicitly as "true"/"false" strings by
the page JS -- an absent field means "not this form's field", never
"unchecked".
"""
from __future__ import annotations
@@ -9,21 +19,20 @@ import logging
import time
import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import gitea_releases, photo_queue, quiet_hours
from ..auth import require_browser
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame
from .common import (
OVERDUE_FACTOR,
battery_estimate_s,
default_frame,
immich_client_for,
immich_creds,
list_assets,
@@ -32,7 +41,7 @@ from .common import (
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_browser)])
router = APIRouter()
MIN_REFRESH_INTERVAL_S = 60
MAX_REFRESH_INTERVAL_S = 86400
@@ -42,12 +51,11 @@ MAX_QUEUE_TARGET_LEN = 5000
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
@router.get("/api/albums")
def api_albums(db: Session = Depends(get_db)):
frame = default_frame(db)
@router.get("/api/frames/{frame_id}/albums")
def api_albums(frame: Frame = Depends(require_frame_view)):
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
raise HTTPException(400, "The frame owner hasn't set up their Immich connection yet (Settings)")
try:
albums = immich_client_for(frame).list_albums()
except httpx.HTTPError as e:
@@ -55,57 +63,82 @@ def api_albums(db: Session = Depends(get_db)):
return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]
@router.post("/api/config")
@router.post("/api/frames/{frame_id}/config")
def api_config_save(
album_id: str = Form(""),
order: str = Form("sequential"),
refresh_interval_s: int = Form(3600),
smart_crop_faces: bool = Form(True),
queue_target_len: int = Form(20),
orientation: str = Form("landscape"),
quiet_hours_enabled: bool = Form(False),
quiet_hours_start: str = Form("22:00"),
quiet_hours_end: str = Form("07:00"),
timezone: str = Form("UTC"),
firmware_update_repo_url: str = Form(""),
firmware_auto_update: bool = Form(False),
name: str | None = Form(None),
album_id: str | None = Form(None),
order: str | None = Form(None),
refresh_interval_s: int | None = Form(None),
smart_crop_faces: bool | None = Form(None),
queue_target_len: int | None = Form(None),
orientation: str | None = Form(None),
quiet_hours_enabled: bool | None = Form(None),
quiet_hours_start: str | None = Form(None),
quiet_hours_end: str | None = Form(None),
timezone: str | None = Form(None),
firmware_update_repo_url: str | None = Form(None),
firmware_auto_update: bool | None = Form(None),
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
# Immich creds are per-user (Phase B) / env-fallback -- this handler
# deliberately never touches them, same as the old env-only rule.
frame = default_frame(db)
with frame_locked(db, frame.id) as cfg:
if album_id != cfg.album_id:
# A newly selected album starts clean -- the old current photo and
# queue don't mean anything in the new album's context.
if name is not None:
cfg.name = name.strip()[:64] or cfg.name
if album_id is not None and album_id != cfg.album_id:
# A newly selected album starts clean -- the old current photo
# and queue don't mean anything in the new album's context.
cfg.current_asset_id = ""
cfg.current_asset_set_at = 0.0
cfg.queue = []
cfg.queue_cursor = 0
cfg.history = []
cfg.excluded_asset_ids = []
cfg.album_id = album_id
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
cfg.refresh_interval_s = max(MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s))
cfg.smart_crop_faces = smart_crop_faces
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
cfg.quiet_hours_enabled = quiet_hours_enabled
if quiet_hours.valid_hhmm(quiet_hours_start):
cfg.album_id = album_id
if order is not None:
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
if refresh_interval_s is not None:
cfg.refresh_interval_s = max(
MIN_REFRESH_INTERVAL_S, min(MAX_REFRESH_INTERVAL_S, refresh_interval_s)
)
if smart_crop_faces is not None:
cfg.smart_crop_faces = smart_crop_faces
if queue_target_len is not None:
cfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
if orientation is not None:
cfg.orientation = orientation if orientation in ORIENTATIONS else "landscape"
if quiet_hours_enabled is not None:
cfg.quiet_hours_enabled = quiet_hours_enabled
if quiet_hours_start is not None and quiet_hours.valid_hhmm(quiet_hours_start):
cfg.quiet_hours_start = quiet_hours_start
if quiet_hours.valid_hhmm(quiet_hours_end):
if quiet_hours_end is not None and quiet_hours.valid_hhmm(quiet_hours_end):
cfg.quiet_hours_end = quiet_hours_end
if timezone in quiet_hours.ALL_TIMEZONES:
if timezone is not None and timezone in quiet_hours.ALL_TIMEZONES:
cfg.timezone = timezone
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
cfg.firmware_auto_update = firmware_auto_update
if firmware_update_repo_url is not None:
cfg.firmware_update_repo_url = firmware_update_repo_url.strip()
if firmware_auto_update is not None:
cfg.firmware_auto_update = firmware_auto_update
cfg.stats_config_saves += 1
return {"status": "saved"}
@router.get("/api/stats")
def api_stats(db: Session = Depends(get_db)):
frame = default_frame(db)
@router.post("/api/frames/{frame_id}/take-control")
def api_take_control(
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
"""Always succeeds for any linked user -- the lock is deliberately
soft. The previous holder just sees who has it now."""
user = require_user_api(request, db)
previous = frame.controlled_by
frame.controlled_by_user_id = user.id
db.commit()
logger.info("User '%s' took control of frame #%d (from %s)", user.username, frame.id,
previous.username if previous else "nobody")
return {"status": "saved", "controller": user.display_name or user.username}
@router.get("/api/frames/{frame_id}/stats")
def api_stats(frame: Frame = Depends(require_frame_view)):
return {
"first_seen": frame.stats_first_seen,
"device_wakes": frame.stats_device_wakes,
@@ -118,9 +151,11 @@ def api_stats(db: Session = Depends(get_db)):
}
@router.get("/api/queue")
def api_queue(db: Session = Depends(get_db)):
frame = default_frame(db)
@router.get("/api/frames/{frame_id}/queue")
def api_queue(
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
user = require_user_api(request, db)
require_configured(frame)
client = immich_client_for(frame)
@@ -140,15 +175,25 @@ def api_queue(db: Session = Depends(get_db)):
"battery_as_of": cfg.battery_as_of,
"on_battery_since": cfg.battery_history[0][0] if cfg.battery_history else None,
"battery_estimate_s": battery_estimate_s(cfg),
"controller_id": cfg.controlled_by_user_id,
"controller": (
(cfg.controlled_by.display_name or cfg.controlled_by.username)
if cfg.controlled_by
else None
),
}
def entry(asset_id: str) -> dict:
return {"id": asset_id, "thumbnail_url": f"/api/photo-thumbnail/{asset_id}"}
return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/thumbnail/{asset_id}"}
now = time.time()
return {
"current": entry(snapshot["current_asset_id"]) if snapshot["current_asset_id"] else None,
"upcoming": [entry(asset_id) for asset_id in snapshot["queue"]],
"control": {
"controller": snapshot["controller"],
"you": snapshot["controller_id"] == user.id,
},
"device": {
"last_seen": snapshot["last_seen"] or None,
"overdue": bool(
@@ -167,9 +212,8 @@ def api_queue(db: Session = Depends(get_db)):
}
@router.get("/api/battery-log")
def api_battery_log(db: Session = Depends(get_db)):
frame = default_frame(db)
@router.get("/api/frames/{frame_id}/battery-log")
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
rows = db.execute(
select(BatteryLog.ts, BatteryLog.percent)
.where(BatteryLog.frame_id == frame.id)
@@ -182,15 +226,18 @@ class QueueReorderRequest(BaseModel):
queue: list[str]
@router.post("/api/queue/reorder")
def api_queue_reorder(body: QueueReorderRequest, db: Session = Depends(get_db)):
@router.post("/api/frames/{frame_id}/queue/reorder")
def api_queue_reorder(
body: QueueReorderRequest,
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Applies the client's requested order, tolerating drift between the
browser's last-fetched snapshot and the server's current queue (e.g.
a top-up/trim landed in between) instead of hard-rejecting: any ID
the client sent that's no longer actually queued is dropped, and any
ID the server has that the client didn't know about is appended
rather than lost."""
frame = default_frame(db)
with frame_locked(db, frame.id) as cfg:
current_set = set(cfg.queue)
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
@@ -203,14 +250,15 @@ class QueuePromoteRequest(BaseModel):
asset_id: str
@router.post("/api/queue/promote")
def api_queue_promote(body: QueuePromoteRequest, db: Session = Depends(get_db)):
"""Moves a single photo to the front of the queue -- "Show next" in
the web UI. Unlike /api/queue/reorder, this doesn't depend on the
client supplying a full, exactly-current snapshot of the queue at
all, so it can't fail due to the queue having shifted server-side
since the browser's last fetch."""
frame = default_frame(db)
@router.post("/api/frames/{frame_id}/queue/promote")
def api_queue_promote(
body: QueuePromoteRequest,
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Moves a single photo to the front of the queue -- "Show next".
Unlike reorder, doesn't depend on the client knowing the queue's
exact current order, so it can't fail from staleness."""
with frame_locked(db, frame.id) as cfg:
if body.asset_id not in cfg.queue:
raise HTTPException(400, "That photo is no longer in the upcoming queue")
@@ -222,14 +270,15 @@ class QueueRemoveRequest(BaseModel):
asset_id: str
@router.post("/api/queue/remove")
def api_queue_remove(body: QueueRemoveRequest, db: Session = Depends(get_db)):
"""Permanently removes a photo from this frame's rotation -- "Remove"
in the web UI, on either an upcoming card or the current photo. Does
NOT touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
frame = default_frame(db)
@router.post("/api/frames/{frame_id}/queue/remove")
def api_queue_remove(
body: QueueRemoveRequest,
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Permanently removes a photo from this frame's rotation. Does NOT
touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
require_configured(frame)
client = immich_client_for(frame)
assets = list_assets(client, frame)
@@ -238,9 +287,8 @@ def api_queue_remove(body: QueueRemoveRequest, db: Session = Depends(get_db)):
return {"status": "removed"}
@router.get("/api/photo-thumbnail/{asset_id}")
def api_photo_thumbnail(asset_id: str, db: Session = Depends(get_db)):
frame = default_frame(db)
@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
def api_thumbnail(asset_id: str, frame: Frame = Depends(require_frame_view)):
require_configured(frame)
client = immich_client_for(frame)
try:
@@ -250,13 +298,16 @@ def api_photo_thumbnail(asset_id: str, db: Session = Depends(get_db)):
return Response(content=content, media_type=content_type)
@router.post("/api/firmware")
def api_firmware_upload(file: UploadFile = File(...), db: Session = Depends(get_db)):
@router.post("/api/frames/{frame_id}/firmware")
def api_firmware_upload(
file: UploadFile = File(...),
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Uploads a firmware image for OTA. The version is parsed out of the
image itself (esp_app_desc_t) rather than trusted from a filename or
form field, and the project name is checked so an unrelated .bin
can't be pushed to the frame by mistake."""
frame = default_frame(db)
data = file.file.read()
version = parse_app_version(data)
path = firmware_path(frame.id)
@@ -278,11 +329,9 @@ def _fetch_latest_release(frame: Frame) -> dict | None:
def _apply_gitea_update(db: Session, frame: Frame) -> str:
"""Downloads the configured Gitea repo's latest release asset for this
frame's board variant and stages it exactly like a manual upload
would. The board comes from the device itself (device_board_variant,
learned from its X-Frame-Board header), not a user picker, so
there's nothing to fetch until the device has checked in at least
once. Network I/O happens before the lock is taken."""
frame's board variant (learned from the device's X-Frame-Board
header, never picked by hand) and stages it exactly like a manual
upload. Network I/O happens before the lock is taken."""
if not frame.device_board_variant:
raise HTTPException(400, "The frame hasn't checked in yet -- can't tell which board's build to fetch")
release = _fetch_latest_release(frame)
@@ -307,18 +356,15 @@ def _apply_gitea_update(db: Session, frame: Frame) -> str:
return version
@router.get("/api/firmware/check")
def api_firmware_check(force: bool = False, db: Session = Depends(get_db)):
@router.get("/api/frames/{frame_id}/firmware/check")
def api_firmware_check(
force: bool = False, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
"""Throttled check of the configured Gitea repo's latest release
(gitea_releases.UPDATE_CHECK_INTERVAL_S) -- cheap, since it only reads
the release's tag name, not its binaries. If firmware_auto_update is
(gitea_releases.UPDATE_CHECK_INTERVAL_S). If firmware_auto_update is
on and a newer version is found, applies it immediately; otherwise
just reports it so the web UI can offer the "Update frame" button.
force=true (the "Check now" button) bypasses the throttle and always
hits Gitea -- otherwise a genuinely new release can sit invisible in
the UI for up to the full throttle interval."""
frame = default_frame(db)
just reports it so the UI can offer the "Update frame" button.
force=true (the "Check now" button) bypasses the throttle."""
if not frame.firmware_update_repo_url:
return {"enabled": False}
@@ -351,13 +397,14 @@ def api_firmware_check(force: bool = False, db: Session = Depends(get_db)):
}
@router.post("/api/firmware/apply-latest")
def api_firmware_apply_latest(db: Session = Depends(get_db)):
@router.post("/api/frames/{frame_id}/firmware/apply-latest")
def api_firmware_apply_latest(
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
):
"""The "Update frame" button: applies the latest Gitea release right
now, bypassing the check throttle -- this is an explicit user action,
not a background poll."""
frame = default_frame(db)
now, bypassing the check throttle -- an explicit user action, not a
background poll."""
if not frame.firmware_update_repo_url:
raise HTTPException(400, "No Gitea firmware repo configured")
version = _apply_gitea_update(db, frame)
return {"status": "saved", "version": version}
return {"status": "saved", "version": version}
+26 -12
View File
@@ -106,15 +106,29 @@ def battery_estimate_s(frame: Frame) -> int | None:
return int(last_pct / rate)
def default_frame(db: Session) -> Frame:
"""Phase A only: the old single-frame /api/* routes all operate on
"the" frame -- the legacy one if flagged, else the lowest id.
Replaced by explicit /api/frames/{id}/ paths in Phase D."""
frame = db.scalars(
select(Frame).where(Frame.legacy_token_enabled == True) # noqa: E712
).first()
if frame is None:
frame = db.scalars(select(Frame).order_by(Frame.id).limit(1)).first()
if frame is None:
raise HTTPException(404, "No frame exists yet")
return frame
def shell_context(request, db: Session, user, active_frame: Frame | None = None,
active_nav: str | None = None) -> dict:
"""Template context every app-shell (sidebar) page needs: the user's
frame list with an online indicator, the active highlights, and the
session's CSRF token. Import here (not auth) keeps the router
modules' template plumbing in one place."""
import time as _time
from .. import quiet_hours
from ..auth import current_session, user_frames
session = current_session(request, db)
frames = user_frames(db, user)
now = _time.time()
for f in frames:
# Same "not overdue" definition the Device panel uses.
gap = quiet_hours.max_expected_gap_s(f) * OVERDUE_FACTOR
f.recently_seen = bool(f.last_seen and now - f.last_seen <= gap)
return {
"request": request,
"user": user,
"csrf_token": session.csrf_token if session else None,
"sidebar_frames": frames,
"active_frame": active_frame,
"active_nav": active_nav,
}
+49
View File
@@ -0,0 +1,49 @@
"""The per-frame HTML pages: Photos (/frames/{id}), Configuration, and
Stats tabs, all inside the sidebar app shell. Data loading happens
client-side against /api/frames/{id}/... (routers/api_frames.py); these
routes just authorize and render the scaffold."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from ..auth import can_view_frame, current_user
from ..db import get_db
from ..models import Frame
from ..quiet_hours import ALL_TIMEZONES
from .common import shell_context
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
def _frame_page(request: Request, db: Session, frame_id: int, template: str, tab: str, **extra):
user = current_user(request, db)
if user is None:
return RedirectResponse(f"/login?next=/frames/{frame_id}", status_code=303)
frame = db.get(Frame, frame_id)
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
ctx = shell_context(request, db, user, active_frame=frame)
ctx.update({"frame": frame, "active_tab": tab, **extra})
return templates.TemplateResponse(template, ctx)
@router.get("/frames/{frame_id}", response_class=HTMLResponse)
def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_photos.html", "photos")
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(
request, db, frame_id, "frame_config.html", "config", timezones=ALL_TIMEZONES
)
@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse)
def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_stats.html", "stats")
+21 -21
View File
@@ -326,15 +326,21 @@ def claim_signup(
return response
def _settings_context(request: Request, db: Session, user, saved: bool, error: str | None) -> dict:
from .common import shell_context
ctx = shell_context(request, db, user, active_nav="settings")
ctx.update({"saved": saved, "error": error})
return ctx
@router.get("/settings", response_class=HTMLResponse)
def settings_page(request: Request, db: Session = Depends(get_db)):
user = current_user(request, db)
if user is None:
return RedirectResponse("/login", status_code=303)
session = current_session(request, db)
return templates.TemplateResponse(
"settings.html",
{"request": request, "user": user, "csrf_token": session.csrf_token, "saved": False, "error": None},
"settings.html", _settings_context(request, db, user, saved=False, error=None)
)
@@ -353,7 +359,6 @@ def settings_submit(
if user is None:
return RedirectResponse("/login", status_code=303)
_check_form_csrf(request, db, csrf_token)
session = current_session(request, db)
error = None
user.display_name = display_name.strip() or user.username
@@ -374,9 +379,7 @@ def settings_submit(
db.commit()
return templates.TemplateResponse(
"settings.html",
{"request": request, "user": user, "csrf_token": session.csrf_token,
"saved": error is None, "error": error},
"settings.html", _settings_context(request, db, user, saved=error is None, error=error)
)
@@ -389,7 +392,8 @@ def _require_admin_page(request: Request, db: Session) -> User:
def _render_admin(request: Request, db: Session, admin: User, notice: str | None = None,
error: str | None = None) -> HTMLResponse:
session = current_session(request, db)
from .common import shell_context
users = list(db.scalars(select(User).order_by(User.id)))
frames = list(db.scalars(select(Frame).order_by(Frame.id)))
links = list(db.scalars(select(UserFrame)))
@@ -397,19 +401,15 @@ def _render_admin(request: Request, db: Session, admin: User, notice: str | None
users_by_id = {u.id: u for u in users}
for link in links:
links_by_frame.setdefault(link.frame_id, []).append(users_by_id[link.user_id])
return templates.TemplateResponse(
"admin.html",
{
"request": request,
"user": admin,
"csrf_token": session.csrf_token,
"users": users,
"frames": frames,
"links_by_frame": links_by_frame,
"notice": notice,
"error": error,
},
)
ctx = shell_context(request, db, admin, active_nav="admin")
ctx.update({
"users": users,
"frames": frames,
"links_by_frame": links_by_frame,
"notice": notice,
"error": error,
})
return templates.TemplateResponse("admin.html", ctx)
@router.get("/admin", response_class=HTMLResponse)
+89
View File
@@ -0,0 +1,89 @@
// Hand-drawn canvas battery-history chart. Ported intact from the
// original single-page UI. Reads theme colors live so it redraws
// correctly on theme changes (see the themechange listener in
// frame_stats.js).
let lastBatteryLog = null;
function drawBatteryChart(log) {
lastBatteryLog = log;
const wrap = document.getElementById('battery-chart-wrap');
if (!log || log.length < 2) {
wrap.innerHTML = '<p class="sub">Not enough data yet.</p>';
return;
}
wrap.innerHTML = '';
const width = wrap.clientWidth || 440;
const height = 180;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.style.display = 'block';
canvas.style.border = `1px solid ${themeColor('--border')}`;
canvas.style.borderRadius = '8px';
wrap.appendChild(canvas);
const ctx = canvas.getContext('2d');
const gridColor = themeColor('--border');
const mutedColor = themeColor('--text-muted');
const accentColor = themeColor('--accent');
const pad = { left: 28, right: 8, top: 10, bottom: 20 };
const plotW = width - pad.left - pad.right;
const plotH = height - pad.top - pad.bottom;
const times = log.map((p) => p[0]);
const minT = Math.min(...times);
const maxT = Math.max(...times);
const spanT = Math.max(1, maxT - minT);
const x = (t) => pad.left + ((t - minT) / spanT) * plotW;
const y = (pct) => pad.top + (1 - pct / 100) * plotH;
ctx.strokeStyle = gridColor;
ctx.fillStyle = mutedColor;
ctx.font = '10px system-ui, sans-serif';
ctx.lineWidth = 1;
ctx.textAlign = 'left';
[0, 25, 50, 75, 100].forEach((pct) => {
const yy = y(pct);
ctx.beginPath();
ctx.moveTo(pad.left, yy);
ctx.lineTo(width - pad.right, yy);
ctx.stroke();
ctx.fillText(String(pct), 2, yy + 3);
});
ctx.strokeStyle = accentColor;
ctx.lineWidth = 1.5;
ctx.beginPath();
log.forEach((p, i) => {
const px = x(p[0]);
const py = y(p[1]);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
const fmt = (t) => new Date(t * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
ctx.fillStyle = mutedColor;
ctx.textAlign = 'left';
ctx.fillText(fmt(minT), pad.left, height - 4);
ctx.textAlign = 'right';
ctx.fillText(fmt(maxT), width - pad.right, height - 4);
}
async function loadBatteryLog() {
const wrap = document.getElementById('battery-chart-wrap');
try {
const resp = await fetch(`${window.FRAME_API}/battery-log`);
if (!resp.ok) {
wrap.innerHTML = '<p class="sub">Could not load.</p>';
return;
}
const data = await resp.json();
drawBatteryChart(data.log);
} catch (e) {
wrap.innerHTML = '<p class="sub">Could not load.</p>';
}
}
+97
View File
@@ -0,0 +1,97 @@
// Shared plumbing for every page: CSRF-injecting fetch, theme toggle,
// sidebar toggle (mobile), and small formatting helpers. No framework,
// no build step -- plain scripts, load order handled by <script> tags.
// Session-cookie auth needs CSRF proof on mutating requests. Wrapping
// fetch once means no call site has to remember the header. The token
// rides a <meta> tag emitted only for session-authed pages.
(function () {
var meta = document.querySelector('meta[name="csrf-token"]');
if (!meta || !meta.content) return;
var CSRF = meta.content;
var origFetch = window.fetch;
window.fetch = function (input, init) {
init = init || {};
var method = (init.method || (input && input.method) || 'GET').toUpperCase();
var url = typeof input === 'string' ? input : (input && input.url) || '';
var sameOrigin = url.indexOf('://') === -1 || url.indexOf(location.origin) === 0;
if (sameOrigin && method !== 'GET' && method !== 'HEAD') {
init.headers = new Headers(init.headers || (input && input.headers) || {});
init.headers.set('X-CSRF-Token', CSRF);
}
return origFetch.call(this, input, init);
};
})();
// Theme toggle: explicit choice wins over the OS preference and is
// remembered; with no explicit choice, CSS falls back to
// prefers-color-scheme on its own. (The pre-paint snippet in the page
// <head> applies the stored theme before first render.)
(function () {
var btn = document.getElementById('theme-toggle');
if (!btn) return;
function currentTheme() {
var stored = null;
try { stored = localStorage.getItem('theme'); } catch (e) { /* ignore */ }
if (stored === 'light' || stored === 'dark') return stored;
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
}
btn.addEventListener('click', function () {
var theme = currentTheme() === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('theme', theme); } catch (e) { /* ignore */ }
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: theme } }));
});
})();
// Mobile sidebar: hamburger opens, backdrop or navigation closes.
(function () {
var shell = document.querySelector('.shell');
var toggle = document.getElementById('sidebar-toggle');
var backdrop = document.querySelector('.sidebar-backdrop');
if (!shell || !toggle) return;
toggle.addEventListener('click', function () { shell.classList.toggle('sidebar-open'); });
if (backdrop) {
backdrop.addEventListener('click', function () { shell.classList.remove('sidebar-open'); });
}
})();
function showStatus(ok, message) {
var el = document.getElementById('result');
if (!el) return;
el.innerHTML = '<div class="status ' + (ok ? 'ok' : 'err') + '"></div>';
el.firstChild.textContent = message;
}
// A 409 from a control-gated endpoint means someone else holds the
// frame's control lock -- surface who, plus how to take over.
async function apiError(resp) {
var text = await resp.text();
try {
var body = JSON.parse(text);
var detail = body.detail !== undefined ? body.detail : body;
if (detail && detail.error === 'not_controller') {
var holder = detail.holder || 'Someone else';
return holder + ' has control of this frame — use "Take control" to make changes.';
}
if (typeof detail === 'string') return detail;
} catch (e) { /* not JSON */ }
return text;
}
function formatDuration(seconds) {
var d = Math.floor(seconds / 86400);
var h = Math.floor((seconds % 86400) / 3600);
var m = Math.floor((seconds % 3600) / 60);
if (d > 0) return d + 'd ' + h + 'h';
if (h > 0) return h + 'h ' + m + 'm';
return m + 'm';
}
// Reads resolved colors from CSS custom properties rather than
// hardcoding hex values, so canvas drawing matches the current theme.
function themeColor(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
+201
View File
@@ -0,0 +1,201 @@
// Configuration tab: frame settings + firmware card + take control.
// window.FRAME_API is set by the template. Checkboxes are always sent
// explicitly as "true"/"false" -- the server treats absent fields as
// "leave unchanged", so a checkbox must never be simply omitted.
async function saveConfig() {
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
const body = new URLSearchParams({
name: document.getElementById('frame_name').value || '',
order: document.getElementById('order').value,
orientation: document.getElementById('orientation').value,
refresh_interval_s: String(minutes * 60),
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked),
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
timezone: document.getElementById('timezone').value || 'UTC',
});
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
}
document.getElementById('config-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await saveConfig();
showStatus(true, 'Saved.');
} catch (e) {
showStatus(false, e.message);
}
});
async function takeControl() {
try {
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'You have control now.');
loadControl();
} catch (e) {
showStatus(false, e.message);
}
}
async function loadControl() {
const banner = document.getElementById('control-banner');
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
if (!resp.ok) return; // unconfigured frame: control still works via 409s
const data = await resp.json();
if (data.control && !data.control.you) {
banner.style.display = 'flex';
document.getElementById('control-holder').textContent = data.control.controller
? `${data.control.controller} currently has control of this frame.`
: 'Nobody has control of this frame yet.';
} else {
banner.style.display = 'none';
}
} catch (e) { /* banner is best-effort */ }
}
document.getElementById('take-control').addEventListener('click', takeControl);
// ---- Firmware card ----
document.getElementById('firmware-upload').addEventListener('click', async () => {
const input = document.getElementById('firmware-file');
if (!input.files.length) {
showStatus(false, 'Pick a firmware .bin first.');
return;
}
const form = new FormData();
form.append('file', input.files[0]);
try {
const resp = await fetch(`${window.FRAME_API}/firmware`, { method: 'POST', body: form });
if (!resp.ok) {
throw new Error(await apiError(resp));
}
const result = await resp.json();
document.getElementById('firmware-available').textContent =
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
showStatus(true, `Firmware v${result.version} uploaded (${result.size} bytes).`);
} catch (e) {
showStatus(false, e.message);
}
});
function showRepoDisplayMode(url) {
document.getElementById('firmware-repo-text').textContent = url;
document.getElementById('firmware-repo-display').style.display = url ? 'block' : 'none';
document.getElementById('firmware-repo-edit').style.display = url ? 'none' : 'block';
}
document.getElementById('firmware-repo-edit-btn').addEventListener('click', () => {
document.getElementById('firmware-repo-display').style.display = 'none';
document.getElementById('firmware-repo-edit').style.display = 'block';
document.getElementById('firmware_update_repo_url').focus();
});
document.getElementById('firmware-settings-save').addEventListener('click', async () => {
try {
const body = new URLSearchParams({
firmware_update_repo_url: document.getElementById('firmware_update_repo_url').value || '',
firmware_auto_update: String(document.getElementById('firmware_auto_update').checked),
});
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim());
loadFirmwareCheck();
} catch (e) {
showStatus(false, e.message);
}
});
async function loadFirmwareCheck(force) {
const statusEl = document.getElementById('firmware-gitea-status');
const btn = document.getElementById('firmware-update-btn');
const boardEl = document.getElementById('firmware-board');
try {
const resp = await fetch(`${window.FRAME_API}/firmware/check` + (force ? '?force=true' : ''));
if (!resp.ok) {
if (force) {
showStatus(false, await apiError(resp));
}
return;
}
const data = await resp.json();
if (data.board) {
boardEl.textContent = `Detected board: ${data.board}`;
}
if (!data.enabled) {
statusEl.style.display = 'none';
btn.style.display = 'none';
if (force) {
showStatus(false, 'No Gitea repo URL configured.');
}
return;
}
statusEl.style.display = 'block';
if (!data.board) {
statusEl.textContent = 'Waiting for the frame to check in before it can look up the right build.';
btn.style.display = 'none';
} else if (data.update_available) {
statusEl.textContent = `Update available: v${data.latest_version}.`;
btn.style.display = 'inline-block';
} else if (data.latest_version) {
statusEl.textContent = `Up to date (v${data.latest_version}).`;
btn.style.display = 'none';
} else {
statusEl.textContent = 'No releases found yet.';
btn.style.display = 'none';
}
if (force) {
showStatus(true, 'Checked.');
}
} catch (e) {
// A failed passive poll is silent; an explicit "Check now" click
// still surfaces the error.
if (force) {
showStatus(false, e.message);
}
}
}
document.getElementById('firmware-check-now').addEventListener('click', () => loadFirmwareCheck(true));
document.getElementById('firmware-update-btn').addEventListener('click', async () => {
const btn = document.getElementById('firmware-update-btn');
btn.disabled = true;
try {
const resp = await fetch(`${window.FRAME_API}/firmware/apply-latest`, { method: 'POST' });
if (!resp.ok) {
throw new Error(await apiError(resp));
}
const result = await resp.json();
document.getElementById('firmware-available').textContent =
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
showStatus(true, `Firmware v${result.version} staged from Gitea.`);
loadFirmwareCheck();
} catch (e) {
showStatus(false, e.message);
} finally {
btn.disabled = false;
}
});
loadControl();
loadFirmwareCheck();
// The server throttles actual Gitea API calls itself, so this poll is
// cheap either way.
setInterval(loadFirmwareCheck, 60000);
+126
View File
@@ -0,0 +1,126 @@
// Photos tab: now-displaying, album picker, and the upcoming grid
// (rendering/drag logic in queue.js). window.FRAME_API is set by the
// template.
function renderControlBanner(control) {
const banner = document.getElementById('control-banner');
if (!banner) return;
if (!control || control.you) {
banner.style.display = 'none';
return;
}
banner.style.display = 'flex';
document.getElementById('control-holder').textContent = control.controller
? `${control.controller} currently has control of this frame.`
: 'Nobody has control of this frame yet.';
}
async function takeControl() {
try {
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'You have control now.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
}
async function loadQueue() {
if (dragState) {
return; // don't yank the grid out from under an in-progress drag
}
const currentEl = document.getElementById('current-thumb');
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
if (!resp.ok) {
currentEl.innerHTML =
'<p class="sub">Not available yet -- the owner needs to connect Immich (Settings) and pick an album.</p>';
renderUpcoming([]);
return;
}
const data = await resp.json();
currentEl.innerHTML = '';
if (data.current) {
const wrap = document.createElement('div');
wrap.className = 'thumb-wrap';
const img = document.createElement('img');
img.className = 'thumb';
img.src = data.current.thumbnail_url;
img.alt = '';
wrap.appendChild(img);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.title = 'Remove from rotation';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => removeAsset(data.current.id));
wrap.appendChild(removeBtn);
currentEl.appendChild(wrap);
} else {
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
}
renderControlBanner(data.control);
renderUpcoming(data.upcoming);
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
}
}
async function savePhotoSettings() {
const body = new URLSearchParams({
album_id: document.getElementById('album_id').value || '',
queue_target_len: document.getElementById('queue_target_len').value,
});
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
}
document.getElementById('load-albums').addEventListener('click', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/albums`);
if (!resp.ok) {
throw new Error(await apiError(resp));
}
const albums = await resp.json();
const select = document.getElementById('album_id');
select.innerHTML = '';
for (const a of albums) {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = `${a.name} (${a.count})`;
select.appendChild(opt);
}
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('photos-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await savePhotoSettings();
showStatus(true, 'Saved.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('take-control').addEventListener('click', takeControl);
loadQueue();
// Slow poll: picks up real changes (new photo displayed, queue edited
// from elsewhere) without a manual refresh. Skipped mid-drag.
setInterval(loadQueue, 10000);
+118
View File
@@ -0,0 +1,118 @@
// Stats tab: device status, lifetime counters, battery history chart
// (chart logic in battery_chart.js). window.FRAME_API set by template.
let lastDevice = null;
function renderDeviceStatus(device) {
const el = document.getElementById('device-status');
el.innerHTML = '';
if (!device || !device.last_seen) {
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
return;
}
const now = Date.now() / 1000;
const rows = [];
const ago = formatDuration(Math.max(0, now - device.last_seen));
rows.push(['Last seen', `${ago} ago`, device.overdue]);
if (device.firmware_version) {
let fw = `v${device.firmware_version}`;
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
fw += ` (v${device.firmware_available} waiting)`;
}
rows.push(['Firmware', fw, false]);
}
if (device.battery) {
rows.push(['Battery', `${device.battery.percent}%`, false]);
}
if (device.on_battery_since) {
rows.push(['On battery for', formatDuration(now - device.on_battery_since), false]);
}
if (device.battery_estimate_s !== null && device.battery_estimate_s !== undefined) {
rows.push(['Est. remaining', `~${formatDuration(device.battery_estimate_s)}`, false]);
}
for (const [label, value, alert] of rows) {
const p = document.createElement('p');
p.className = 'sub';
if (alert) {
p.style.color = 'var(--danger-text)';
p.style.fontWeight = '600';
}
p.textContent = `${label}: ${value}`;
el.appendChild(p);
}
}
async function loadDevice() {
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
if (!resp.ok) {
return;
}
const data = await resp.json();
lastDevice = data.device;
renderDeviceStatus(data.device);
} catch (e) { /* retried on the next poll */ }
}
function renderStats(stats) {
const el = document.getElementById('stats-box');
el.innerHTML = '';
const now = Date.now() / 1000;
const rows = [
['Running since', stats.first_seen ? formatDuration(Math.max(0, now - stats.first_seen)) + ' ago' : 'never checked in'],
['Wake cycles', stats.device_wakes],
['Photos displayed', stats.photos_displayed],
['Photos removed from rotation', stats.photos_removed],
['Battery reports received', stats.battery_reports],
['Battery recharge cycles', stats.recharge_cycles],
['OTA updates applied', stats.ota_updates_applied],
['Settings saved', stats.config_saves],
];
for (const [label, value] of rows) {
const p = document.createElement('p');
p.className = 'sub';
p.textContent = `${label}: ${value}`;
el.appendChild(p);
}
}
async function loadStats() {
const el = document.getElementById('stats-box');
try {
const resp = await fetch(`${window.FRAME_API}/stats`);
if (!resp.ok) {
el.innerHTML = '<p class="sub">Could not load.</p>';
return;
}
renderStats(await resp.json());
} catch (e) {
el.innerHTML = '<p class="sub">Could not load.</p>';
}
}
loadDevice();
loadStats();
loadBatteryLog();
// Redraw the canvas chart (and the "Last seen" alert color) with the
// new theme's colors as soon as the toggle is used -- canvas pixels
// don't repaint themselves the way CSS does.
document.addEventListener('themechange', () => {
if (lastBatteryLog) {
drawBatteryChart(lastBatteryLog);
}
if (lastDevice) {
renderDeviceStatus(lastDevice);
}
});
// Fast tick: re-renders "Last seen"/"On battery for" from already-
// fetched data every second so they count up smoothly without hitting
// the server that often.
setInterval(() => {
if (lastDevice) {
renderDeviceStatus(lastDevice);
}
}, 1000);
setInterval(loadDevice, 10000);
+240
View File
@@ -0,0 +1,240 @@
// The upcoming-photos grid: rendering plus drag-to-reorder. Ported
// intact from the original single-page UI -- the Pointer Events state
// machine below (hold-to-arm on touch so page scrolling still works) is
// battle-tested; treat changes with suspicion.
//
// Expects window.FRAME_API = '/api/frames/<id>' set by the page, and a
// loadQueue() global (frame_photos.js) to refetch authoritative state.
let upcomingItems = [];
// Pointer Events (not the native HTML5 Drag-and-Drop API) so the same
// code drives mouse, touch, and pen -- native drag-and-drop is
// mouse-only by spec and never fires at all on phones/tablets.
//
// On touch specifically, a card only "arms" for dragging after a
// brief hold (DRAG_HOLD_MS) with the finger roughly stationary --
// .photo-card's touch-action stays "pan-y" (native scroll allowed)
// the whole time up to that point, so a normal touch-and-swipe to
// scroll the page still works even though it starts on a card. Once
// armed, touch-action switches to "none" for the rest of that touch
// so drag tracking gets every pointermove reliably. Mouse skips the
// hold entirely (no scroll-vs-drag ambiguity with a mouse).
let dragState = null; // { pointerId, fromIndex, toIndex, moved, armed, holdTimer }
const DRAG_START_THRESHOLD_PX = 6; // ignore tiny jitter once armed, before committing to a drag
const DRAG_HOLD_MS = 250;
const HOLD_CANCEL_THRESHOLD_PX = 10; // movement before the hold timer fires -> treat as a scroll, not a drag
function vibrate(ms) {
// Chrome/Android only -- iOS Safari has no Vibration API. Wrapped so
// it's a harmless no-op everywhere else rather than needing a
// feature check at every call site.
try { navigator.vibrate?.(ms); } catch (e) { /* ignore */ }
}
function clearDragOverStyling() {
document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over'));
}
function endDrag(card) {
if (dragState && dragState.holdTimer) {
clearTimeout(dragState.holdTimer);
}
card.style.touchAction = '';
card.style.transform = '';
card.classList.remove('dragging', 'drag-armed');
clearDragOverStyling();
if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) {
vibrate(15);
moveItem(dragState.fromIndex, dragState.toIndex);
}
dragState = null;
}
function renderUpcoming(items) {
upcomingItems = items;
const grid = document.getElementById('upcoming-grid');
grid.innerHTML = '';
items.forEach((item, i) => {
const card = document.createElement('div');
card.className = 'photo-card';
card.dataset.index = String(i);
const img = document.createElement('img');
img.src = item.thumbnail_url;
img.alt = '';
card.appendChild(img);
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = String(i + 1);
card.appendChild(badge);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.title = 'Remove from rotation';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
removeAsset(item.id);
});
card.appendChild(removeBtn);
const nextBtn = document.createElement('button');
nextBtn.type = 'button';
nextBtn.className = 'show-next';
nextBtn.textContent = 'Show next';
nextBtn.addEventListener('click', (e) => {
e.stopPropagation();
showNext(i);
});
card.appendChild(nextBtn);
card.addEventListener('pointerdown', (e) => {
if (e.target.closest('.show-next') || e.target.closest('.remove-btn')) {
return; // let the button's own click handler run, don't start a drag
}
if (e.pointerType === 'mouse' && e.button !== 0) {
return; // left button only
}
dragState = {
pointerId: e.pointerId, fromIndex: i, toIndex: undefined, moved: false,
armed: e.pointerType !== 'touch', startX: e.clientX, startY: e.clientY, holdTimer: null,
};
if (e.pointerType === 'touch') {
dragState.holdTimer = setTimeout(() => {
if (dragState && dragState.pointerId === e.pointerId && !dragState.armed) {
dragState.armed = true;
card.classList.add('drag-armed');
card.style.touchAction = 'none';
vibrate(10);
try { card.setPointerCapture(e.pointerId); } catch (err) { /* pointer may have already left */ }
}
}, DRAG_HOLD_MS);
} else {
card.setPointerCapture(e.pointerId);
}
});
card.addEventListener('pointermove', (e) => {
if (!dragState || dragState.pointerId !== e.pointerId) {
return;
}
if (!dragState.armed) {
// Still deciding whether this is a hold-to-drag or a scroll --
// moving this much before the hold timer fires means scroll;
// bail out and let the browser's native pan-y handle it.
const dx0 = e.clientX - dragState.startX;
const dy0 = e.clientY - dragState.startY;
if (Math.hypot(dx0, dy0) > HOLD_CANCEL_THRESHOLD_PX) {
clearTimeout(dragState.holdTimer);
dragState = null;
}
return;
}
const dx = e.clientX - dragState.startX;
const dy = e.clientY - dragState.startY;
if (!dragState.moved) {
if (Math.hypot(dx, dy) < DRAG_START_THRESHOLD_PX) {
return;
}
dragState.moved = true;
card.classList.remove('drag-armed');
card.classList.add('dragging');
}
// Follows the finger 1:1 -- the actual "pick it up and carry it"
// feedback.
card.style.transform = `translate(${dx}px, ${dy}px) scale(1.06)`;
const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card');
clearDragOverStyling();
if (overCard && overCard !== card) {
overCard.classList.add('drag-over');
dragState.toIndex = Number(overCard.dataset.index);
} else {
dragState.toIndex = undefined;
}
});
card.addEventListener('pointerup', (e) => {
if (dragState && dragState.pointerId === e.pointerId) {
endDrag(card);
}
});
card.addEventListener('pointercancel', (e) => {
if (dragState && dragState.pointerId === e.pointerId) {
endDrag(card);
}
});
grid.appendChild(card);
});
}
function moveItem(fromIndex, toIndex) {
const items = upcomingItems.slice();
const [moved] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, moved);
renderUpcoming(items);
persistOrder(items);
}
async function showNext(index) {
const assetId = upcomingItems[index].id;
try {
const resp = await fetch(`${window.FRAME_API}/queue/promote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
} catch (e) {
showStatus(false, e.message);
}
loadQueue(); // always refetch the authoritative order rather than guessing locally
}
async function removeAsset(assetId) {
if (!confirm('Remove this photo from the rotation? It stays in Immich -- this frame just won\'t show it again.')) {
return;
}
try {
const resp = await fetch(`${window.FRAME_API}/queue/remove`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
} catch (e) {
showStatus(false, e.message);
}
loadQueue();
}
async function persistOrder(items) {
try {
const resp = await fetch(`${window.FRAME_API}/queue/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ queue: items.map((item) => item.id) }),
});
if (!resp.ok) {
throw new Error(await apiError(resp));
}
} catch (e) {
showStatus(false, e.message);
loadQueue();
}
}
+470
View File
@@ -0,0 +1,470 @@
:root {
--bg: #f5f6f8;
--surface: #ffffff;
--surface-alt: #f0f1f4;
--border: #e3e5e9;
--text: #16181d;
--text-muted: #666d7a;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--focus-ring: rgba(37, 99, 235, 0.35);
--success-bg: #dcfce7;
--success-text: #166534;
--danger-bg: #fee2e2;
--danger-text: #991b1b;
--warn-bg: #fef9c3;
--warn-text: #854d0e;
--shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06);
--shadow-hover: 0 8px 20px rgba(16, 24, 40, 0.10);
--overlay: rgba(0, 0, 0, 0.55);
--overlay-hover: rgba(153, 27, 27, 0.85);
--color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0d1016;
--surface: #161a22;
--surface-alt: #1d222b;
--border: #2a2f3a;
--text: #e8eaed;
--text-muted: #9aa2b1;
--accent: #4c8dff;
--accent-hover: #6ea1ff;
--focus-ring: rgba(76, 141, 255, 0.4);
--success-bg: rgba(34, 197, 94, 0.16);
--success-text: #4ade80;
--danger-bg: rgba(239, 68, 68, 0.16);
--danger-text: #f87171;
--warn-bg: rgba(234, 179, 8, 0.16);
--warn-text: #fbbf24;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
--overlay: rgba(0, 0, 0, 0.55);
--overlay-hover: rgba(248, 113, 113, 0.35);
--color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0d1016;
--surface: #161a22;
--surface-alt: #1d222b;
--border: #2a2f3a;
--text: #e8eaed;
--text-muted: #9aa2b1;
--accent: #4c8dff;
--accent-hover: #6ea1ff;
--focus-ring: rgba(76, 141, 255, 0.4);
--success-bg: rgba(34, 197, 94, 0.16);
--success-text: #4ade80;
--danger-bg: rgba(239, 68, 68, 0.16);
--danger-text: #f87171;
--warn-bg: rgba(234, 179, 8, 0.16);
--warn-text: #fbbf24;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
--overlay: rgba(0, 0, 0, 0.55);
--overlay-hover: rgba(248, 113, 113, 0.35);
--color-scheme: dark;
}
* { box-sizing: border-box; }
html {
color-scheme: var(--color-scheme);
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
transition: background-color .15s ease, color .15s ease;
}
.page {
max-width: 1080px;
margin: 0 auto;
padding: 28px 20px 72px;
}
.page.page-narrow {
max-width: 420px;
padding-top: 88px;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.brand { display: flex; align-items: flex-start; gap: 12px; }
.brand-mark {
font-size: 26px;
line-height: 1;
margin-top: 2px;
}
h1 { font-size: 21px; margin: 0; letter-spacing: -0.01em; font-weight: 700; }
p.sub { color: var(--text-muted); font-size: 13.5px; margin: 5px 0 0; line-height: 1.5; }
.icon-btn {
flex: none;
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
cursor: pointer;
box-shadow: var(--shadow);
transition: background-color .15s ease, border-color .15s ease, transform .1s ease;
}
.icon-btn:hover { background: var(--surface-alt); }
.icon-btn:active { transform: scale(0.94); }
.topbar-actions { display: flex; align-items: center; gap: 14px; }
.topnav { display: flex; align-items: center; gap: 14px; font-size: 13.5px; }
.topnav a { color: var(--text-muted); text-decoration: none; }
.topnav a:hover { color: var(--text); }
.inline-form { display: inline; margin: 0; }
button.linklike {
background: none;
border: none;
padding: 0;
margin: 0;
color: var(--text-muted);
font-size: 13.5px;
font-weight: 400;
cursor: pointer;
box-shadow: none;
}
button.linklike:hover { color: var(--text); background: none; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.admin-table th { text-align: left; color: var(--text-muted); font-weight: 600; padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border); }
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
.admin-actions form { margin: 4px 0 0; }
.admin-actions details summary { cursor: pointer; color: var(--text-muted); font-size: 13px; }
.admin-actions input[type="password"] { margin-top: 6px; }
.admin-frame { border-bottom: 1px solid var(--border); padding: 10px 0; }
.admin-frame:last-child { border-bottom: none; }
.admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
h2.card-title, summary.card-title {
font-size: 14.5px;
font-weight: 650;
margin: 0 0 14px;
letter-spacing: 0.01em;
text-transform: uppercase;
color: var(--text-muted);
}
summary.card-title { cursor: pointer; margin-bottom: 0; }
details.card[open] summary.card-title { margin-bottom: 14px; }
details.card .sub { margin-top: 8px; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px 22px 22px;
box-shadow: var(--shadow);
}
.card + .card { margin-top: 20px; }
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; color: var(--text); }
label:first-child { margin-top: 0; }
input, select {
width: 100%;
padding: 9px 10px;
box-sizing: border-box;
margin-top: 5px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
background: var(--bg);
color: var(--text);
font-family: inherit;
transition: border-color .15s ease, box-shadow .15s ease;
}
input:focus, select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
.checkbox-row input { width: auto; margin-top: 0; }
.checkbox-row label { margin-top: 0; font-weight: normal; }
button {
margin-top: 20px;
padding: 10px 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: white;
cursor: pointer;
font-size: 14px;
font-weight: 600;
font-family: inherit;
transition: background-color .15s ease, transform .1s ease;
}
button:hover { background: var(--accent-hover); }
button:active { transform: translateY(1px); }
button.btn-inline {
margin-top: 0;
padding: 3px 10px;
font-size: 12px;
vertical-align: middle;
}
button.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
margin-right: 8px;
}
button.secondary:hover { background: var(--surface-alt); }
.status { margin-top: 16px; padding: 10px 12px; border-radius: 8px; font-size: 14px; }
.status.ok { background: var(--success-bg); color: var(--success-text); }
.status.err { background: var(--danger-bg); color: var(--danger-text); }
.info-box {
margin-bottom: 20px;
padding: 12px 14px;
border-radius: 10px;
font-size: 13px;
background: var(--surface-alt);
color: var(--text-muted);
border: 1px solid var(--border);
}
.info-box.warn { background: var(--warn-bg); color: var(--warn-text); border-color: transparent; }
code {
background: var(--surface-alt);
color: var(--text);
padding: 2px 5px;
border-radius: 4px;
font-size: 0.92em;
}
.info-box code, .info-box.warn code { background: rgba(127, 127, 127, 0.18); color: inherit; }
.thumb { width: 160px; max-width: 100%; border-radius: 8px; display: block; }
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; margin-top: 8px; }
.photo-card {
position: relative; cursor: grab; border-radius: 8px; overflow: hidden; aspect-ratio: 1;
background: var(--surface-alt); border: 1px solid var(--border);
box-shadow: var(--shadow);
transition: box-shadow .15s ease, transform .15s ease;
/* pan-y (not none): lets a normal touch-scroll of the page work
when you touch a card without meaning to drag it. Dragging on
touch instead requires a brief hold first (see the JS below),
which switches this to "none" for the rest of that touch --
only once we're sure it's a deliberate drag, not a scroll. */
touch-action: pan-y;
/* Without this, a press-and-drag gesture also triggers the
browser's native text/content selection (the blue highlight) --
distracting, and on some browsers it fights the pointer-based
drag tracking below closely enough to break it outright. */
user-select: none;
-webkit-user-select: none;
}
.photo-card:hover { box-shadow: var(--shadow-hover); transform: translateY(-1px); }
.photo-card:active { cursor: grabbing; }
.photo-card.drag-armed {
box-shadow: 0 0 0 3px var(--focus-ring) inset;
transform: scale(0.97);
}
/* No transform transition here -- the JS drives transform on every
pointermove to track the finger 1:1, and the .15s base transition
would otherwise make it visibly lag behind a fast swipe. */
.photo-card.dragging {
z-index: 20;
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
transition: box-shadow .15s ease;
pointer-events: none; /* so elementFromPoint hits the card underneath, not this one */
cursor: grabbing;
}
.photo-card.drag-over { outline: 3px solid var(--accent); outline-offset: -3px; }
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.photo-card .badge { position: absolute; top: 6px; left: 6px; background: var(--overlay); color: white; font-size: 10px; padding: 2px 6px; border-radius: 4px; }
.photo-card .remove-btn, .thumb-wrap .remove-btn {
position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; margin: 0; padding: 0;
line-height: 20px; text-align: center; font-size: 13px; border-radius: 50%;
background: var(--overlay); color: white;
}
.photo-card .remove-btn:hover, .thumb-wrap .remove-btn:hover { background: var(--overlay-hover); }
.photo-card .show-next {
position: absolute; bottom: 6px; left: 6px; right: 6px; margin: 0; padding: 5px 0;
font-size: 11px; text-align: center; background: rgba(37, 99, 235, 0.85); border-radius: 4px;
}
.thumb-wrap { position: relative; display: inline-block; }
.layout {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: 20px;
align-items: start;
}
.main-col, .side-col { display: flex; flex-direction: column; }
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
}
/* ------------------------------------------------------------------ */
/* App shell: left sidebar (frame list + account nav) + main content. */
/* Used by app_base.html for all logged-in pages; the narrow auth/ */
/* manage pages keep the simple centered .page layout above. */
/* ------------------------------------------------------------------ */
.shell { display: flex; min-height: 100vh; }
.sidebar {
width: 248px;
flex: none;
display: flex;
flex-direction: column;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 20px 14px 16px;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebar .brand { display: flex; align-items: center; gap: 10px; padding: 0 8px 18px; }
.sidebar .brand h1 { font-size: 17px; }
.sidebar-section {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
padding: 14px 8px 6px;
}
.sidebar a.nav-item, .sidebar .nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border-radius: 8px;
color: var(--text);
text-decoration: none;
font-size: 14px;
transition: background-color .12s ease;
}
.sidebar a.nav-item:hover { background: var(--surface-alt); }
.sidebar a.nav-item.active { background: var(--surface-alt); font-weight: 600; }
.nav-item .frame-dot {
width: 8px; height: 8px; border-radius: 50%; flex: none;
background: var(--text-muted); opacity: 0.5;
}
.nav-item.online .frame-dot { background: #22c55e; opacity: 1; }
.nav-item .nav-sub { margin-left: auto; font-size: 11.5px; color: var(--text-muted); }
.sidebar-footer { margin-top: auto; padding-top: 14px; border-top: 1px solid var(--border); }
.sidebar-footer .nav-item { color: var(--text-muted); font-size: 13.5px; }
.sidebar-footer form { margin: 0; }
.sidebar-footer button.linklike {
display: block; width: 100%; text-align: left; padding: 9px 10px; border-radius: 8px;
}
.sidebar-footer button.linklike:hover { background: var(--surface-alt); }
.main {
flex: 1;
min-width: 0;
padding: 24px 28px 72px;
max-width: 1160px;
}
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.page-head h1 { font-size: 21px; margin: 0; letter-spacing: -0.01em; font-weight: 700; }
.page-head .head-actions { display: flex; align-items: center; gap: 10px; }
.tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 1px solid var(--border);
}
.tabs a {
padding: 9px 14px;
font-size: 14px;
color: var(--text-muted);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color .12s ease;
}
.tabs a:hover { color: var(--text); }
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.control-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 18px;
padding: 10px 14px;
border-radius: 10px;
font-size: 13.5px;
background: var(--warn-bg);
color: var(--warn-text);
}
.control-banner button { margin: 0; }
/* Mobile: sidebar off-canvas, hamburger in a slim top bar. */
.mobile-bar { display: none; }
.sidebar-backdrop { display: none; }
@media (max-width: 860px) {
.mobile-bar {
display: flex;
align-items: center;
gap: 12px;
position: sticky;
top: 0;
z-index: 30;
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 10px 14px;
}
.mobile-bar .brand { display: flex; align-items: center; gap: 8px; }
.mobile-bar h1 { font-size: 16px; margin: 0; }
.mobile-bar .icon-btn { width: 34px; height: 34px; box-shadow: none; }
.mobile-bar .spacer { flex: 1; }
.shell { display: block; }
.sidebar {
position: fixed;
left: 0; top: 0; bottom: 0;
z-index: 50;
height: 100vh;
transform: translateX(-105%);
transition: transform .2s ease;
box-shadow: var(--shadow-hover);
}
.shell.sidebar-open .sidebar { transform: translateX(0); }
.sidebar-backdrop {
position: fixed; inset: 0; z-index: 40;
background: var(--overlay);
opacity: 0; pointer-events: none;
transition: opacity .2s ease;
}
.shell.sidebar-open .sidebar-backdrop { display: block; opacity: 1; pointer-events: auto; }
.sidebar-backdrop { display: block; }
.main { padding: 18px 14px 64px; }
}
+5
View File
@@ -0,0 +1,5 @@
<nav class="tabs">
<a href="/frames/{{ frame.id }}" class="{% if active_tab == 'photos' %}active{% endif %}">Photos</a>
<a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</a>
<a href="/frames/{{ frame.id }}/stats" class="{% if active_tab == 'stats' %}active{% endif %}">Stats</a>
</nav>
+3 -4
View File
@@ -1,8 +1,7 @@
{% extends "base.html" %}
{% extends "app_base.html" %}
{% block subtitle %}
<p class="sub">Administration</p>
{% endblock %}
{% block title %}Admin{% endblock %}
{% block page_title %}Administration{% endblock %}
{% block content %}
{% if notice %}<div class="status ok">{{ notice }}</div>{% endif %}
+80
View File
@@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}ESPresso Frame{% endblock %}</title>
{% if csrf_token %}<meta name="csrf-token" content="{{ csrf_token }}">{% endif %}
<script>
// Applied before first paint so there's no flash of the wrong theme.
(function () {
try {
var stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') {
document.documentElement.setAttribute('data-theme', stored);
}
} catch (e) { /* localStorage unavailable (private mode, etc.) */ }
})();
</script>
<link rel="stylesheet" href="/static/theme.css">
{% block extra_head %}{% endblock %}
</head>
<body>
<div class="shell">
<div class="sidebar-backdrop"></div>
<aside class="sidebar">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<h1>ESPresso Frame</h1>
</div>
<div class="sidebar-section">Frames</div>
{% for f in sidebar_frames %}
<a class="nav-item {% if active_frame and active_frame.id == f.id %}active{% endif %} {% if f.recently_seen %}online{% endif %}"
href="/frames/{{ f.id }}">
<span class="frame-dot" aria-hidden="true"></span>
{{ f.name or ("Frame " ~ f.id) }}
{% if f.owner_user_id is none %}<span class="nav-sub">unclaimed</span>{% endif %}
</a>
{% endfor %}
{% if not sidebar_frames %}
<p class="sub" style="padding: 0 10px;">No frames yet.</p>
{% endif %}
<div class="sidebar-footer">
<a class="nav-item {% if active_nav == 'settings' %}active{% endif %}" href="/settings">Settings</a>
{% if user.is_admin %}
<a class="nav-item {% if active_nav == 'admin' %}active{% endif %}" href="/admin">Admin</a>
{% endif %}
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="linklike">Log out</button>
</form>
</div>
</aside>
<div class="main-wrap" style="flex: 1; min-width: 0;">
<div class="mobile-bar">
<button type="button" id="sidebar-toggle" class="icon-btn" title="Menu" aria-label="Open menu"></button>
<div class="brand"><span aria-hidden="true"></span><h1>ESPresso Frame</h1></div>
<div class="spacer"></div>
</div>
<main class="main">
<div class="page-head">
<h1>{% block page_title %}{% endblock %}</h1>
<div class="head-actions">
{% block head_actions %}{% endblock %}
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
</div>
</div>
{% block tabs %}{% endblock %}
{% block content %}{% endblock %}
</main>
</div>
</div>
<script src="/static/common.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+4 -386
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}ESPresso Frame{% endblock %}</title>
{% if csrf_token %}<meta name="csrf-token" content="{{ csrf_token }}">{% endif %}
<script>
// Applied before first paint so there's no flash of the wrong theme.
(function () {
@@ -15,327 +16,7 @@
} catch (e) { /* localStorage unavailable (private mode, etc.) */ }
})();
</script>
<style>
:root {
--bg: #f5f6f8;
--surface: #ffffff;
--surface-alt: #f0f1f4;
--border: #e3e5e9;
--text: #16181d;
--text-muted: #666d7a;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--focus-ring: rgba(37, 99, 235, 0.35);
--success-bg: #dcfce7;
--success-text: #166534;
--danger-bg: #fee2e2;
--danger-text: #991b1b;
--warn-bg: #fef9c3;
--warn-text: #854d0e;
--shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06);
--shadow-hover: 0 8px 20px rgba(16, 24, 40, 0.10);
--overlay: rgba(0, 0, 0, 0.55);
--overlay-hover: rgba(153, 27, 27, 0.85);
--color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0d1016;
--surface: #161a22;
--surface-alt: #1d222b;
--border: #2a2f3a;
--text: #e8eaed;
--text-muted: #9aa2b1;
--accent: #4c8dff;
--accent-hover: #6ea1ff;
--focus-ring: rgba(76, 141, 255, 0.4);
--success-bg: rgba(34, 197, 94, 0.16);
--success-text: #4ade80;
--danger-bg: rgba(239, 68, 68, 0.16);
--danger-text: #f87171;
--warn-bg: rgba(234, 179, 8, 0.16);
--warn-text: #fbbf24;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
--overlay: rgba(0, 0, 0, 0.55);
--overlay-hover: rgba(248, 113, 113, 0.35);
--color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0d1016;
--surface: #161a22;
--surface-alt: #1d222b;
--border: #2a2f3a;
--text: #e8eaed;
--text-muted: #9aa2b1;
--accent: #4c8dff;
--accent-hover: #6ea1ff;
--focus-ring: rgba(76, 141, 255, 0.4);
--success-bg: rgba(34, 197, 94, 0.16);
--success-text: #4ade80;
--danger-bg: rgba(239, 68, 68, 0.16);
--danger-text: #f87171;
--warn-bg: rgba(234, 179, 8, 0.16);
--warn-text: #fbbf24;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.35);
--shadow-hover: 0 10px 24px rgba(0, 0, 0, 0.45);
--overlay: rgba(0, 0, 0, 0.55);
--overlay-hover: rgba(248, 113, 113, 0.35);
--color-scheme: dark;
}
* { box-sizing: border-box; }
html {
color-scheme: var(--color-scheme);
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
transition: background-color .15s ease, color .15s ease;
}
.page {
max-width: 1080px;
margin: 0 auto;
padding: 28px 20px 72px;
}
.page.page-narrow {
max-width: 420px;
padding-top: 88px;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.brand { display: flex; align-items: flex-start; gap: 12px; }
.brand-mark {
font-size: 26px;
line-height: 1;
margin-top: 2px;
}
h1 { font-size: 21px; margin: 0; letter-spacing: -0.01em; font-weight: 700; }
p.sub { color: var(--text-muted); font-size: 13.5px; margin: 5px 0 0; line-height: 1.5; }
.icon-btn {
flex: none;
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
cursor: pointer;
box-shadow: var(--shadow);
transition: background-color .15s ease, border-color .15s ease, transform .1s ease;
}
.icon-btn:hover { background: var(--surface-alt); }
.icon-btn:active { transform: scale(0.94); }
.topbar-actions { display: flex; align-items: center; gap: 14px; }
.topnav { display: flex; align-items: center; gap: 14px; font-size: 13.5px; }
.topnav a { color: var(--text-muted); text-decoration: none; }
.topnav a:hover { color: var(--text); }
.inline-form { display: inline; margin: 0; }
button.linklike {
background: none;
border: none;
padding: 0;
margin: 0;
color: var(--text-muted);
font-size: 13.5px;
font-weight: 400;
cursor: pointer;
box-shadow: none;
}
button.linklike:hover { color: var(--text); background: none; }
.admin-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.admin-table th { text-align: left; color: var(--text-muted); font-weight: 600; padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border); }
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
.admin-actions form { margin: 4px 0 0; }
.admin-actions details summary { cursor: pointer; color: var(--text-muted); font-size: 13px; }
.admin-actions input[type="password"] { margin-top: 6px; }
.admin-frame { border-bottom: 1px solid var(--border); padding: 10px 0; }
.admin-frame:last-child { border-bottom: none; }
.admin-inline-form { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
.admin-inline-form input[type="text"] { margin-top: 0; flex: 1; }
h2.card-title, summary.card-title {
font-size: 14.5px;
font-weight: 650;
margin: 0 0 14px;
letter-spacing: 0.01em;
text-transform: uppercase;
color: var(--text-muted);
}
summary.card-title { cursor: pointer; margin-bottom: 0; }
details.card[open] summary.card-title { margin-bottom: 14px; }
details.card .sub { margin-top: 8px; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px 22px 22px;
box-shadow: var(--shadow);
}
.card + .card { margin-top: 20px; }
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; color: var(--text); }
label:first-child { margin-top: 0; }
input, select {
width: 100%;
padding: 9px 10px;
box-sizing: border-box;
margin-top: 5px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
background: var(--bg);
color: var(--text);
font-family: inherit;
transition: border-color .15s ease, box-shadow .15s ease;
}
input:focus, select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
.checkbox-row input { width: auto; margin-top: 0; }
.checkbox-row label { margin-top: 0; font-weight: normal; }
button {
margin-top: 20px;
padding: 10px 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: white;
cursor: pointer;
font-size: 14px;
font-weight: 600;
font-family: inherit;
transition: background-color .15s ease, transform .1s ease;
}
button:hover { background: var(--accent-hover); }
button:active { transform: translateY(1px); }
button.btn-inline {
margin-top: 0;
padding: 3px 10px;
font-size: 12px;
vertical-align: middle;
}
button.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
margin-right: 8px;
}
button.secondary:hover { background: var(--surface-alt); }
.status { margin-top: 16px; padding: 10px 12px; border-radius: 8px; font-size: 14px; }
.status.ok { background: var(--success-bg); color: var(--success-text); }
.status.err { background: var(--danger-bg); color: var(--danger-text); }
.info-box {
margin-bottom: 20px;
padding: 12px 14px;
border-radius: 10px;
font-size: 13px;
background: var(--surface-alt);
color: var(--text-muted);
border: 1px solid var(--border);
}
.info-box.warn { background: var(--warn-bg); color: var(--warn-text); border-color: transparent; }
code {
background: var(--surface-alt);
color: var(--text);
padding: 2px 5px;
border-radius: 4px;
font-size: 0.92em;
}
.info-box code, .info-box.warn code { background: rgba(127, 127, 127, 0.18); color: inherit; }
.thumb { width: 160px; max-width: 100%; border-radius: 8px; display: block; }
.photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; margin-top: 8px; }
.photo-card {
position: relative; cursor: grab; border-radius: 8px; overflow: hidden; aspect-ratio: 1;
background: var(--surface-alt); border: 1px solid var(--border);
box-shadow: var(--shadow);
transition: box-shadow .15s ease, transform .15s ease;
/* pan-y (not none): lets a normal touch-scroll of the page work
when you touch a card without meaning to drag it. Dragging on
touch instead requires a brief hold first (see the JS below),
which switches this to "none" for the rest of that touch --
only once we're sure it's a deliberate drag, not a scroll. */
touch-action: pan-y;
/* Without this, a press-and-drag gesture also triggers the
browser's native text/content selection (the blue highlight) --
distracting, and on some browsers it fights the pointer-based
drag tracking below closely enough to break it outright. */
user-select: none;
-webkit-user-select: none;
}
.photo-card:hover { box-shadow: var(--shadow-hover); transform: translateY(-1px); }
.photo-card:active { cursor: grabbing; }
.photo-card.drag-armed {
box-shadow: 0 0 0 3px var(--focus-ring) inset;
transform: scale(0.97);
}
/* No transform transition here -- the JS drives transform on every
pointermove to track the finger 1:1, and the .15s base transition
would otherwise make it visibly lag behind a fast swipe. */
.photo-card.dragging {
z-index: 20;
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
transition: box-shadow .15s ease;
pointer-events: none; /* so elementFromPoint hits the card underneath, not this one */
cursor: grabbing;
}
.photo-card.drag-over { outline: 3px solid var(--accent); outline-offset: -3px; }
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.photo-card .badge { position: absolute; top: 6px; left: 6px; background: var(--overlay); color: white; font-size: 10px; padding: 2px 6px; border-radius: 4px; }
.photo-card .remove-btn, .thumb-wrap .remove-btn {
position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; margin: 0; padding: 0;
line-height: 20px; text-align: center; font-size: 13px; border-radius: 50%;
background: var(--overlay); color: white;
}
.photo-card .remove-btn:hover, .thumb-wrap .remove-btn:hover { background: var(--overlay-hover); }
.photo-card .show-next {
position: absolute; bottom: 6px; left: 6px; right: 6px; margin: 0; padding: 5px 0;
font-size: 11px; text-align: center; background: rgba(37, 99, 235, 0.85); border-radius: 4px;
}
.thumb-wrap { position: relative; display: inline-block; }
.layout {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
gap: 20px;
align-items: start;
}
.main-col, .side-col { display: flex; flex-direction: column; }
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
}
</style>
<link rel="stylesheet" href="/static/theme.css">
{% block extra_head %}{% endblock %}
</head>
<body>
@@ -348,76 +29,13 @@
{% block subtitle %}{% endblock %}
</div>
</div>
<div class="topbar-actions">
{% if user %}
<nav class="topnav">
<a href="/">Home</a>
<a href="/settings">Settings</a>
{% if user.is_admin %}<a href="/admin">Admin</a>{% endif %}
<form method="post" action="/logout" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="linklike">Log out</button>
</form>
</nav>
{% endif %}
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
</div>
<button type="button" id="theme-toggle" class="icon-btn" title="Toggle dark mode" aria-label="Toggle dark mode">🌓</button>
</header>
{% block content %}{% endblock %}
</div>
{% if csrf_token %}
<script>
// Session-cookie auth needs CSRF proof on mutating requests. Rather
// than touching every fetch() call site in the page scripts, wrap
// fetch once: same-origin non-GET requests automatically carry the
// per-session token. (Legacy shared-token access renders without a
// csrf_token, so this block doesn't exist there at all.)
(function () {
var CSRF = {{ csrf_token | tojson }};
var origFetch = window.fetch;
window.fetch = function (input, init) {
init = init || {};
var method = (init.method || (input && input.method) || 'GET').toUpperCase();
var url = typeof input === 'string' ? input : (input && input.url) || '';
var sameOrigin = url.indexOf('://') === -1 || url.indexOf(location.origin) === 0;
if (sameOrigin && method !== 'GET' && method !== 'HEAD') {
init.headers = new Headers(init.headers || (input && input.headers) || {});
init.headers.set('X-CSRF-Token', CSRF);
}
return origFetch.call(this, input, init);
};
})();
</script>
{% endif %}
<script>
// Shared theme toggle: explicit choice wins over the OS preference and
// is remembered; with no explicit choice, the CSS above falls back to
// prefers-color-scheme on its own.
(function () {
var btn = document.getElementById('theme-toggle');
if (!btn) return;
function currentTheme() {
var stored = null;
try { stored = localStorage.getItem('theme'); } catch (e) { /* ignore */ }
if (stored === 'light' || stored === 'dark') return stored;
return (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
}
function apply(theme) {
document.documentElement.setAttribute('data-theme', theme);
try { localStorage.setItem('theme', theme); } catch (e) { /* ignore */ }
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: theme } }));
}
btn.addEventListener('click', function () {
apply(currentTheme() === 'dark' ? 'light' : 'dark');
});
})();
</script>
<script src="/static/common.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+111
View File
@@ -0,0 +1,111 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %}
<div id="control-banner" class="control-banner" style="display: none;">
<span id="control-holder"></span>
<button type="button" id="take-control" class="btn-inline">Take control</button>
</div>
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Display settings</h2>
<form id="config-form">
<label>Frame name
<input type="text" id="frame_name" maxlength="64" value="{{ frame.name }}">
</label>
<label>Order
<select id="order">
<option value="sequential" {% if frame.order == "sequential" %}selected{% endif %}>Sequential</option>
<option value="shuffle" {% if frame.order == "shuffle" %}selected{% endif %}>Shuffle</option>
</select>
</label>
<label>Orientation
<select id="orientation">
<option value="landscape" {% if frame.orientation == "landscape" %}selected{% endif %}>Landscape</option>
<option value="portrait" {% if frame.orientation == "portrait" %}selected{% endif %}>Portrait</option>
<option value="landscape_flipped" {% if frame.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
<option value="portrait_flipped" {% if frame.orientation == "portrait_flipped" %}selected{% endif %}>Portrait (flipped)</option>
</select>
</label>
<label>Refresh interval (minutes)
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
value="{{ (frame.refresh_interval_s // 60) or 60 }}" required>
</label>
<div class="checkbox-row">
<input type="checkbox" id="smart_crop_faces" {% if frame.smart_crop_faces %}checked{% endif %}>
<label for="smart_crop_faces">Center faces in crop</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="quiet_hours_enabled" {% if frame.quiet_hours_enabled %}checked{% endif %}>
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
</div>
<label>Quiet hours start
<input type="time" id="quiet_hours_start" value="{{ frame.quiet_hours_start }}">
</label>
<label>Quiet hours end
<input type="time" id="quiet_hours_end" value="{{ frame.quiet_hours_end }}">
</label>
<label>Timezone
<select id="timezone">
{% for tz in timezones %}
<option value="{{ tz }}" {% if tz == frame.timezone %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
</label>
<p class="sub" style="margin-top: 8px;">Quiet hours times are
interpreted in this timezone. The device may still wake once right
at the start of quiet hours -- it can't know ahead of time -- but
goes right back to sleep until they end.</p>
<button type="submit">Save</button>
</form>
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Firmware update</h2>
<p class="sub" id="firmware-board">
{% if frame.device_board_variant %}Detected board: {{ frame.device_board_variant }}
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
</p>
<p class="sub" id="firmware-available">
{% if frame.firmware_available_version %}Uploaded: v{{ frame.firmware_available_version }} -- the frame
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
</p>
<input type="file" id="firmware-file" accept=".bin">
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
<div id="firmware-repo-display" style="margin-top: 16px; {% if not frame.firmware_update_repo_url %}display: none;{% endif %}">
<p class="sub">Gitea repo: <code id="firmware-repo-text">{{ frame.firmware_update_repo_url }}</code>
<button type="button" class="secondary btn-inline" id="firmware-repo-edit-btn">Edit</button>
</p>
</div>
<label id="firmware-repo-edit" style="{% if frame.firmware_update_repo_url %}display: none;{% endif %}">Gitea repo URL
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
value="{{ frame.firmware_update_repo_url }}">
</label>
<div class="checkbox-row">
<input type="checkbox" id="firmware_auto_update" {% if frame.firmware_auto_update %}checked{% endif %}>
<label for="firmware_auto_update">Automatically apply updates</label>
</div>
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
</section>
</div>
</div>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/frame_config.js"></script>
{% endblock %}
+60
View File
@@ -0,0 +1,60 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %}
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %}
<div id="control-banner" class="control-banner" style="display: none;">
<span id="control-holder"></span>
<button type="button" id="take-control" class="btn-inline">Take control</button>
</div>
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Album</h2>
<form id="photos-form">
<label>Album
<select id="album_id">
{% if frame.album_id %}<option value="{{ frame.album_id }}" selected>(current selection -- Load Albums to change)</option>{% endif %}
</select>
</label>
<label>Upcoming photos to show
<select id="queue_target_len">
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
<option value="{{ n }}" {% if frame.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
<button type="button" class="secondary" id="load-albums">Load Albums</button>
<button type="submit">Save</button>
</form>
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
</section>
</div>
</div>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Upcoming</h2>
<p class="sub">Drag a photo to reorder (on touch, hold briefly first so a
normal scroll still works), "Show next" to jump it to the front, or
the &times; to remove it from rotation entirely.</p>
<div id="upcoming-grid" class="photo-grid"></div>
</section>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/queue.js"></script>
<script src="/static/frame_photos.js"></script>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %}
{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% endblock %}
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
{% block content %}
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Battery history</h2>
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
</section>
<section class="card">
<h2 class="card-title">Lifetime stats</h2>
<div id="stats-box"><p class="sub">Loading...</p></div>
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Device</h2>
<div id="device-status"><p class="sub">Loading...</p></div>
</section>
</div>
</div>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/battery_chart.js"></script>
<script src="/static/frame_stats.js"></script>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "app_base.html" %}
{% block title %}ESPresso Frame{% endblock %}
{% block page_title %}Welcome{% endblock %}
{% block content %}
<section class="card">
<h2 class="card-title">No frames yet</h2>
<p class="sub">Set up a frame and it'll appear in the sidebar:</p>
<p class="sub">1. Power the frame on -- it opens a WiFi network named
<code>ESPRESSO_XXXXXX</code> and shows join instructions on its panel.</p>
<p class="sub">2. Join that network and fill in your WiFi details plus this
server's address.</p>
<p class="sub">3. Your browser lands on this server's claim page and links
the frame to your account automatically.</p>
<p class="sub" style="margin-top: 12px;">Already provisioned? Ask whoever
claimed it (or an admin) to link your account, or scan the frame's
on-panel manage QR.</p>
</section>
{% endblock %}
-828
View File
@@ -1,828 +0,0 @@
{% extends "base.html" %}
{% block subtitle %}
<p class="sub">Point your frame's "Tools Server" field at this server's <code>host:port</code>.</p>
{% endblock %}
{% block content %}
{% if immich_url %}
<div class="info-box">Immich: <code>{{ immich_url }}</code> (API key configured). Set via
<code>IMMICH_URL</code>/<code>IMMICH_API_KEY</code> in <code>docker-compose.yml</code> -- see
<code>docker-compose.yml.example</code>.</div>
{% else %}
<div class="info-box warn">Immich isn't configured yet. Set <code>IMMICH_URL</code> and
<code>IMMICH_API_KEY</code> in <code>docker-compose.yml</code> (copy
<code>docker-compose.yml.example</code>) and restart the server.</div>
{% endif %}
<div class="layout">
<div class="main-col">
<section class="card config-panel">
<h2 class="card-title">Settings</h2>
<form id="config-form">
<label>Album
<select id="album_id">
{% if cfg.album_id %}<option value="{{ cfg.album_id }}" selected>(current selection -- reload to rename)</option>{% endif %}
</select>
</label>
<label>Order
<select id="order">
<option value="sequential" {% if cfg.order == "sequential" %}selected{% endif %}>Sequential</option>
<option value="shuffle" {% if cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
</select>
</label>
<label>Orientation
<select id="orientation">
<option value="landscape" {% if cfg.orientation == "landscape" %}selected{% endif %}>Landscape</option>
<option value="portrait" {% if cfg.orientation == "portrait" %}selected{% endif %}>Portrait</option>
<option value="landscape_flipped" {% if cfg.orientation == "landscape_flipped" %}selected{% endif %}>Landscape (flipped)</option>
<option value="portrait_flipped" {% if cfg.orientation == "portrait_flipped" %}selected{% endif %}>Portrait (flipped)</option>
</select>
</label>
<label>Refresh interval (minutes)
<input type="number" id="refresh_interval_minutes" min="1" max="1440"
value="{{ (cfg.refresh_interval_s // 60) or 60 }}" required>
</label>
<div class="checkbox-row">
<input type="checkbox" id="smart_crop_faces" {% if cfg.smart_crop_faces %}checked{% endif %}>
<label for="smart_crop_faces">Center faces in crop</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="quiet_hours_enabled" {% if cfg.quiet_hours_enabled %}checked{% endif %}>
<label for="quiet_hours_enabled">Quiet hours (don't wake overnight)</label>
</div>
<label>Quiet hours start
<input type="time" id="quiet_hours_start" value="{{ cfg.quiet_hours_start }}">
</label>
<label>Quiet hours end
<input type="time" id="quiet_hours_end" value="{{ cfg.quiet_hours_end }}">
</label>
<label>Timezone
<select id="timezone">
{% for tz in timezones %}
<option value="{{ tz }}" {% if tz == cfg.timezone %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
</label>
<p class="sub" style="margin-top: 8px;">Quiet hours times above are
interpreted in this timezone. The device may still wake once right
at the start of quiet hours -- it can't know ahead of time -- but
goes right back to sleep until they end.</p>
<label>Upcoming photos to show
<select id="queue_target_len">
{% for n in [5, 10, 15, 20, 25, 30, 40, 50] %}
<option value="{{ n }}" {% if cfg.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
<button type="button" class="secondary" id="load-albums">Load Albums</button>
<button type="submit">Save</button>
</form>
<div id="result"></div>
</section>
<details class="card">
<summary class="card-title">Stats</summary>
<div id="stats-box"><p class="sub">Loading...</p></div>
</details>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
</section>
<section class="card">
<h2 class="card-title">Device</h2>
<div id="device-status"><p class="sub">Loading...</p></div>
</section>
<section class="card">
<h2 class="card-title">Battery history</h2>
<div id="battery-chart-wrap"><p class="sub">Loading...</p></div>
</section>
<section class="card">
<h2 class="card-title">Firmware update</h2>
<p class="sub" id="firmware-board">
{% if cfg.device_board_variant %}Detected board: {{ cfg.device_board_variant }}
{% else %}Board not detected yet -- the frame reports it on its next check-in.{% endif %}
</p>
<p class="sub" id="firmware-available">
{% if cfg.firmware_available_version %}Uploaded: v{{ cfg.firmware_available_version }} -- the frame
updates itself on its next wake if it's running something else.{% else %}No firmware uploaded yet.{% endif %}
</p>
<input type="file" id="firmware-file" accept=".bin">
<button type="button" class="secondary" id="firmware-upload">Upload firmware</button>
<div id="firmware-repo-display" style="margin-top: 16px; {% if not cfg.firmware_update_repo_url %}display: none;{% endif %}">
<p class="sub">Gitea repo: <code id="firmware-repo-text">{{ cfg.firmware_update_repo_url }}</code>
<button type="button" class="secondary btn-inline" id="firmware-repo-edit-btn">Edit</button>
</p>
</div>
<label id="firmware-repo-edit" style="{% if cfg.firmware_update_repo_url %}display: none;{% endif %}">Gitea repo URL
<input type="text" id="firmware_update_repo_url" placeholder="https://git.example.com/owner/repo"
value="{{ cfg.firmware_update_repo_url }}">
</label>
<div class="checkbox-row">
<input type="checkbox" id="firmware_auto_update" {% if cfg.firmware_auto_update %}checked{% endif %}>
<label for="firmware_auto_update">Automatically apply updates</label>
</div>
<button type="button" class="secondary" id="firmware-settings-save">Save</button>
<p class="sub" id="firmware-gitea-status" style="display: none; margin-top: 10px;"></p>
<button type="button" class="secondary" id="firmware-check-now">Check now</button>
<button type="button" id="firmware-update-btn" style="display: none;">Update frame</button>
</section>
</div>
</div>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Upcoming</h2>
<p class="sub">Drag a photo to reorder (on touch, hold briefly first so a
normal scroll still works), "Show next" to jump it to the front, or
the &times; to remove it from rotation entirely.</p>
<div id="upcoming-grid" class="photo-grid"></div>
</section>
{% endblock %}
{% block scripts %}
<script>
const resultEl = document.getElementById('result');
function showStatus(ok, message) {
resultEl.innerHTML = `<div class="status ${ok ? 'ok' : 'err'}">${message}</div>`;
}
async function saveConfig() {
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
const body = new URLSearchParams({
album_id: document.getElementById('album_id').value || '',
order: document.getElementById('order').value,
orientation: document.getElementById('orientation').value,
refresh_interval_s: String(minutes * 60),
smart_crop_faces: String(document.getElementById('smart_crop_faces').checked),
queue_target_len: document.getElementById('queue_target_len').value,
quiet_hours_enabled: String(document.getElementById('quiet_hours_enabled').checked),
quiet_hours_start: document.getElementById('quiet_hours_start').value || '22:00',
quiet_hours_end: document.getElementById('quiet_hours_end').value || '07:00',
timezone: document.getElementById('timezone').value || 'UTC',
firmware_update_repo_url: document.getElementById('firmware_update_repo_url').value || '',
firmware_auto_update: String(document.getElementById('firmware_auto_update').checked),
});
const resp = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!resp.ok) {
throw new Error(await resp.text());
}
}
document.getElementById('load-albums').addEventListener('click', async () => {
try {
await saveConfig();
const resp = await fetch('/api/albums');
if (!resp.ok) {
throw new Error(await resp.text());
}
const albums = await resp.json();
const select = document.getElementById('album_id');
select.innerHTML = '';
for (const a of albums) {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = `${a.name} (${a.count})`;
select.appendChild(opt);
}
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('config-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await saveConfig();
showStatus(true, 'Saved.');
loadQueue();
} catch (e) {
showStatus(false, e.message);
}
});
function showRepoDisplayMode(url) {
document.getElementById('firmware-repo-text').textContent = url;
document.getElementById('firmware-repo-display').style.display = url ? 'block' : 'none';
document.getElementById('firmware-repo-edit').style.display = url ? 'none' : 'block';
}
document.getElementById('firmware-repo-edit-btn').addEventListener('click', () => {
document.getElementById('firmware-repo-display').style.display = 'none';
document.getElementById('firmware-repo-edit').style.display = 'block';
document.getElementById('firmware_update_repo_url').focus();
});
document.getElementById('firmware-settings-save').addEventListener('click', async () => {
try {
await saveConfig();
showStatus(true, 'Saved.');
showRepoDisplayMode(document.getElementById('firmware_update_repo_url').value.trim());
loadFirmwareCheck();
} catch (e) {
showStatus(false, e.message);
}
});
let upcomingItems = [];
// Pointer Events (not the native HTML5 Drag-and-Drop API) so the same
// code drives mouse, touch, and pen -- native drag-and-drop is
// mouse-only by spec and never fires at all on phones/tablets.
//
// On touch specifically, a card only "arms" for dragging after a
// brief hold (DRAG_HOLD_MS) with the finger roughly stationary --
// .photo-card's touch-action stays "pan-y" (native scroll allowed)
// the whole time up to that point, so a normal touch-and-swipe to
// scroll the page still works even though it starts on a card. Once
// armed, touch-action switches to "none" for the rest of that touch
// so drag tracking gets every pointermove reliably. Mouse skips the
// hold entirely (no scroll-vs-drag ambiguity with a mouse).
let dragState = null; // { pointerId, fromIndex, toIndex, moved, armed, holdTimer }
const DRAG_START_THRESHOLD_PX = 6; // ignore tiny jitter once armed, before committing to a drag
const DRAG_HOLD_MS = 250;
const HOLD_CANCEL_THRESHOLD_PX = 10; // movement before the hold timer fires -> treat as a scroll, not a drag
function vibrate(ms) {
// Chrome/Android only -- iOS Safari has no Vibration API. Wrapped so
// it's a harmless no-op everywhere else rather than needing a
// feature check at every call site.
try { navigator.vibrate?.(ms); } catch (e) { /* ignore */ }
}
function clearDragOverStyling() {
document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over'));
}
function endDrag(card) {
if (dragState && dragState.holdTimer) {
clearTimeout(dragState.holdTimer);
}
card.style.touchAction = '';
card.style.transform = '';
card.classList.remove('dragging', 'drag-armed');
clearDragOverStyling();
if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) {
vibrate(15);
moveItem(dragState.fromIndex, dragState.toIndex);
}
dragState = null;
}
function renderUpcoming(items) {
upcomingItems = items;
const grid = document.getElementById('upcoming-grid');
grid.innerHTML = '';
items.forEach((item, i) => {
const card = document.createElement('div');
card.className = 'photo-card';
card.dataset.index = String(i);
const img = document.createElement('img');
img.src = item.thumbnail_url;
img.alt = '';
card.appendChild(img);
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = String(i + 1);
card.appendChild(badge);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.title = 'Remove from rotation';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
removeAsset(item.id);
});
card.appendChild(removeBtn);
const nextBtn = document.createElement('button');
nextBtn.type = 'button';
nextBtn.className = 'show-next';
nextBtn.textContent = 'Show next';
nextBtn.addEventListener('click', (e) => {
e.stopPropagation();
showNext(i);
});
card.appendChild(nextBtn);
card.addEventListener('pointerdown', (e) => {
if (e.target.closest('.show-next') || e.target.closest('.remove-btn')) {
return; // let the button's own click handler run, don't start a drag
}
if (e.pointerType === 'mouse' && e.button !== 0) {
return; // left button only
}
dragState = {
pointerId: e.pointerId, fromIndex: i, toIndex: undefined, moved: false,
armed: e.pointerType !== 'touch', startX: e.clientX, startY: e.clientY, holdTimer: null,
};
if (e.pointerType === 'touch') {
dragState.holdTimer = setTimeout(() => {
if (dragState && dragState.pointerId === e.pointerId && !dragState.armed) {
dragState.armed = true;
card.classList.add('drag-armed');
card.style.touchAction = 'none';
vibrate(10);
try { card.setPointerCapture(e.pointerId); } catch (err) { /* pointer may have already left */ }
}
}, DRAG_HOLD_MS);
} else {
card.setPointerCapture(e.pointerId);
}
});
card.addEventListener('pointermove', (e) => {
if (!dragState || dragState.pointerId !== e.pointerId) {
return;
}
if (!dragState.armed) {
// Still deciding whether this is a hold-to-drag or a scroll --
// moving this much before the hold timer fires means scroll;
// bail out and let the browser's native pan-y handle it.
const dx0 = e.clientX - dragState.startX;
const dy0 = e.clientY - dragState.startY;
if (Math.hypot(dx0, dy0) > HOLD_CANCEL_THRESHOLD_PX) {
clearTimeout(dragState.holdTimer);
dragState = null;
}
return;
}
const dx = e.clientX - dragState.startX;
const dy = e.clientY - dragState.startY;
if (!dragState.moved) {
if (Math.hypot(dx, dy) < DRAG_START_THRESHOLD_PX) {
return;
}
dragState.moved = true;
card.classList.remove('drag-armed');
card.classList.add('dragging');
}
// Follows the finger 1:1 -- the actual "pick it up and carry it"
// feedback that was missing before (the card used to just fade
// in place while a static outline highlighted the drop target).
card.style.transform = `translate(${dx}px, ${dy}px) scale(1.06)`;
const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card');
clearDragOverStyling();
if (overCard && overCard !== card) {
overCard.classList.add('drag-over');
dragState.toIndex = Number(overCard.dataset.index);
} else {
dragState.toIndex = undefined;
}
});
card.addEventListener('pointerup', (e) => {
if (dragState && dragState.pointerId === e.pointerId) {
endDrag(card);
}
});
card.addEventListener('pointercancel', (e) => {
if (dragState && dragState.pointerId === e.pointerId) {
endDrag(card);
}
});
grid.appendChild(card);
});
}
function moveItem(fromIndex, toIndex) {
const items = upcomingItems.slice();
const [moved] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, moved);
renderUpcoming(items);
persistOrder(items);
}
async function showNext(index) {
const assetId = upcomingItems[index].id;
try {
const resp = await fetch('/api/queue/promote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await resp.text());
}
} catch (e) {
showStatus(false, e.message);
}
loadQueue(); // always refetch the authoritative order rather than guessing locally
}
async function removeAsset(assetId) {
if (!confirm('Remove this photo from the rotation? It stays in Immich -- this frame just won\'t show it again.')) {
return;
}
try {
const resp = await fetch('/api/queue/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_id: assetId }),
});
if (!resp.ok) {
throw new Error(await resp.text());
}
} catch (e) {
showStatus(false, e.message);
}
loadQueue();
}
async function persistOrder(items) {
try {
const resp = await fetch('/api/queue/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ queue: items.map((item) => item.id) }),
});
if (!resp.ok) {
throw new Error(await resp.text());
}
} catch (e) {
showStatus(false, e.message);
loadQueue();
}
}
function formatDuration(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function renderDeviceStatus(device) {
const el = document.getElementById('device-status');
el.innerHTML = '';
if (!device || !device.last_seen) {
el.innerHTML = '<p class="sub">The frame hasn\'t checked in yet.</p>';
return;
}
const now = Date.now() / 1000;
const rows = [];
const ago = formatDuration(Math.max(0, now - device.last_seen));
rows.push([`Last seen`, `${ago} ago`, device.overdue]);
if (device.firmware_version) {
let fw = `v${device.firmware_version}`;
if (device.firmware_available && device.firmware_available !== device.firmware_version) {
fw += ` (v${device.firmware_available} waiting)`;
}
rows.push(['Firmware', fw, false]);
}
if (device.battery) {
rows.push(['Battery', `${device.battery.percent}%`, false]);
}
if (device.on_battery_since) {
rows.push(['On battery for', formatDuration(now - device.on_battery_since), false]);
}
if (device.battery_estimate_s !== null && device.battery_estimate_s !== undefined) {
rows.push(['Est. remaining', `~${formatDuration(device.battery_estimate_s)}`, false]);
}
for (const [label, value, alert] of rows) {
const p = document.createElement('p');
p.className = 'sub';
if (alert) {
p.style.color = 'var(--danger-text)';
p.style.fontWeight = '600';
}
p.textContent = `${label}: ${value}`;
el.appendChild(p);
}
}
document.getElementById('firmware-upload').addEventListener('click', async () => {
const input = document.getElementById('firmware-file');
if (!input.files.length) {
showStatus(false, 'Pick a firmware .bin first.');
return;
}
const form = new FormData();
form.append('file', input.files[0]);
try {
const resp = await fetch('/api/firmware', { method: 'POST', body: form });
if (!resp.ok) {
throw new Error(await resp.text());
}
const result = await resp.json();
document.getElementById('firmware-available').textContent =
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
showStatus(true, `Firmware v${result.version} uploaded (${result.size} bytes).`);
} catch (e) {
showStatus(false, e.message);
}
});
async function loadFirmwareCheck(force) {
const statusEl = document.getElementById('firmware-gitea-status');
const btn = document.getElementById('firmware-update-btn');
const boardEl = document.getElementById('firmware-board');
try {
const resp = await fetch('/api/firmware/check' + (force ? '?force=true' : ''));
if (!resp.ok) {
if (force) {
showStatus(false, await resp.text());
}
return;
}
const data = await resp.json();
if (data.board) {
boardEl.textContent = `Detected board: ${data.board}`;
}
if (!data.enabled) {
statusEl.style.display = 'none';
btn.style.display = 'none';
if (force) {
showStatus(false, 'No Gitea repo URL configured.');
}
return;
}
statusEl.style.display = 'block';
if (!data.board) {
statusEl.textContent = "Waiting for the frame to check in before it can look up the right build.";
btn.style.display = 'none';
} else if (data.update_available) {
statusEl.textContent = `Update available: v${data.latest_version}.`;
btn.style.display = 'inline-block';
} else if (data.latest_version) {
statusEl.textContent = `Up to date (v${data.latest_version}).`;
btn.style.display = 'none';
} else {
statusEl.textContent = 'No releases found yet.';
btn.style.display = 'none';
}
if (force) {
showStatus(true, 'Checked.');
}
} catch (e) {
// A failed passive poll is silent -- the manual upload path still
// works regardless, and this just retries on the next poll. An
// explicit "Check now" click still surfaces the error.
if (force) {
showStatus(false, e.message);
}
}
}
document.getElementById('firmware-check-now').addEventListener('click', () => loadFirmwareCheck(true));
document.getElementById('firmware-update-btn').addEventListener('click', async () => {
const btn = document.getElementById('firmware-update-btn');
btn.disabled = true;
try {
const resp = await fetch('/api/firmware/apply-latest', { method: 'POST' });
if (!resp.ok) {
throw new Error(await resp.text());
}
const result = await resp.json();
document.getElementById('firmware-available').textContent =
`Uploaded: v${result.version} -- the frame updates itself on its next wake if it's running something else.`;
showStatus(true, `Firmware v${result.version} staged from Gitea.`);
loadFirmwareCheck();
} catch (e) {
showStatus(false, e.message);
} finally {
btn.disabled = false;
}
});
let lastDevice = null;
async function loadQueue() {
if (dragState) {
return; // don't yank the grid out from under an in-progress drag
}
const currentEl = document.getElementById('current-thumb');
try {
const resp = await fetch('/api/queue');
if (!resp.ok) {
currentEl.innerHTML = '<p class="sub">Not available yet -- configure Immich and an album first.</p>';
renderUpcoming([]);
return;
}
const data = await resp.json();
currentEl.innerHTML = '';
if (data.current) {
const wrap = document.createElement('div');
wrap.className = 'thumb-wrap';
const img = document.createElement('img');
img.className = 'thumb';
img.src = data.current.thumbnail_url;
img.alt = '';
wrap.appendChild(img);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'remove-btn';
removeBtn.title = 'Remove from rotation';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => removeAsset(data.current.id));
wrap.appendChild(removeBtn);
currentEl.appendChild(wrap);
} else {
currentEl.innerHTML = '<p class="sub">Nothing displayed yet.</p>';
}
lastDevice = data.device;
renderDeviceStatus(data.device);
renderUpcoming(data.upcoming);
} catch (e) {
currentEl.innerHTML = '<p class="sub">Could not load.</p>';
}
}
// Reads resolved colors from CSS custom properties rather than
// hardcoding hex values, so the chart matches the current theme
// (light/dark) without needing its own separate palette.
function themeColor(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
let lastBatteryLog = null;
function drawBatteryChart(log) {
lastBatteryLog = log;
const wrap = document.getElementById('battery-chart-wrap');
if (!log || log.length < 2) {
wrap.innerHTML = '<p class="sub">Not enough data yet.</p>';
return;
}
wrap.innerHTML = '';
const width = wrap.clientWidth || 440;
const height = 180;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.style.display = 'block';
canvas.style.border = `1px solid ${themeColor('--border')}`;
canvas.style.borderRadius = '8px';
wrap.appendChild(canvas);
const ctx = canvas.getContext('2d');
const gridColor = themeColor('--border');
const mutedColor = themeColor('--text-muted');
const accentColor = themeColor('--accent');
const pad = { left: 28, right: 8, top: 10, bottom: 20 };
const plotW = width - pad.left - pad.right;
const plotH = height - pad.top - pad.bottom;
const times = log.map((p) => p[0]);
const minT = Math.min(...times);
const maxT = Math.max(...times);
const spanT = Math.max(1, maxT - minT);
const x = (t) => pad.left + ((t - minT) / spanT) * plotW;
const y = (pct) => pad.top + (1 - pct / 100) * plotH;
ctx.strokeStyle = gridColor;
ctx.fillStyle = mutedColor;
ctx.font = '10px system-ui, sans-serif';
ctx.lineWidth = 1;
ctx.textAlign = 'left';
[0, 25, 50, 75, 100].forEach((pct) => {
const yy = y(pct);
ctx.beginPath();
ctx.moveTo(pad.left, yy);
ctx.lineTo(width - pad.right, yy);
ctx.stroke();
ctx.fillText(String(pct), 2, yy + 3);
});
ctx.strokeStyle = accentColor;
ctx.lineWidth = 1.5;
ctx.beginPath();
log.forEach((p, i) => {
const px = x(p[0]);
const py = y(p[1]);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
const fmt = (t) => new Date(t * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
ctx.fillStyle = mutedColor;
ctx.textAlign = 'left';
ctx.fillText(fmt(minT), pad.left, height - 4);
ctx.textAlign = 'right';
ctx.fillText(fmt(maxT), width - pad.right, height - 4);
}
async function loadBatteryLog() {
const wrap = document.getElementById('battery-chart-wrap');
try {
const resp = await fetch('/api/battery-log');
if (!resp.ok) {
wrap.innerHTML = '<p class="sub">Could not load.</p>';
return;
}
const data = await resp.json();
drawBatteryChart(data.log);
} catch (e) {
wrap.innerHTML = '<p class="sub">Could not load.</p>';
}
}
function renderStats(stats) {
const el = document.getElementById('stats-box');
el.innerHTML = '';
const now = Date.now() / 1000;
const rows = [
['Running since', stats.first_seen ? formatDuration(Math.max(0, now - stats.first_seen)) + ' ago' : 'never checked in'],
['Wake cycles', stats.device_wakes],
['Photos displayed', stats.photos_displayed],
['Photos removed from rotation', stats.photos_removed],
['Battery reports received', stats.battery_reports],
['Battery recharge cycles', stats.recharge_cycles],
['OTA updates applied', stats.ota_updates_applied],
['Settings saved', stats.config_saves],
];
for (const [label, value] of rows) {
const p = document.createElement('p');
p.className = 'sub';
p.textContent = `${label}: ${value}`;
el.appendChild(p);
}
}
async function loadStats() {
const el = document.getElementById('stats-box');
try {
const resp = await fetch('/api/stats');
if (!resp.ok) {
el.innerHTML = '<p class="sub">Could not load.</p>';
return;
}
renderStats(await resp.json());
} catch (e) {
el.innerHTML = '<p class="sub">Could not load.</p>';
}
}
loadQueue();
loadBatteryLog();
loadStats();
loadFirmwareCheck();
// Redraw the canvas chart (and the "Last seen" alert color) with the
// new theme's colors as soon as the toggle in the header is used --
// canvas pixels don't repaint themselves the way CSS does.
document.addEventListener('themechange', () => {
if (lastBatteryLog) {
drawBatteryChart(lastBatteryLog);
}
if (lastDevice) {
renderDeviceStatus(lastDevice);
}
});
// Fast tick: re-renders "Last seen"/"On battery for" etc. from the
// already-fetched device data every second, so they count up smoothly
// (1s ago, 5s ago, 1m ago...) without hitting the server that often.
setInterval(() => {
if (lastDevice) {
renderDeviceStatus(lastDevice);
}
}, 1000);
// Slow poll: picks up real changes (new photo displayed, queue edited
// from elsewhere, battery report, firmware version) without a manual
// refresh. Skipped mid-drag (see loadQueue above).
setInterval(loadQueue, 10000);
// Separate, slower poll for the Gitea release check -- cheap either
// way since the server itself throttles actual Gitea API calls to
// once per gitea_releases.UPDATE_CHECK_INTERVAL_S.
setInterval(loadFirmwareCheck, 60000);
</script>
{% endblock %}
+3 -6
View File
@@ -1,10 +1,7 @@
{% extends "base.html" %}
{% extends "app_base.html" %}
{% block page_class %}page-narrow{% endblock %}
{% block subtitle %}
<p class="sub">Your account</p>
{% endblock %}
{% block title %}Settings{% endblock %}
{% block page_title %}Your account{% endblock %}
{% block content %}
{% if saved %}<div class="status ok">Saved.</div>{% endif %}