Redesign phase D: sidebar app shell, per-frame tabs, namespaced API
Build and push server image / build-and-push (push) Successful in 43s
Build and push server image / build-and-push (push) Successful in 43s
The web UI grows into the multi-frame world: a left sidebar lists the
user's frames (with an online dot driven by the same overdue math as
the Device panel; collapsible off-canvas with a hamburger on mobile),
and each frame gets three tabs -- Photos (album picker, now displaying,
the drag-to-reorder upcoming grid), Configuration (name/order/
orientation/refresh/quiet hours/timezone/smart crop + the firmware
card), and Stats (device telemetry, lifetime counters, battery chart).
Settings and Admin adopt the same shell. / becomes a routing hub:
first frame, empty-state onboarding page, setup/login, or the
manage-QR redirect.
The JSON API moves to /api/frames/{id}/... behind require_frame_view /
require_frame_control: any linked user (admins see all) can view; 404
for frames outside your view so ids aren't confirmed; mutations 409
with the holder's name unless you hold the soft control lock, and
POST take-control always flips it to you. Config saves are now partial
updates -- each tab posts only its own fields (checkboxes always sent
explicitly), so the split forms can't clobber each other.
All CSS moves to static/theme.css and the old 680-line inline script
block splits into static/*.js -- the Pointer Events drag-drop state
machine and the canvas battery chart ported intact, not rewritten. The
CSRF fetch wrapper now reads a <meta> tag. No build step, still vanilla.
Verified end-to-end: page/static/API suites, control-lock handoff in
both directions, partial-save field preservation, non-admin frame
isolation, and the legacy-device curl suite (still byte-identical
responses for the deployed frame).
This commit is contained in:
+29
-38
@@ -1,11 +1,14 @@
|
||||
"""ESPresso Frame server: pulls photos from Immich, pre-processes them
|
||||
for the panel, and serves ESP32 frames ready-to-display images.
|
||||
|
||||
This module is assembly only -- routes live in app/routers/ (device.py
|
||||
for the firmware-facing /frame/* protocol, api.py for the web UI's
|
||||
/api/*, pages.py for setup/login/settings/admin), storage in SQLite via
|
||||
models.py/db.py, with migration.py importing a pre-database config.json
|
||||
deployment on first boot."""
|
||||
This module is assembly only -- routes live in app/routers/:
|
||||
device.py the firmware-facing /frame/* protocol (paths frozen)
|
||||
api_frames.py the web UI's JSON API, /api/frames/{id}/...
|
||||
frame_pages.py the per-frame Photos/Configuration/Stats pages
|
||||
pages.py setup/login/claim/settings/admin
|
||||
manage.py the limited manage-QR surface (/m/, /api/m/)
|
||||
Storage is SQLite via models.py/db.py; migration.py imports a
|
||||
pre-database config.json deployment on first boot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,23 +16,22 @@ import logging
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from . import migration
|
||||
from .auth import (
|
||||
browser_token_valid,
|
||||
current_session,
|
||||
current_user,
|
||||
management_token,
|
||||
user_frames,
|
||||
users_exist,
|
||||
)
|
||||
from .db import SessionLocal
|
||||
from .models import Frame
|
||||
from .quiet_hours import ALL_TIMEZONES
|
||||
from .routers import api, device, manage, pages
|
||||
from .routers.common import default_frame, immich_creds
|
||||
from .routers import api_frames, device, frame_pages, manage, pages
|
||||
from .routers.common import shell_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,17 +41,25 @@ migration.run_migrations()
|
||||
app = FastAPI(title="ESPresso Frame Server")
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
app.include_router(device.router)
|
||||
app.include_router(api.router)
|
||||
app.include_router(api_frames.router)
|
||||
app.include_router(frame_pages.router)
|
||||
app.include_router(pages.router)
|
||||
app.include_router(manage.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str | None:
|
||||
"""The on-frame manage QR points at the server root with the device's
|
||||
own credentials (new firmware: ?id=&token=; deployed firmware:
|
||||
?token=<legacy shared token>). Those scans get the frame's limited
|
||||
manage page -- never the full UI, which now requires a login.
|
||||
manage page -- never the full UI, which requires a login.
|
||||
allow_legacy is False before /setup has run: at that point a bare
|
||||
?token= hit is the admin coming through the token prompt to do
|
||||
first-run setup, not a QR scan."""
|
||||
@@ -68,19 +78,11 @@ def _device_credential_redirect(request: Request, db, allow_legacy: bool) -> str
|
||||
return None
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
"""The web UI (still the single-frame page until Phase D). Access:
|
||||
a user session (the normal path once /setup has run), or -- only
|
||||
while no users exist AND no MANAGEMENT_TOKEN is configured -- fully
|
||||
open, the original trusted-LAN default. A hit carrying device
|
||||
credentials (the on-frame manage QR) redirects to that frame's
|
||||
limited manage page instead."""
|
||||
"""Routing hub: manage-QR scans go to the limited manage page, users
|
||||
land on their first frame (or an empty-state page), and everyone
|
||||
else is walked through setup/login."""
|
||||
with SessionLocal() as db:
|
||||
have_users = users_exist(db)
|
||||
manage_redirect = _device_credential_redirect(request, db, allow_legacy=have_users)
|
||||
@@ -88,8 +90,6 @@ def index(request: Request):
|
||||
return RedirectResponse(manage_redirect, status_code=303)
|
||||
|
||||
user = current_user(request, db)
|
||||
session = current_session(request, db) if user else None
|
||||
|
||||
if user is None:
|
||||
if not have_users:
|
||||
if management_token() and not browser_token_valid(request):
|
||||
@@ -101,16 +101,7 @@ def index(request: Request):
|
||||
return RedirectResponse("/setup", status_code=303)
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
frame = default_frame(db)
|
||||
immich_url, _ = immich_creds(frame)
|
||||
return templates.TemplateResponse(
|
||||
"index.html",
|
||||
{
|
||||
"request": request,
|
||||
"cfg": frame,
|
||||
"immich_url": immich_url,
|
||||
"timezones": ALL_TIMEZONES,
|
||||
"user": user,
|
||||
"csrf_token": session.csrf_token if session else None,
|
||||
},
|
||||
)
|
||||
frames = user_frames(db, user)
|
||||
if frames:
|
||||
return RedirectResponse(f"/frames/{frames[0].id}", status_code=303)
|
||||
return templates.TemplateResponse("frames_empty.html", shell_context(request, db, user))
|
||||
|
||||
Reference in New Issue
Block a user