Six more vendored families alongside the existing Noto Sans (Inter, Source Sans 3, Noto Serif, Crimson Text, Arvo, IBM Plex Mono -- sans/ serif/slab/mono variety), all OFL-licensed with their own per-family license file in app/fonts/ since each has a different copyright holder. Static Regular/Bold/Italic/BoldItalic builds only -- variable-font-only families (Inter and Source Sans's current Google Fonts releases, plus Playfair Display/Lora/Merriweather) were skipped in favor of static builds from their own upstream repos, keeping every family's loading code uniform with what was already there. Considered but deliberately left out: Georgia -- a proprietary Microsoft core font, not freely redistributable, unlike everything else vendored here. Also moves the bold/italic/underline/color toolbar below the contenteditable box per request, and reorders the dialog's Settings card to a more natural family-then-size order. Fixes a latent migration bug this surfaced: migration 20 (static image widget) used Base.metadata.create_all, which creates every table declared in Base.metadata that's missing, not just its own new one -- harmless when nothing else pending, but once TextWidgetConfig existed it would silently pre-create text_widget_configs (in whatever shape models.py currently declares) before migration 21 got a turn, so migration 21's own CREATE TABLE (or a later ALTER TABLE adding font_family) would collide with a table create_all had already leaked into existence. Both migrations 20 and 21 now use raw, frozen CREATE TABLE SQL instead, matching migration 17's existing precedent for exactly this reason.
269 lines
12 KiB
Python
269 lines
12 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 ..auth import can_view_frame, current_user
|
|
from ..calendar_render import CALENDAR_VIEW_LABELS
|
|
from ..db import get_db
|
|
from ..image_pipeline import (
|
|
DEFAULT_PALETTE_RGB,
|
|
DISPLAY_MODE_LABELS,
|
|
PALETTE_LABELS,
|
|
STATIC_DISPLAY_MODES,
|
|
palette_to_hex,
|
|
)
|
|
from ..models import (
|
|
CalendarWidgetConfig,
|
|
Frame,
|
|
FrameCalendar,
|
|
FrameTaskList,
|
|
PhotoWidgetConfig,
|
|
StaticWidgetConfig,
|
|
TaskWidgetConfig,
|
|
TextWidgetConfig,
|
|
User,
|
|
UserFrame,
|
|
WhiteboardWidgetConfig,
|
|
Widget,
|
|
)
|
|
from ..quiet_hours import ALL_TIMEZONES
|
|
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,
|
|
palette_to_hex=palette_to_hex,
|
|
photo_widget_id=photo_widget_id,
|
|
)
|
|
|
|
|
|
@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")
|
|
|
|
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,
|
|
})
|
|
|
|
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,
|
|
"default_palette_rgb": DEFAULT_PALETTE_RGB,
|
|
"palette_to_hex": palette_to_hex,
|
|
})
|
|
|
|
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,
|
|
"default_palette_rgb": DEFAULT_PALETTE_RGB,
|
|
"palette_to_hex": palette_to_hex,
|
|
})
|
|
|
|
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},
|
|
})
|
|
|
|
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,
|
|
})
|
|
|
|
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_source": _whiteboard_source_info(db, whiteboard_cfg),
|
|
"viewer_has_webdav_creds": viewer_has_webdav_creds,
|
|
})
|
|
|
|
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
|