Files
espresso_frame/server/app/routers/frame_pages.py
T
tfaour 5f4f8f2ea7
Build and push server image / test (push) Successful in 44s
Build and push server image / build-and-push (push) Successful in 3m33s
Build and push server image / deploy (push) Failing after 1m27s
Add a curated theme system for "modern" style widgets, inspired by Tesserae
Frame.theme (7 presets in app/theme_tokens.py) drives font family,
corner radius, drop shadow, and an accent hue for every modern-style
widget's header/accent region. Rich accent colors (not just the 6 flat
panel inks) are approximated via denser Bayer stippling confined to just
that region (html_render.ordered_dither_regions), so icon/text content
elsewhere stays exactly as crisp as it is today -- verified directly
against real Chromium renders, both in unit tests and via run-server.
"classic" is a byte-identical no-visual-change default: weather's header
keeps its original fixed blue gradient, tasks/calendar keep their flat
THEME_* ink.

Themes are purely stylistic -- battery's charge-level color, calendar/
tasks' per-owner event chips, and text's own per-widget font choice are
never touched.
2026-07-31 10:34:28 +00:00

329 lines
14 KiB
Python

"""The per-frame HTML pages: Layout (/frames/{id}, the widget placement
canvas), Configuration, and Stats, all inside the sidebar app shell.
Each widget's own settings (album, calendar view/inclusion, whiteboard
source, etc.) no longer have their own tab/page -- they're a dialog
opened from a gear icon on the widget's box in the Layout canvas (see
static/frame_layout.js), whose content this module also serves (the
/widgets/{widget_id}/dialog route) as a small HTML fragment, not a full
page. Data loading otherwise happens client-side against
/api/frames/{id}/... (routers/api_frames.py, routers/api_widgets.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 import select
from sqlalchemy.orm import Session
from .. import theme_tokens, weather
from ..auth import can_view_frame, current_user
from ..calendar_render import CALENDAR_VIEW_LABELS
from ..db import get_db
from ..global_actions import GLOBAL_ACTION_LABELS
from ..image_pipeline import (
BORDER_STYLES,
BORDER_STYLE_LABELS,
CALIBRATED_SPECTRA6_RGB,
DEFAULT_PALETTE_RGB,
DISPLAY_MODE_LABELS,
MAX_BORDER_THICKNESS,
MIN_BORDER_THICKNESS,
PALETTE_LABELS,
STATIC_DISPLAY_MODES,
palette_to_hex,
)
from ..models import (
BatteryWidgetConfig,
CalendarWidgetConfig,
Frame,
FrameButtonAction,
FrameCalendar,
FrameTaskList,
PhotoWidgetConfig,
StaticWidgetConfig,
TaskWidgetConfig,
TextWidgetConfig,
User,
UserFrame,
WeatherWidgetConfig,
WhiteboardWidgetConfig,
Widget,
)
from ..quiet_hours import ALL_TIMEZONES
from ..widgets import WIDGET_TYPES
from ..widgets import text as text_widget
from .common import shell_context, widget_of_type
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_layout_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_layout.html", "layout")
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
frame = db.get(Frame, frame_id)
photo_widget_id = None
if frame is not None:
photo_widget = widget_of_type(db, frame, "photos")
if photo_widget is not None:
photo_widget_id = photo_widget.id
return _frame_page(
request, db, frame_id, "frame_config.html", "config",
timezones=ALL_TIMEZONES,
palette_labels=PALETTE_LABELS,
default_palette_rgb=DEFAULT_PALETTE_RGB,
calibrated_spectra6_hex=palette_to_hex(CALIBRATED_SPECTRA6_RGB),
palette_to_hex=palette_to_hex,
photo_widget_id=photo_widget_id,
global_action_labels=GLOBAL_ACTION_LABELS,
themes=theme_tokens.THEMES,
)
@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")
# --- Per-widget config dialog content -------------------------------------
def _user_available_calendars(user: User) -> list[dict]:
"""This user's full set of calendars available to add to any widget:
the single ICS subscription (if set) plus every CalDAV calendar last
discovered from Settings' "Discover calendars" button. Doesn't hit
the network -- reads the cached list a user refreshes themselves."""
calendars = []
if user.calendar_ics_url:
calendars.append({"key": "ics", "label": "My calendar (ICS)"})
for c in (user.calendar_caldav_calendars or []):
calendars.append({"key": f"caldav:{c['href']}", "label": c.get("display_name") or "Calendar"})
return calendars
def _calendar_users_for_widget(db: Session, frame_id: int, widget_id: int, viewer_id: int | None) -> list[dict]:
"""Per-linked-user calendar list for the calendar dialog's "Included
calendars" section. The viewer's own row lists EVERY calendar they
have available, each with a full add/remove toggle; every other
linked user's row lists ONLY the calendars they've already included
(mute-only for the viewer -- see api_widgets.py's
api_widget_calendar_select: only a calendar's owner may turn it on,
but anyone linked to the frame may turn one off)."""
users = db.execute(
select(User).join(UserFrame, UserFrame.user_id == User.id)
.where(UserFrame.frame_id == frame_id).order_by(User.username)
).scalars().all()
included_by_user: dict[int, list[FrameCalendar]] = {}
for fc in db.execute(select(FrameCalendar).where(FrameCalendar.widget_id == widget_id)).scalars().all():
included_by_user.setdefault(fc.user_id, []).append(fc)
result = []
for u in users:
is_self = u.id == viewer_id
if is_self:
own_rows = {fc.calendar_key: fc for fc in included_by_user.get(u.id, [])}
calendars = [
{**c, "included": own_rows[c["key"]].included if c["key"] in own_rows else False,
"color_index": own_rows[c["key"]].color_index if c["key"] in own_rows else None}
for c in _user_available_calendars(u)
]
else:
calendars = [
{"key": fc.calendar_key, "label": fc.calendar_label, "included": True}
for fc in included_by_user.get(u.id, []) if fc.included
]
result.append({
"user_id": u.id, "display_name": u.display_name or u.username,
"is_self": is_self, "calendars": calendars,
})
return result
def _task_users_for_widget(db: Session, frame_id: int, widget_id: int, viewer_id: int | None) -> list[dict]:
"""Per-linked-user task-list list for the tasks dialog's "Included
task lists" section -- same shape as _calendar_users_for_widget,
restricted to CalDAV calendars only (no "ics" option: a plain ICS
subscription has no VTODO collection to speak of, see
caldav_client.fetch_tasks)."""
users = db.execute(
select(User).join(UserFrame, UserFrame.user_id == User.id)
.where(UserFrame.frame_id == frame_id).order_by(User.username)
).scalars().all()
included_by_user: dict[int, list[FrameTaskList]] = {}
for ftl in db.execute(select(FrameTaskList).where(FrameTaskList.widget_id == widget_id)).scalars().all():
included_by_user.setdefault(ftl.user_id, []).append(ftl)
result = []
for u in users:
is_self = u.id == viewer_id
available = [c for c in _user_available_calendars(u) if c["key"].startswith("caldav:")]
if is_self:
own_rows = {ftl.calendar_key: ftl for ftl in included_by_user.get(u.id, [])}
task_lists = [
{**c, "included": own_rows[c["key"]].included if c["key"] in own_rows else False,
"color_index": own_rows[c["key"]].color_index if c["key"] in own_rows else None}
for c in available
]
else:
task_lists = [
{"key": ftl.calendar_key, "label": ftl.calendar_label, "included": True}
for ftl in included_by_user.get(u.id, []) if ftl.included
]
result.append({
"user_id": u.id, "display_name": u.display_name or u.username,
"is_self": is_self, "task_lists": task_lists,
})
return result
def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None:
"""Whose account this widget currently fetches with, for showing
"using <name>'s account" to everyone linked, not just whoever set
it. None if no source is configured."""
if not whiteboard_cfg.user_id or not whiteboard_cfg.url:
return None
user = db.get(User, whiteboard_cfg.user_id)
if user is None:
return None
return {"user_id": user.id, "display_name": user.display_name or user.username, "url": whiteboard_cfg.url}
WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday",
4: "Friday", 5: "Saturday", 6: "Sunday"}
@router.get("/frames/{frame_id}/widgets/{widget_id}/dialog", response_class=HTMLResponse)
def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session = Depends(get_db)):
"""The gear-icon dialog's content, dispatched by widget_type -- a
small HTML fragment (no app_base shell/tabs), fetched and injected
into a <dialog> by static/frame_layout.js. Not itself a page a user
would navigate to directly."""
user = current_user(request, db)
if user is None:
raise HTTPException(401, "Not logged in")
frame = db.get(Frame, frame_id)
if frame is None or not can_view_frame(db, user, frame):
raise HTTPException(404, "No such frame")
widget = db.get(Widget, widget_id)
if widget is None or widget.frame_id != frame.id:
raise HTTPException(404, "No such widget")
# Every dialog includes the shared "Border" card (_widget_border_fields.html,
# models.Widget.border_style/border_thickness/border_color_index) --
# a Widget-level property, not a per-type config field, so this
# context is the same regardless of widget_type.
border_ctx = {
"border_styles": BORDER_STYLES,
"border_style_labels": BORDER_STYLE_LABELS,
"border_color_labels": PALETTE_LABELS,
"default_palette_rgb": DEFAULT_PALETTE_RGB,
"palette_to_hex": palette_to_hex,
"min_border_thickness": MIN_BORDER_THICKNESS,
"max_border_thickness": MAX_BORDER_THICKNESS,
}
# Every dialog also includes the shared "Button actions" card
# (_widget_button_fields.html) if this widget type supports any --
# empty for tasks/static/text/battery, so the card renders nothing
# for those. Bindings, not the type's own config, so this lives in
# FrameButtonAction (see models.py), same reasoning as border_ctx
# above for why it's a separate card/endpoint from the type-specific
# form.
bindings = {
row.button: row.action
for row in db.scalars(
select(FrameButtonAction).where(FrameButtonAction.widget_id == widget.id)
)
}
button_ctx = {
"button_action_labels": WIDGET_TYPES[widget.widget_type].ACTION_LABELS,
"next_button_action": bindings.get("next", ""),
"back_button_action": bindings.get("back", ""),
}
if widget.widget_type == "photos":
photo_cfg = db.get(PhotoWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_photos.html", {
"request": request, "frame": frame, "widget": widget, "photo_cfg": photo_cfg,
"display_mode_labels": DISPLAY_MODE_LABELS, **border_ctx, **button_ctx,
})
if widget.widget_type == "calendar":
calendar_cfg = db.get(CalendarWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_calendar.html", {
"request": request, "frame": frame, "widget": widget, "calendar_cfg": calendar_cfg, "user": user,
"calendar_views": CALENDAR_VIEW_LABELS,
"calendar_users": _calendar_users_for_widget(db, frame.id, widget.id, user.id),
"week_start_labels": WEEK_START_LABELS,
"calendar_color_labels": PALETTE_LABELS,
**border_ctx, **button_ctx,
})
if widget.widget_type == "tasks":
task_cfg = db.get(TaskWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_tasks.html", {
"request": request, "frame": frame, "widget": widget, "task_cfg": task_cfg, "user": user,
"task_users": _task_users_for_widget(db, frame.id, widget.id, user.id),
"task_color_labels": PALETTE_LABELS,
**border_ctx, **button_ctx,
})
if widget.widget_type == "static":
static_cfg = db.get(StaticWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_static.html", {
"request": request, "frame": frame, "widget": widget, "static_cfg": static_cfg,
"display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES},
**border_ctx, **button_ctx,
})
if widget.widget_type == "text":
text_cfg = db.get(TextWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_text.html", {
"request": request, "frame": frame, "widget": widget, "text_cfg": text_cfg,
"text_font_families": text_widget.FONT_FAMILIES, **border_ctx, **button_ctx,
})
if widget.widget_type == "whiteboard":
whiteboard_cfg = db.get(WhiteboardWidgetConfig, widget.id)
viewer_has_webdav_creds = bool(
user.webdav_username or (user.webdav_reuse_caldav_creds and user.calendar_caldav_username)
)
return templates.TemplateResponse("_widget_dialog_whiteboard.html", {
"request": request, "frame": frame, "widget": widget, "user": user,
"whiteboard_cfg": whiteboard_cfg,
"whiteboard_source": _whiteboard_source_info(db, whiteboard_cfg),
"viewer_has_webdav_creds": viewer_has_webdav_creds, **border_ctx, **button_ctx,
})
if widget.widget_type == "weather":
weather_cfg = db.get(WeatherWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_weather.html", {
"request": request, "frame": frame, "widget": widget, "weather_cfg": weather_cfg,
"weather_provider_labels": weather.PROVIDER_LABELS, **border_ctx, **button_ctx,
})
if widget.widget_type == "battery":
battery_cfg = db.get(BatteryWidgetConfig, widget.id)
return templates.TemplateResponse("_widget_dialog_battery.html", {
"request": request, "frame": frame, "widget": widget, "battery_cfg": battery_cfg, **border_ctx, **button_ctx,
})
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")