Widget system Phase 4b: per-widget gear-icon config dialogs
Build and push server image / test (push) Successful in 21s
Build and push server image / build-and-push (push) Successful in 1m57s
Build and push server image / deploy (push) Successful in 52s

Replaces the Photos/Calendar/Whiteboard tabs with a single Layout page
(now the frame's landing route) where each widget gets a gear icon
opening a dialog scoped to that specific widget's own settings. This
was the missing piece for genuinely independent same-type widgets --
"the Calendar tab" never made sense once a frame could hold more than
one calendar widget with different settings.

Data layer: FrameCalendar re-keyed from frame_id to widget_id, so each
calendar widget has its own independent included-calendars set. The
rekey runs as an unconditional post-startup step (like the existing
widget backfill), not a numbered migration -- it depends on calendar
widgets already existing, which themselves come from that same
backfill step, not from schema migration. Registering it as a numbered
migration would have run it first during a real upgrade, silently
dropping every row; caught by a new test that exercises the raw-SQL
upgrade path instead of the fresh-install create_all() shortcut every
other migration test takes.

API layer: every endpoint that used to assume "the frame's widget of
this type" (photo queue/thumbnail/preview, calendar select/color/
tasks/weather, whiteboard source/browse/preview) moved into
api_widgets.py under /api/frames/{id}/widgets/{widget_id}/..., with a
new require_widget_view/control dependency pair mirroring the existing
frame-level ones. Device status (battery/last-seen/firmware) got its
own frame-level /status endpoint, split out of the old photo-specific
/queue it used to piggyback on -- fixes the status bar going silently
blank on any frame without a photo widget.

UI layer: each widget type's existing settings markup/JS was ported
into a dialog partial + an explicit init/close function pair (the
content is now fetched and injected on demand, not loaded at page load
time). window.FRAME_API is repointed to the open dialog's widget-scoped
API base for its duration and restored on close; a separate
window.FRAME_BASE_API stays stable for the always-present header/
status-bar scripts.

Caught during manual browser testing: the consolidated config-save
endpoint initially expected a JSON body while the copied-over dialog JS
posts form-urlencoded data (the old convention) -- fixed to match, with
new HTTP-level test coverage that would have caught it immediately.
This commit is contained in:
2026-07-24 14:31:24 -04:00
parent 63751a79ad
commit a33a3a71e4
35 changed files with 2335 additions and 1912 deletions
+4 -4
View File
@@ -63,8 +63,8 @@ def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None)
what tells the "same event, more than one calendar" case apart from
an ordinary single-calendar event at render time -- see
_draw_color_bar. Each source's own manually pinned color
(FrameCalendar.color_index -- see routers/api_frames.py's
api_calendar_color) resolves against whichever palette this frame
(FrameCalendar.color_index -- see routers/api_widgets.py's
api_widget_calendar_color) resolves against whichever palette this frame
actually renders with, so a pinned "Blue" stays this frame's actual
blue; a source with no color pinned falls back to the old
auto-cycle-by-owner-name behavior. owners_seen is shared across every
@@ -644,7 +644,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None,
start_offset: int = 0) -> Image.Image:
"""`days` (2-10, see routers/api_frames.py's clamp) side-by-side
"""`days` (2-10, see routers/api_widgets.py's clamp) side-by-side
columns (layout="horizontal", the original fixed-at-7 behavior
generalized) or stacked bands (layout="vertical", reusing
_draw_agenda_day the same way _build_today_tomorrow does, just for
@@ -657,7 +657,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
weekday, "start on the most recent Monday") exactly like before --
otherwise "start of the week" doesn't mean much for an arbitrary day
count, so it instead starts `start_offset` days from today (0 =
today, see routers/api_frames.py's api_config_save)."""
today, see routers/api_widgets.py's api_widget_config_save)."""
img = Image.new("RGB", (target_w, target_h), BG)
draw = ImageDraw.Draw(img)
tier = _size_tier(target_w, target_h)
+63 -1
View File
@@ -15,7 +15,7 @@ import secrets
import shutil
import time
from sqlalchemy import select, text
from sqlalchemy import inspect, select, text
from . import config, grid
from .db import SessionLocal, engine
@@ -378,6 +378,7 @@ def run_migrations() -> None:
_ensure_frame_one()
_ensure_server_settings()
_ensure_widgets_backfilled()
_ensure_frame_calendars_rekeyed()
def new_device_token() -> str:
@@ -604,3 +605,64 @@ def _ensure_widgets_backfilled() -> None:
continue
_backfill_frame_widgets(db, frame)
db.commit()
def _ensure_frame_calendars_rekeyed() -> None:
"""Re-keys frame_calendars from frame_id to widget_id -- a frame can
hold more than one independent calendar widget (see the widget
system), each with its own included-calendars set, so "included on
this frame" no longer means anything unambiguous (see
models.FrameCalendar). Existing rows attach to their frame's calendar
widget if it has one; rows for a frame with no calendar widget at all
are dropped -- they were already-dormant settings for content
nothing ever actually displayed (the Calendar tab stayed reachable
and savable even while a frame's old `mode` was "photos"), not real
live configuration.
Deliberately NOT a numbered migration: this needs each frame's
calendar widget to already exist to know what to re-key against, and
those widget rows aren't created by a schema migration at all --
they come from _ensure_widgets_backfilled() above, which (like this
function) runs unconditionally after every startup rather than being
tracked by schema_version. Running this as a numbered migration
would execute it *before* that backfill during a real upgrade (the
numbered-migration loop runs first, see run_migrations), silently
dropping every row -- caught by test_migrations.py actually exercising
the raw-SQL upgrade path instead of the fresh-install create_all()
shortcut every other test in that file takes.
Runs unconditionally after every startup, like _ensure_widgets_
backfilled; a no-op the moment frame_calendars is already
widget_id-shaped (every fresh install, and any existing install
after its first run past this code) -- SQLite can't ALTER a column's
FK target or drop a column that's part of an index/FK constraint, so
when it isn't a no-op this is the standard SQLite "rebuild" pattern:
create the new-shape table, copy matching rows across (joining to
find each row's calendar widget), drop the old table, rename the new
one into place."""
inspector = inspect(engine)
columns = {c["name"] for c in inspector.get_columns("frame_calendars")}
if "widget_id" in columns:
return
with engine.begin() as conn:
conn.execute(text(
"CREATE TABLE frame_calendars_new ("
"id INTEGER PRIMARY KEY, "
"widget_id INTEGER NOT NULL REFERENCES widgets(id) ON DELETE CASCADE, "
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
"calendar_key TEXT NOT NULL, "
"calendar_label TEXT NOT NULL DEFAULT '', "
"included INTEGER NOT NULL DEFAULT 1, "
"color_index INTEGER)"
))
conn.execute(text(
"INSERT INTO frame_calendars_new (widget_id, user_id, calendar_key, calendar_label, included, color_index) "
"SELECT w.id, fc.user_id, fc.calendar_key, fc.calendar_label, fc.included, fc.color_index "
"FROM frame_calendars fc "
"JOIN widgets w ON w.frame_id = fc.frame_id AND w.widget_type = 'calendar'"
))
conn.execute(text("DROP TABLE frame_calendars"))
conn.execute(text("ALTER TABLE frame_calendars_new RENAME TO frame_calendars"))
conn.execute(text(
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (widget_id, user_id, calendar_key)"
))
+20 -15
View File
@@ -80,9 +80,10 @@ class User(Base):
webdav_username: Mapped[str] = mapped_column(String, default="")
webdav_password: Mapped[str] = mapped_column(String, default="")
webdav_reuse_caldav_creds: Mapped[bool] = mapped_column(Boolean, default=False)
# Optional starting folder for the file-picker on a frame's Whiteboard
# tab (see routers/api_frames.py's whiteboard-browse) -- purely a
# convenience for browsing to a file rather than typing its full URL.
# Optional starting folder for the whiteboard dialog's file-picker
# (see routers/api_widgets.py's api_widget_whiteboard_browse) --
# purely a convenience for browsing to a file rather than typing its
# full URL.
# Never used for fetching/rendering itself, which always uses the
# frame's own saved whiteboard_url regardless of whether this is set.
webdav_base_url: Mapped[str] = mapped_column(String, default="")
@@ -241,8 +242,8 @@ class Frame(Base):
# _draw_tasks). CalDAV only (a task list is a VTODO collection, not
# something a plain ICS subscription meaningfully has); source is
# one specific linked user's own CalDAV calendar, same
# owner-controls-their-own-data permission split as FrameCalendar --
# see routers/api_frames.py's api_tasks_source. calendar_tasks_user_id
# owner-controls-their-own-data permission split as FrameCalendar.
# calendar_tasks_user_id
# SET NULL on the user's deletion clears the source rather than
# leaving a dangling reference (checked_at isn't reset by that, but
# the next refresh attempt finds no source and just returns []).
@@ -261,9 +262,8 @@ class Frame(Base):
# routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide
# setting like calendar mode's own frame_calendars source, not
# personal data, but still owner-gated the same way: only
# whiteboard_user_id may point the frame at their own account (see
# routers/api_frames.py's api_whiteboard_source), since it's their
# credentials being used to fetch it. --
# whiteboard_user_id may point the frame at their own account, since
# it's their credentials being used to fetch it. --
whiteboard_user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
@@ -355,12 +355,18 @@ class FrameCalendar(Base):
ANY user linked to the frame may flip included back to False, muting
a calendar they'd rather not see on a shared display even though
they don't own it. Only the owner may flip it back to True. See
routers/api_frames.py's api_calendar_select."""
routers/api_widgets.py's api_widget_calendar_select.
Keyed by widget_id, not frame_id -- a frame can hold more than one
independent calendar widget (see Widget), each with its own included-
calendars set; "included on this frame" stopped being unambiguous
the moment that became possible (see migration.py's _migration_17,
which re-keyed this table)."""
__tablename__ = "frame_calendars"
id: Mapped[int] = mapped_column(primary_key=True)
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
calendar_key: Mapped[str] = mapped_column(String)
# Snapshot label for display -- so the list still reads sensibly even
@@ -372,11 +378,11 @@ class FrameCalendar(Base):
# text/background) pinning this calendar's events to a specific
# panel color rather than calendar_render.py's old owner-name
# auto-cycle. NULL keeps the auto-cycle behavior. Only the calendar's
# owner may set this -- see routers/api_frames.py's api_calendar_color.
# owner may set this -- see routers/api_widgets.py's api_widget_calendar_color.
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
__table_args__ = (
Index("ix_frame_calendars_unique", "frame_id", "user_id", "calendar_key", unique=True),
Index("ix_frame_calendars_unique", "widget_id", "user_id", "calendar_key", unique=True),
)
@@ -445,9 +451,8 @@ class CalendarWidgetConfig(Base):
minus calendar_photo_inlay (dropped: arbitrary widget placement
subsumes what a fixed 50/50 inlay split did, so it's not a special
case anymore, just place a photo widget alongside). "Included
calendars" stays on FrameCalendar (frame_id-keyed for now; re-keyed
to widget_id in a later phase once more than one calendar widget per
frame is actually supported end to end)."""
calendars" is its own table (FrameCalendar), widget_id-keyed so each
calendar widget on a frame has its own independent set."""
__tablename__ = "calendar_widget_configs"
+42 -643
View File
@@ -1,16 +1,17 @@
"""The web UI's JSON API, namespaced per frame: /api/frames/{id}/...
"""The web UI's JSON API for frame-wide settings: /api/frames/{id}/...
Per-widget settings (album, calendar view/inclusion, whiteboard source,
etc.) live in api_widgets.py instead, under /api/frames/{id}/widgets/
{widget_id}/... -- split out once a frame could hold more than one
widget of the same type.
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".
Config saves are PARTIAL updates: only provided fields 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
@@ -20,48 +21,16 @@ import time
import httpx
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 calendar_render, gitea_releases, grid, photo_queue, quiet_hours, weather, webdav_client
from .. import gitea_releases, grid, quiet_hours
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db, widget_locked
from ..image_pipeline import (
DEFAULT_DISPLAY_MODE,
DISPLAY_MODES,
PALETTE_LABELS,
hex_to_rgb,
render_preview_png,
)
from ..db import frame_locked, get_db
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
from ..firmware import firmware_path, parse_app_version
from ..models import (
BatteryLog,
CalendarWidgetConfig,
Frame,
FrameCalendar,
PhotoWidgetConfig,
WhiteboardWidgetConfig,
Widget,
)
from .common import (
OVERDUE_FACTOR,
battery_estimate_s,
calendar_sources_for_frame,
fetch_source_and_faces,
get_or_refresh_calendar_events_for_widget,
get_or_refresh_tasks_for_widget,
get_or_refresh_weather_for_widget,
get_or_refresh_whiteboard_for_widget,
immich_client_for,
immich_creds,
list_assets,
photo_widget_config_or_404,
valid_http_url,
webdav_creds_for,
widget_of_type,
)
from ..models import BatteryLog, Frame, Widget
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
logger = logging.getLogger(__name__)
@@ -69,8 +38,6 @@ router = APIRouter()
MIN_REFRESH_INTERVAL_S = 60
MAX_REFRESH_INTERVAL_S = 86400
MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 5000
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
@@ -116,11 +83,7 @@ def api_albums(frame: Frame = Depends(require_frame_view)):
@router.post("/api/frames/{frame_id}/config")
def api_config_save(
name: str | None = Form(None),
album_id: str | None = Form(None),
order: str | None = Form(None),
refresh_interval_s: int | None = Form(None),
display_mode: str | 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),
@@ -134,27 +97,18 @@ def api_config_save(
color_boost: float | None = Form(None),
contrast_boost: float | None = Form(None),
dither_strength: float | None = Form(None),
calendar_view: str | None = Form(None),
calendar_week_start: int | None = Form(None),
calendar_week_days: int | None = Form(None),
calendar_week_layout: str | None = Form(None),
calendar_week_start_offset: int | None = Form(None),
calendar_weather_enabled: bool | None = Form(None),
calendar_weather_units: str | None = Form(None),
calendar_tasks_enabled: bool | None = Form(None),
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Partial update, split across up to three sequential lock spans --
frame-level settings, the frame's photo widget, the frame's calendar
widget -- rather than one, now that those settings live on separate
rows (see models.Widget's per-type extension tables). Never nested
(see db.widget_locked's own docstring on why that would deadlock).
`mode` and `calendar_photo_inlay` are no longer accepted here: mode
no longer governs anything (a frame's widgets do), and photo inlay
has no widget-system equivalent (place an independent photo widget
alongside instead -- see CalendarWidgetConfig's docstring). Both are
"""Partial update of frame-wide settings only -- per-widget settings
(album, calendar view/inclusion, whiteboard source, etc.) live on
routers/api_widgets.py's /widgets/{widget_id}/... endpoints instead,
since a frame can hold more than one widget of the same type and
"the frame's calendar settings" stopped being unambiguous the moment
that became possible. `mode` and `calendar_photo_inlay` are no
longer accepted here either: mode no longer governs anything (a
frame's widgets do), and photo inlay has no widget-system equivalent
(place an independent photo widget alongside instead). All three are
harmless no-ops if an old cached page still POSTs them -- FastAPI
silently ignores form fields with no matching parameter.
@@ -162,14 +116,7 @@ def api_config_save(
_reset_widget_layout_for_new_orientation) -- widget placement is
grid-cell-relative to the panel's long/short axis, which swaps on a
landscape<->portrait change, so an old placement is usually not just
visually wrong but literally out of bounds on the new grid.
Until the widget-placement UI (a later phase) lets a frame have more
than one widget of a type, "the photo widget" / "the calendar
widget" below unambiguously means the frame's single auto-migrated
one (see widget_of_type) -- these fields are silent no-ops if the
frame doesn't have one yet, same posture as any other partial update
whose target doesn't exist."""
visually wrong but literally out of bounds on the new grid."""
with frame_locked(db, frame.id) as cfg:
if name is not None:
cfg.name = name.strip()[:64] or cfg.name
@@ -219,72 +166,6 @@ def api_config_save(
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
cfg.stats_config_saves += 1
photo_widget = widget_of_type(db, frame, "photos")
photo_fields_present = any(v is not None for v in (album_id, order, display_mode, queue_target_len))
if photo_widget and photo_fields_present:
with widget_locked(db, frame.id, photo_widget.id) as (_, _, pcfg):
if album_id is not None and album_id != pcfg.album_id:
# A newly selected album starts clean -- the old current
# photo and queue don't mean anything in the new album's
# context.
pcfg.current_asset_id = ""
pcfg.current_asset_set_at = 0.0
pcfg.queue = []
pcfg.queue_cursor = 0
pcfg.history = []
pcfg.excluded_asset_ids = []
pcfg.album_id = album_id
if order is not None:
pcfg.order = order if order in ("sequential", "shuffle") else "sequential"
if display_mode is not None:
pcfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
if queue_target_len is not None:
pcfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
calendar_widget = widget_of_type(db, frame, "calendar")
calendar_fields_present = any(v is not None for v in (
calendar_view, calendar_week_start, calendar_week_days, calendar_week_layout,
calendar_week_start_offset, calendar_weather_enabled, calendar_weather_units, calendar_tasks_enabled,
))
if calendar_widget and calendar_fields_present:
with widget_locked(db, frame.id, calendar_widget.id) as (_, _, ccfg):
if calendar_view is not None:
new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
if new_view != ccfg.view:
# A stale offset means something different in a
# different view's units (days vs. weeks vs. months).
ccfg.browse_offset = 0
ccfg.view = new_view
if calendar_week_start is not None:
ccfg.week_start = max(0, min(6, calendar_week_start))
if calendar_week_days is not None:
new_days = max(2, min(10, calendar_week_days))
if new_days != ccfg.week_days:
# A stale offset counts a different-sized page under
# the old day count.
ccfg.browse_offset = 0
ccfg.week_days = new_days
if calendar_week_layout is not None:
ccfg.week_layout = (
calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal"
)
if calendar_week_start_offset is not None:
new_offset = max(-30, min(30, calendar_week_start_offset))
if new_offset != ccfg.week_start_offset:
ccfg.browse_offset = 0
ccfg.week_start_offset = new_offset
if calendar_weather_enabled is not None:
ccfg.weather_enabled = calendar_weather_enabled
if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"):
if calendar_weather_units != ccfg.weather_units:
# Cached forecasts are in the old unit -- force a
# refetch rather than showing stale numbers under a
# new unit label.
ccfg.weather_checked_at = 0.0
ccfg.weather_units = calendar_weather_units
if calendar_tasks_enabled is not None:
ccfg.tasks_enabled = calendar_tasks_enabled
return {"status": "saved"}
@@ -317,62 +198,35 @@ def api_stats(frame: Frame = Depends(require_frame_view)):
}
@router.get("/api/frames/{frame_id}/queue")
def api_queue(
@router.get("/api/frames/{frame_id}/status")
def api_status(
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
"""Device liveness + control-lock info -- frame-level facts (battery,
last-seen, firmware, who has control), not tied to any particular
widget. Powers static/device_status_bar.js, shown on every per-frame
page regardless of which widgets that frame has. Used to piggyback on
the photo queue endpoint (back when a frame had at most one widget,
always photos-shaped); split out once that stopped being true, so the
status bar isn't blank on a frame with no photo widget."""
user = require_user_api(request, db)
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
client = immich_client_for(frame)
assets = list_assets(client, pcfg.album_id)
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
photo_queue.get_current(locked_pcfg, assets, locked_frame,
in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
photo_queue.sync_queue_length(locked_pcfg, assets)
snapshot = {
"current_asset_id": locked_pcfg.current_asset_id,
"queue": list(locked_pcfg.queue),
"last_seen": locked_frame.last_seen,
"overdue_gap": quiet_hours.max_expected_gap_s(locked_frame) * OVERDUE_FACTOR,
"firmware_version": locked_frame.device_firmware_version,
"firmware_available": locked_frame.firmware_available_version,
"battery_percent": locked_frame.battery_percent,
"battery_as_of": locked_frame.battery_as_of,
"battery_estimate_s": battery_estimate_s(locked_frame, db),
"controller_id": locked_frame.controlled_by_user_id,
"controller": (
(locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
if locked_frame.controlled_by
else None
),
}
def entry(asset_id: str) -> dict:
return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/thumbnail/{asset_id}"}
now = time.time()
overdue_gap = quiet_hours.max_expected_gap_s(frame) * OVERDUE_FACTOR
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,
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
"you": frame.controlled_by_user_id == user.id,
},
"device": {
"last_seen": snapshot["last_seen"] or None,
"overdue": bool(
snapshot["last_seen"] and now - snapshot["last_seen"] > snapshot["overdue_gap"]
),
"firmware_version": snapshot["firmware_version"] or None,
"firmware_available": snapshot["firmware_available"] or None,
"last_seen": frame.last_seen or None,
"overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap),
"firmware_version": frame.device_firmware_version or None,
"firmware_available": frame.firmware_available_version or None,
"battery": (
{"percent": snapshot["battery_percent"], "as_of": snapshot["battery_as_of"]}
if snapshot["battery_percent"] >= 0
else None
{"percent": frame.battery_percent, "as_of": frame.battery_as_of}
if frame.battery_percent >= 0 else None
),
"battery_estimate_s": snapshot["battery_estimate_s"],
"battery_estimate_s": battery_estimate_s(frame, db),
},
}
@@ -387,461 +241,6 @@ def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = De
return {"log": [[ts, percent] for ts, percent in rows]}
class QueueReorderRequest(BaseModel):
queue: list[str]
@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."""
photo_widget = widget_of_type(db, frame, "photos")
if photo_widget is None:
raise HTTPException(404, "No photo widget on this frame")
with widget_locked(db, frame.id, photo_widget.id) as (_, _, cfg):
current_set = set(cfg.queue)
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
cfg.queue = reordered
return {"status": "saved"}
class QueuePromoteRequest(BaseModel):
asset_id: str
@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."""
photo_widget = widget_of_type(db, frame, "photos")
if photo_widget is None:
raise HTTPException(404, "No photo widget on this frame")
with widget_locked(db, frame.id, photo_widget.id) as (_, _, cfg):
if body.asset_id not in cfg.queue:
raise HTTPException(400, "That photo is no longer in the upcoming queue")
cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
return {"status": "saved"}
class QueueRemoveRequest(BaseModel):
asset_id: str
@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()."""
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
client = immich_client_for(frame)
assets = list_assets(client, pcfg.album_id)
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
photo_queue.remove_from_rotation(locked_pcfg, assets, body.asset_id, locked_frame)
return {"status": "removed"}
@router.get("/api/frames/{frame_id}/thumbnail/{asset_id}")
def api_thumbnail(
asset_id: str, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
"""Scoped to what this frame is actually showing/queuing -- a user
merely linked to view this frame shouldn't be able to pull thumbnails
for arbitrary asset ids in the owner's Immich library, only the
frame's own curated album. Same rule device.frame_share and
manage.manage_thumbnail already enforce."""
_, pcfg = photo_widget_config_or_404(db, frame)
if asset_id != pcfg.current_asset_id and asset_id not in pcfg.queue:
raise HTTPException(404, "Not on this frame")
client = immich_client_for(frame)
try:
content, content_type = client.download_asset_thumbnail(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
return Response(content=content, media_type=content_type)
def _current_asset_id(frame: Frame, db: Session) -> tuple[str, PhotoWidgetConfig]:
"""Same idempotent get_current() dance /api/frames/{id}/queue uses --
picks a current photo if none is set yet, otherwise just reads it,
never advances early. Returns the photo widget's own config
alongside the asset id, since callers (api_preview_rendered) also
need its display_mode."""
photo_widget, pcfg = photo_widget_config_or_404(db, frame)
client = immich_client_for(frame)
assets = list_assets(client, pcfg.album_id)
with widget_locked(db, frame.id, photo_widget.id) as (locked_frame, _, locked_pcfg):
photo_queue.get_current(locked_pcfg, assets, locked_frame,
in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
asset_id = locked_pcfg.current_asset_id
if not asset_id:
raise HTTPException(404, "No current photo")
return asset_id, pcfg
@router.get("/api/frames/{frame_id}/preview/original")
def api_preview_original(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
"""The Immich preview image behind the currently-displayed photo,
unprocessed -- the "now displaying" side of the Configuration tab's
before/after comparison."""
asset_id, _ = _current_asset_id(frame, db)
client = immich_client_for(frame)
try:
jpeg_bytes = client.download_asset_preview(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
return Response(content=jpeg_bytes, media_type="image/jpeg")
@router.get("/api/frames/{frame_id}/preview/rendered")
def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
"""The same photo run through this frame's actual saved rendering
pipeline (display mode, palette, color/contrast/dithering) and
exported as a PNG -- the "how it will look on the frame" side of the
comparison. Not a live preview of unsaved slider values; reflects
whatever's currently saved. display_mode comes from the photo
widget's own config now (palette/color/contrast/dither stay
frame-level -- one physical panel, one set of those)."""
asset_id, pcfg = _current_asset_id(frame, db)
client = immich_client_for(frame)
source, faces = fetch_source_and_faces(client, pcfg.display_mode, asset_id)
png = render_preview_png(
source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode=pcfg.display_mode, color_boost=frame.color_boost,
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
)
return Response(content=png, media_type="image/png")
class CalendarSelectRequest(BaseModel):
user_id: int
calendar_key: str
calendar_label: str = ""
included: bool
@router.post("/api/frames/{frame_id}/calendar-select")
def api_calendar_select(
body: CalendarSelectRequest,
request: Request,
frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control
db: Session = Depends(get_db),
):
"""Include/exclude one calendar (calendar_key "ics" or
"caldav:<href>", see FrameCalendar) on this frame. Deliberately not
require_frame_control: adding your own calendar, or muting anyone's
(including your own), is each viewer's own call, not something a
frame's controller manages on someone else's behalf. The one-sided
permission split lives here: turning a calendar ON requires being its
owner (nobody can add someone else's calendar to a shared frame for
them); turning one OFF only requires being linked to the frame at
all, so anyone sharing the display can mute a calendar they'd rather
not see there even if they don't own it."""
user = require_user_api(request, db)
if body.included and body.user_id != user.id:
raise HTTPException(403, "Only a calendar's owner can add it to a frame")
row = db.execute(
select(FrameCalendar).where(
FrameCalendar.frame_id == frame.id,
FrameCalendar.user_id == body.user_id,
FrameCalendar.calendar_key == body.calendar_key,
)
).scalar_one_or_none()
if row is None:
if not body.included:
raise HTTPException(404, "Not currently included on this frame")
row = FrameCalendar(frame_id=frame.id, user_id=body.user_id, calendar_key=body.calendar_key)
db.add(row)
row.included = body.included
if body.calendar_label:
row.calendar_label = body.calendar_label
# Force the frame's calendar widget's merged cache to pick up the
# change promptly rather than waiting out the throttle.
calendar_widget = widget_of_type(db, frame, "calendar")
if calendar_widget is not None:
db.get(CalendarWidgetConfig, calendar_widget.id).checked_at = 0.0
db.commit()
return {"status": "saved", "included": row.included}
CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS
class CalendarColorRequest(BaseModel):
calendar_key: str
color_index: int | None # None clears the pin, reverting to auto-cycle
@router.post("/api/frames/{frame_id}/calendar-color")
def api_calendar_color(
body: CalendarColorRequest,
request: Request,
frame: Frame = Depends(require_frame_view),
db: Session = Depends(get_db),
):
"""Pins a specific panel color to one of your own included calendars
(models.FrameCalendar.color_index) -- always owner-only, unlike
calendar-select's included=False, since recoloring someone else's
calendar isn't the same kind of "I'd rather not see this" veto as
muting it. None clears the pin, reverting calendar_render.py to its
old auto-cycle-by-owner-name behavior for this calendar."""
user = require_user_api(request, db)
if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE:
raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)")
row = db.execute(
select(FrameCalendar).where(
FrameCalendar.frame_id == frame.id,
FrameCalendar.user_id == user.id,
FrameCalendar.calendar_key == body.calendar_key,
)
).scalar_one_or_none()
if row is None:
raise HTTPException(404, "Not included on this frame")
row.color_index = body.color_index
calendar_widget = widget_of_type(db, frame, "calendar")
if calendar_widget is not None:
db.get(CalendarWidgetConfig, calendar_widget.id).checked_at = 0.0
db.commit()
return {"status": "saved", "color_index": row.color_index}
@router.get("/api/frames/{frame_id}/preview/calendar")
def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
"""The same merged, cached event set a live device render would use
-- not a live preview of an unsaved calendar_view choice, same
"reflects what's currently saved" convention as preview/rendered.
No photo_inlay parameter anymore -- that's not a widget-system
concept (see CalendarWidgetConfig's docstring); place an independent
photo widget alongside instead."""
calendar_widget = widget_of_type(db, frame, "calendar")
if calendar_widget is None:
raise HTTPException(400, "No calendar widget on this frame yet")
if not calendar_sources_for_frame(db, frame):
raise HTTPException(400, "No calendars included on this frame yet")
ccfg = db.get(CalendarWidgetConfig, calendar_widget.id)
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, calendar_widget)
weather_cities = get_or_refresh_weather_for_widget(db, frame, calendar_widget) if ccfg.weather_enabled else None
tasks = (
get_or_refresh_tasks_for_widget(db, frame, calendar_widget)
if (ccfg.view == "week" and ccfg.tasks_enabled) else None
)
png = calendar_render.render_calendar_preview_png(
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
week_start=ccfg.week_start,
weather_cities=weather_cities, weather_units=ccfg.weather_units,
week_days=ccfg.week_days, week_layout=ccfg.week_layout, tasks=tasks,
week_start_offset=ccfg.week_start_offset,
)
return Response(content=png, media_type="image/png")
class TasksSourceRequest(BaseModel):
calendar_key: str | None # None clears the source
@router.post("/api/frames/{frame_id}/tasks-source")
def api_tasks_source(
body: TasksSourceRequest,
request: Request,
frame: Frame = Depends(require_frame_view),
db: Session = Depends(get_db),
):
"""Points this frame's week-view task list at one of the calling
user's own CalDAV calendars -- same owner-controls-their-own-data
permission split as calendar-select's included=True, since this is
volunteering personal calendar data, not a frame-wide display
setting a controller should get to pick on someone else's behalf.
None clears the source; clearing (unlike setting) isn't
ownership-gated -- like muting a shared calendar, anyone linked to
the frame can turn off a task list they'd rather not see, but only
its owner can point the frame at one of their calendars to begin
with."""
user = require_user_api(request, db)
calendar_widget = widget_of_type(db, frame, "calendar")
if calendar_widget is None:
raise HTTPException(404, "No calendar widget on this frame yet")
with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg):
if body.calendar_key is None:
cfg.tasks_user_id = None
cfg.tasks_calendar_key = None
cfg.tasks_cached = None
else:
cfg.tasks_user_id = user.id
cfg.tasks_calendar_key = body.calendar_key
cfg.tasks_checked_at = 0.0 # pick up the change promptly
return {"status": "saved", "calendar_key": body.calendar_key}
class WhiteboardSourceRequest(BaseModel):
url: str | None # None clears the source
@router.post("/api/frames/{frame_id}/whiteboard-source")
def api_whiteboard_source(
body: WhiteboardSourceRequest,
request: Request,
frame: Frame = Depends(require_frame_view),
db: Session = Depends(get_db),
):
"""Points this frame's whiteboard at one of the calling user's own
WebDAV (or reused-CalDAV, see User.webdav_reuse_caldav_creds)
credentials -- same owner-controls-their-own-data permission split
as api_tasks_source: only the account owner can set the frame to use
it, but anyone linked to the frame can clear it, same as muting a
shared calendar."""
user = require_user_api(request, db)
whiteboard_widget = widget_of_type(db, frame, "whiteboard")
if whiteboard_widget is None:
raise HTTPException(404, "No whiteboard widget on this frame yet")
with widget_locked(db, frame.id, whiteboard_widget.id) as (_, _, cfg):
if body.url is None:
cfg.user_id = None
cfg.url = ""
cfg.cached_image = None
else:
stripped = body.url.strip()
if not valid_http_url(stripped):
raise HTTPException(400, "Whiteboard URL must be a plain http:// or https:// URL")
cfg.user_id = user.id
cfg.url = stripped
cfg.checked_at = 0.0 # pick up the change promptly
return {"status": "saved", "url": body.url}
@router.get("/api/frames/{frame_id}/whiteboard-browse")
def api_whiteboard_browse(
request: Request,
url: str | None = None,
frame: Frame = Depends(require_frame_view),
db: Session = Depends(get_db),
):
"""One level of a WebDAV directory listing, using the calling user's
own credentials (never the frame's saved whiteboard_user_id -- this
is "help me find a file in MY account", same person as whoever would
go on to Save it, before that's even happened) -- powers the file
picker on the Whiteboard tab as an alternative to pasting a URL.
`url` omitted/None starts from the user's webdav_base_url (see
models.py's User docstring); passing back a previous response's
`entries[].url` (for a folder) descends into it."""
user = require_user_api(request, db)
creds = webdav_creds_for(user)
if creds is None:
raise HTTPException(400, "Set up WebDAV credentials in Settings first")
target = url or user.webdav_base_url
if not target:
raise HTTPException(400, "Set a WebDAV browse root in Settings first, or paste the file URL directly")
if not valid_http_url(target):
raise HTTPException(400, "Browse URL must be a plain http:// or https:// URL")
try:
entries = webdav_client.list_directory(target, creds[0], creds[1])
except webdav_client.WebDavError as e:
raise HTTPException(502, f"Could not browse: {e}")
base = user.webdav_base_url or target
parent_url = webdav_client.parent_directory_url(base, target)
return {"current_url": target, "parent_url": parent_url, "entries": entries}
@router.get("/api/frames/{frame_id}/preview/whiteboard")
def api_preview_whiteboard(
force: bool = False, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
):
"""The same throttled fetch/render cache a live device request would
use, run through the same panel composition/quantization pipeline
(see routers/device.py's _render_whiteboard_mode) -- "how it will
look on the frame" (dithered, letterboxed), not just the raw
Excalidraw export, same convention as preview/rendered and
preview/calendar. force=True (the "Refresh now" button, as opposed
to just reopening this tab) bypasses the fetch throttle."""
whiteboard_widget = widget_of_type(db, frame, "whiteboard")
if whiteboard_widget is None:
raise HTTPException(400, "No whiteboard widget on this frame yet")
wcfg = db.get(WhiteboardWidgetConfig, whiteboard_widget.id)
png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, whiteboard_widget, force=force)
if png_bytes is None:
if not wcfg.url:
raise HTTPException(400, "No whiteboard configured on this frame yet")
raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
import io
from PIL import Image
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
png = render_preview_png(
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode="letterbox",
)
return Response(content=png, media_type="image/png")
class WeatherCityAddRequest(BaseModel):
name: str
@router.post("/api/frames/{frame_id}/weather-cities/add")
def api_weather_city_add(
body: WeatherCityAddRequest,
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
"""Geocodes a free-text city name (e.g. "Portland, OR") and adds it
to this frame's weather strip -- a frame-wide display setting (like
calendar_view), not personal data, so this is gated the same way as
api_config_save rather than the calendar-select owner/mute split."""
calendar_widget = widget_of_type(db, frame, "calendar")
if calendar_widget is None:
raise HTTPException(404, "No calendar widget on this frame yet")
try:
city = weather.geocode_city(body.name)
except weather.WeatherFetchError as e:
raise HTTPException(400, str(e)) from e
with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg):
cities = list(cfg.weather_cities or [])
if any(c["label"] == city["label"] for c in cities):
raise HTTPException(400, f"{city['label']} is already on this frame's list")
cities.append(city)
cfg.weather_cities = cities
cfg.weather_checked_at = 0.0 # pick up the new city promptly
return {"status": "saved", "city": city}
class WeatherCityRemoveRequest(BaseModel):
label: str
@router.post("/api/frames/{frame_id}/weather-cities/remove")
def api_weather_city_remove(
body: WeatherCityRemoveRequest,
frame: Frame = Depends(require_frame_control),
db: Session = Depends(get_db),
):
calendar_widget = widget_of_type(db, frame, "calendar")
if calendar_widget is None:
raise HTTPException(404, "No calendar widget on this frame yet")
with widget_locked(db, frame.id, calendar_widget.id) as (_, _, cfg):
cities = [c for c in (cfg.weather_cities or []) if c["label"] != body.label]
cfg.weather_cities = cities
cached = [c for c in (cfg.weather_cached or []) if c["label"] != body.label]
cfg.weather_cached = cached
return {"status": "saved"}
@router.post("/api/frames/{frame_id}/firmware")
def api_firmware_upload(
+659 -7
View File
@@ -1,33 +1,113 @@
"""CRUD + grid placement for a frame's widgets (see models.Widget) --
backs the Layout tab's placement canvas (static/frame_widget_canvas.js).
Every mutation re-validates bounds/minimum footprint/no-overlap
"""Everything scoped to one specific widget rather than "the frame":
placement CRUD (backing the Layout tab's canvas, static/frame_layout.js)
plus every setting/action that used to assume a frame had at most one
widget of a given type -- photo queue, calendar inclusion/color/tasks,
whiteboard source, and their preview endpoints. Split out of
api_frames.py (which keeps frame-wide settings: orientation, quiet
hours, palette, firmware, stats) once a frame could hold more than one
widget of the same type, at which point "the frame's calendar settings"
stopped meaning anything unambiguous.
Placement mutations re-validate bounds/minimum footprint/no-overlap
server-side regardless of what the client already checked -- the
client's own checks are UX, not the source of truth (this project's
usual posture, e.g. api_frames.py's own field clamps)."""
from __future__ import annotations
import io
import time
from fastapi import APIRouter, Depends, HTTPException, Request
import httpx
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import Response
from PIL import Image
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import grid
from .. import calendar_render, grid, photo_queue, quiet_hours, weather, webdav_client
from ..auth import require_frame_control, require_frame_view, require_user_api
from ..db import frame_locked, get_db
from ..models import Frame, WIDGET_CONFIG_MODELS, Widget
from ..db import frame_locked, get_db, widget_locked
from ..image_pipeline import DEFAULT_DISPLAY_MODE, DISPLAY_MODES, render_preview_png
from ..models import (
CalendarWidgetConfig,
Frame,
FrameCalendar,
PhotoWidgetConfig,
WhiteboardWidgetConfig,
WIDGET_CONFIG_MODELS,
Widget,
)
from ..widgets import WIDGET_TYPES
from .common import (
calendar_sources_for_widget,
fetch_source_and_faces,
get_or_refresh_calendar_events_for_widget,
get_or_refresh_tasks_for_widget,
get_or_refresh_weather_for_widget,
get_or_refresh_whiteboard_for_widget,
immich_client_for,
immich_creds,
list_assets,
valid_http_url,
webdav_creds_for,
)
router = APIRouter()
MIN_QUEUE_TARGET_LEN = 5
MAX_QUEUE_TARGET_LEN = 5000
CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS
def _widget_dict(w: Widget) -> dict:
return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h,
"sort_order": w.sort_order}
def require_widget_view(
widget_id: int, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
) -> tuple[Frame, Widget]:
"""View-only widget dependency -- same 404-not-403 posture as
require_frame_view for a widget id that doesn't belong to this
frame (or doesn't exist at all)."""
widget = db.get(Widget, widget_id)
if widget is None or widget.frame_id != frame.id:
raise HTTPException(404, "No such widget")
return frame, widget
def require_widget_control(
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
) -> tuple[Frame, Widget]:
"""Same as require_widget_view, but behind the frame's "take control"
soft lock -- for endpoints that mutate the widget's own settings."""
widget = db.get(Widget, widget_id)
if widget is None or widget.frame_id != frame.id:
raise HTTPException(404, "No such widget")
return frame, widget
def _require_widget_type(widget: Widget, expected: str) -> None:
if widget.widget_type != expected:
raise HTTPException(400, f"This widget is a {widget.widget_type} widget, not {expected}")
def _photo_config_or_400(db: Session, frame: Frame, widget: Widget) -> PhotoWidgetConfig:
"""Same 400 shape routers/common.py's photo_widget_config_or_404 uses
for a frame with no configured photo widget at all, here for a widget
we already know is a photos widget -- Immich creds are frame/owner-
level, album_id is this widget's own."""
url, key = immich_creds(frame)
if not url or not key:
raise HTTPException(400, "Immich URL/API key not configured yet")
pcfg = db.get(PhotoWidgetConfig, widget.id)
if not pcfg.album_id:
raise HTTPException(400, "No album configured yet")
return pcfg
@router.get("/api/frames/{frame_id}/widgets")
def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
user = require_user_api(request, db)
@@ -140,3 +220,575 @@ def api_widget_delete(
db.delete(widget)
db.commit()
return {"status": "deleted"}
# --- Per-widget-type config save (the gear-icon dialog's Save button) --------
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/config")
def api_widget_config_save(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
# photos
album_id: str | None = Form(None),
order: str | None = Form(None),
display_mode: str | None = Form(None),
queue_target_len: int | None = Form(None),
# calendar
calendar_view: str | None = Form(None),
calendar_week_start: int | None = Form(None),
calendar_week_days: int | None = Form(None),
calendar_week_layout: str | None = Form(None),
calendar_week_start_offset: int | None = Form(None),
calendar_weather_enabled: bool | None = Form(None),
calendar_weather_units: str | None = Form(None),
calendar_tasks_enabled: bool | None = Form(None),
):
"""Every field optional -- same partial-update, form-urlencoded
convention as the old frame-level api_config_save, now scoped to one
widget instead of "the frame's widget of this type". Fields that
don't apply to this widget's own widget_type are simply ignored,
same posture as an unrecognized form field always had here."""
frame, widget = frame_widget
if widget.widget_type == "photos":
with widget_locked(db, frame.id, widget.id) as (_, _, pcfg):
if album_id is not None and album_id != pcfg.album_id:
# A newly selected album starts clean -- the old current
# photo and queue don't mean anything in the new album's
# context.
pcfg.current_asset_id = ""
pcfg.current_asset_set_at = 0.0
pcfg.queue = []
pcfg.queue_cursor = 0
pcfg.history = []
pcfg.excluded_asset_ids = []
pcfg.album_id = album_id
if order is not None:
pcfg.order = order if order in ("sequential", "shuffle") else "sequential"
if display_mode is not None:
pcfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
if queue_target_len is not None:
pcfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
elif widget.widget_type == "calendar":
with widget_locked(db, frame.id, widget.id) as (_, _, ccfg):
if calendar_view is not None:
new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
if new_view != ccfg.view:
# A stale offset means something different in a
# different view's units (days vs. weeks vs. months).
ccfg.browse_offset = 0
ccfg.view = new_view
if calendar_week_start is not None:
ccfg.week_start = max(0, min(6, calendar_week_start))
if calendar_week_days is not None:
new_days = max(2, min(10, calendar_week_days))
if new_days != ccfg.week_days:
# A stale offset counts a different-sized page under
# the old day count.
ccfg.browse_offset = 0
ccfg.week_days = new_days
if calendar_week_layout is not None:
ccfg.week_layout = (
calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal"
)
if calendar_week_start_offset is not None:
new_offset = max(-30, min(30, calendar_week_start_offset))
if new_offset != ccfg.week_start_offset:
ccfg.browse_offset = 0
ccfg.week_start_offset = new_offset
if calendar_weather_enabled is not None:
ccfg.weather_enabled = calendar_weather_enabled
if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"):
if calendar_weather_units != ccfg.weather_units:
# Cached forecasts are in the old unit -- force a
# refetch rather than showing stale numbers under a
# new unit label.
ccfg.weather_checked_at = 0.0
ccfg.weather_units = calendar_weather_units
if calendar_tasks_enabled is not None:
ccfg.tasks_enabled = calendar_tasks_enabled
with frame_locked(db, frame.id) as cfg:
cfg.stats_config_saves += 1
return {"status": "saved"}
# --- Photos: queue/thumbnail/preview ------------------------------------
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/queue")
def api_widget_queue(
request: Request, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
frame, widget = frame_widget
_require_widget_type(widget, "photos")
user = require_user_api(request, db)
pcfg = _photo_config_or_400(db, frame, widget)
client = immich_client_for(frame)
assets = list_assets(client, pcfg.album_id)
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_pcfg):
photo_queue.get_current(locked_pcfg, assets, locked_frame,
in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
photo_queue.sync_queue_length(locked_pcfg, assets)
current_asset_id = locked_pcfg.current_asset_id
queue = list(locked_pcfg.queue)
controller_id = locked_frame.controlled_by_user_id
controller = (
(locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
if locked_frame.controlled_by else None
)
def entry(asset_id: str) -> dict:
return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/widgets/{widget.id}/thumbnail/{asset_id}"}
return {
"current": entry(current_asset_id) if current_asset_id else None,
"upcoming": [entry(asset_id) for asset_id in queue],
"control": {"controller": controller, "you": controller_id == user.id},
}
class QueueReorderRequest(BaseModel):
queue: list[str]
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/queue/reorder")
def api_widget_queue_reorder(
body: QueueReorderRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_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, widget = frame_widget
_require_widget_type(widget, "photos")
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
current_set = set(cfg.queue)
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
cfg.queue = reordered
return {"status": "saved"}
class QueuePromoteRequest(BaseModel):
asset_id: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/queue/promote")
def api_widget_queue_promote(
body: QueuePromoteRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Moves a single photo to the front of the queue -- "Show next"."""
frame, widget = frame_widget
_require_widget_type(widget, "photos")
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
if body.asset_id not in cfg.queue:
raise HTTPException(400, "That photo is no longer in the upcoming queue")
cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
return {"status": "saved"}
class QueueRemoveRequest(BaseModel):
asset_id: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/queue/remove")
def api_widget_queue_remove(
body: QueueRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Permanently removes a photo from this widget's rotation. Does NOT
touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
frame, widget = frame_widget
_require_widget_type(widget, "photos")
pcfg = _photo_config_or_400(db, frame, widget)
client = immich_client_for(frame)
assets = list_assets(client, pcfg.album_id)
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_pcfg):
photo_queue.remove_from_rotation(locked_pcfg, assets, body.asset_id, locked_frame)
return {"status": "removed"}
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/thumbnail/{asset_id}")
def api_widget_thumbnail(
asset_id: str, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""Scoped to what this widget is actually showing/queuing -- a user
merely linked to view this frame shouldn't be able to pull thumbnails
for arbitrary asset ids in the owner's Immich library, only this
widget's own curated album. Same rule device.frame_share and
manage.manage_thumbnail already enforce."""
frame, widget = frame_widget
_require_widget_type(widget, "photos")
pcfg = db.get(PhotoWidgetConfig, widget.id)
if asset_id != pcfg.current_asset_id and asset_id not in pcfg.queue:
raise HTTPException(404, "Not on this frame")
client = immich_client_for(frame)
try:
content, content_type = client.download_asset_thumbnail(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
return Response(content=content, media_type=content_type)
def _current_asset_id(db: Session, frame: Frame, widget: Widget) -> tuple[str, PhotoWidgetConfig]:
"""Same idempotent get_current() dance the queue endpoint uses --
picks a current photo if none is set yet, otherwise just reads it,
never advances early."""
pcfg = _photo_config_or_400(db, frame, widget)
client = immich_client_for(frame)
assets = list_assets(client, pcfg.album_id)
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_pcfg):
photo_queue.get_current(locked_pcfg, assets, locked_frame,
in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
asset_id = locked_pcfg.current_asset_id
if not asset_id:
raise HTTPException(404, "No current photo")
return asset_id, pcfg
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/original")
def api_widget_preview_original(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
):
"""The Immich preview image behind the currently-displayed photo,
unprocessed -- the "now displaying" side of the dialog's before/after
comparison."""
frame, widget = frame_widget
_require_widget_type(widget, "photos")
asset_id, _ = _current_asset_id(db, frame, widget)
client = immich_client_for(frame)
try:
jpeg_bytes = client.download_asset_preview(asset_id)
except httpx.HTTPError as e:
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
return Response(content=jpeg_bytes, media_type="image/jpeg")
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/rendered")
def api_widget_preview_rendered(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
):
"""The same photo run through this frame's actual saved rendering
pipeline (display mode, palette, color/contrast/dithering) and
exported as a PNG -- the "how it will look on the frame" side of the
comparison. Not a live preview of unsaved slider values; reflects
whatever's currently saved. display_mode comes from this widget's own
config (palette/color/contrast/dither stay frame-level -- one
physical panel, one set of those)."""
frame, widget = frame_widget
_require_widget_type(widget, "photos")
asset_id, pcfg = _current_asset_id(db, frame, widget)
client = immich_client_for(frame)
source, faces = fetch_source_and_faces(client, pcfg.display_mode, asset_id)
png = render_preview_png(
source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode=pcfg.display_mode, color_boost=frame.color_boost,
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
)
return Response(content=png, media_type="image/png")
# --- Calendar: inclusion/color/tasks/weather/preview --------------------
class CalendarSelectRequest(BaseModel):
user_id: int
calendar_key: str
calendar_label: str = ""
included: bool
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/calendar-select")
def api_widget_calendar_select(
body: CalendarSelectRequest, request: Request,
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), # view access only -- NOT control
db: Session = Depends(get_db),
):
"""Include/exclude one calendar (calendar_key "ics" or
"caldav:<href>", see FrameCalendar) on this calendar widget.
Deliberately not require_widget_control: adding your own calendar, or
muting anyone's (including your own), is each viewer's own call, not
something a frame's controller manages on someone else's behalf. The
one-sided permission split lives here: turning a calendar ON requires
being its owner (nobody can add someone else's calendar to a shared
frame for them); turning one OFF only requires being linked to the
frame at all, so anyone sharing the display can mute a calendar
they'd rather not see there even if they don't own it."""
frame, widget = frame_widget
_require_widget_type(widget, "calendar")
user = require_user_api(request, db)
if body.included and body.user_id != user.id:
raise HTTPException(403, "Only a calendar's owner can add it to a frame")
row = db.execute(
select(FrameCalendar).where(
FrameCalendar.widget_id == widget.id,
FrameCalendar.user_id == body.user_id,
FrameCalendar.calendar_key == body.calendar_key,
)
).scalar_one_or_none()
if row is None:
if not body.included:
raise HTTPException(404, "Not currently included on this widget")
row = FrameCalendar(widget_id=widget.id, user_id=body.user_id, calendar_key=body.calendar_key)
db.add(row)
row.included = body.included
if body.calendar_label:
row.calendar_label = body.calendar_label
# Force this widget's merged cache to pick up the change promptly
# rather than waiting out the throttle.
db.get(CalendarWidgetConfig, widget.id).checked_at = 0.0
db.commit()
return {"status": "saved", "included": row.included}
class CalendarColorRequest(BaseModel):
calendar_key: str
color_index: int | None # None clears the pin, reverting to auto-cycle
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/calendar-color")
def api_widget_calendar_color(
body: CalendarColorRequest, request: Request,
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""Pins a specific panel color to one of your own included calendars
(models.FrameCalendar.color_index) -- always owner-only, unlike
calendar-select's included=False, since recoloring someone else's
calendar isn't the same kind of "I'd rather not see this" veto as
muting it. None clears the pin, reverting calendar_render.py to its
old auto-cycle-by-owner-name behavior for this calendar."""
frame, widget = frame_widget
_require_widget_type(widget, "calendar")
user = require_user_api(request, db)
if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE:
raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)")
row = db.execute(
select(FrameCalendar).where(
FrameCalendar.widget_id == widget.id,
FrameCalendar.user_id == user.id,
FrameCalendar.calendar_key == body.calendar_key,
)
).scalar_one_or_none()
if row is None:
raise HTTPException(404, "Not included on this widget")
row.color_index = body.color_index
db.get(CalendarWidgetConfig, widget.id).checked_at = 0.0
db.commit()
return {"status": "saved", "color_index": row.color_index}
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/calendar")
def api_widget_preview_calendar(
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
):
"""The same merged, cached event set a live device render would use --
not a live preview of an unsaved calendar_view choice, same "reflects
what's currently saved" convention as preview/rendered."""
frame, widget = frame_widget
_require_widget_type(widget, "calendar")
if not calendar_sources_for_widget(db, widget):
raise HTTPException(400, "No calendars included on this widget yet")
ccfg = db.get(CalendarWidgetConfig, widget.id)
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None
tasks = (
get_or_refresh_tasks_for_widget(db, frame, widget)
if (ccfg.view == "week" and ccfg.tasks_enabled) else None
)
png = calendar_render.render_calendar_preview_png(
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
week_start=ccfg.week_start,
weather_cities=weather_cities, weather_units=ccfg.weather_units,
week_days=ccfg.week_days, week_layout=ccfg.week_layout, tasks=tasks,
week_start_offset=ccfg.week_start_offset,
)
return Response(content=png, media_type="image/png")
class TasksSourceRequest(BaseModel):
calendar_key: str | None # None clears the source
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/tasks-source")
def api_widget_tasks_source(
body: TasksSourceRequest, request: Request,
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""Points this widget's week-view task list at one of the calling
user's own CalDAV calendars -- same owner-controls-their-own-data
permission split as calendar-select's included=True, since this is
volunteering personal calendar data, not a display setting a
controller should get to pick on someone else's behalf. None clears
the source; clearing (unlike setting) isn't ownership-gated -- like
muting a shared calendar, anyone linked to the frame can turn off a
task list they'd rather not see, but only its owner can point the
widget at one of their calendars to begin with."""
frame, widget = frame_widget
_require_widget_type(widget, "calendar")
user = require_user_api(request, db)
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
if body.calendar_key is None:
cfg.tasks_user_id = None
cfg.tasks_calendar_key = None
cfg.tasks_cached = None
else:
cfg.tasks_user_id = user.id
cfg.tasks_calendar_key = body.calendar_key
cfg.tasks_checked_at = 0.0 # pick up the change promptly
return {"status": "saved", "calendar_key": body.calendar_key}
class WeatherCityAddRequest(BaseModel):
name: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-cities/add")
def api_widget_weather_city_add(
body: WeatherCityAddRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
"""Geocodes a free-text city name (e.g. "Portland, OR") and adds it to
this widget's weather strip -- a widget-wide display setting (like
calendar_view), not personal data, so this is gated the same way as
the config-save endpoint rather than the calendar-select owner/mute
split."""
frame, widget = frame_widget
_require_widget_type(widget, "calendar")
try:
city = weather.geocode_city(body.name)
except weather.WeatherFetchError as e:
raise HTTPException(400, str(e)) from e
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cities = list(cfg.weather_cities or [])
if any(c["label"] == city["label"] for c in cities):
raise HTTPException(400, f"{city['label']} is already on this widget's list")
cities.append(city)
cfg.weather_cities = cities
cfg.weather_checked_at = 0.0 # pick up the new city promptly
return {"status": "saved", "city": city}
class WeatherCityRemoveRequest(BaseModel):
label: str
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-cities/remove")
def api_widget_weather_city_remove(
body: WeatherCityRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
db: Session = Depends(get_db),
):
frame, widget = frame_widget
_require_widget_type(widget, "calendar")
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
cities = [c for c in (cfg.weather_cities or []) if c["label"] != body.label]
cfg.weather_cities = cities
cached = [c for c in (cfg.weather_cached or []) if c["label"] != body.label]
cfg.weather_cached = cached
return {"status": "saved"}
# --- Whiteboard: source/preview ------------------------------------------
class WhiteboardSourceRequest(BaseModel):
url: str | None # None clears the source
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/whiteboard-source")
def api_widget_whiteboard_source(
body: WhiteboardSourceRequest, request: Request,
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""Points this widget at one of the calling user's own WebDAV (or
reused-CalDAV, see User.webdav_reuse_caldav_creds) credentials --
same owner-controls-their-own-data permission split as
api_widget_tasks_source: only the account owner can set the widget to
use it, but anyone linked to the frame can clear it, same as muting a
shared calendar."""
frame, widget = frame_widget
_require_widget_type(widget, "whiteboard")
user = require_user_api(request, db)
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
if body.url is None:
cfg.user_id = None
cfg.url = ""
cfg.cached_image = None
else:
stripped = body.url.strip()
if not valid_http_url(stripped):
raise HTTPException(400, "Whiteboard URL must be a plain http:// or https:// URL")
cfg.user_id = user.id
cfg.url = stripped
cfg.checked_at = 0.0 # pick up the change promptly
return {"status": "saved", "url": body.url}
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/whiteboard-browse")
def api_widget_whiteboard_browse(
request: Request, url: str | None = None,
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""One level of a WebDAV directory listing, using the calling user's
own credentials (never this widget's saved user_id -- this is "help
me find a file in MY account", same person as whoever would go on to
Save it, before that's even happened) -- powers the file picker in
the whiteboard dialog as an alternative to pasting a URL. Nested
under this widget's own path purely so the dialog's JS can keep using
one shared window.FRAME_API base for every call it makes -- the
lookup itself doesn't touch this (or any) widget's own state. `url`
omitted/None starts from the user's webdav_base_url (see models.py's
User docstring); passing back a previous response's `entries[].url`
(for a folder) descends into it."""
frame, widget = frame_widget
_require_widget_type(widget, "whiteboard")
user = require_user_api(request, db)
creds = webdav_creds_for(user)
if creds is None:
raise HTTPException(400, "Set up WebDAV credentials in Settings first")
target = url or user.webdav_base_url
if not target:
raise HTTPException(400, "Set a WebDAV browse root in Settings first, or paste the file URL directly")
if not valid_http_url(target):
raise HTTPException(400, "Browse URL must be a plain http:// or https:// URL")
try:
entries = webdav_client.list_directory(target, creds[0], creds[1])
except webdav_client.WebDavError as e:
raise HTTPException(502, f"Could not browse: {e}")
base = user.webdav_base_url or target
parent_url = webdav_client.parent_directory_url(base, target)
return {"current_url": target, "parent_url": parent_url, "entries": entries}
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/whiteboard")
def api_widget_preview_whiteboard(
force: bool = False, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
db: Session = Depends(get_db),
):
"""The same throttled fetch/render cache a live device request would
use, run through the same panel composition/quantization pipeline --
"how it will look on the frame" (dithered, letterboxed), not just the
raw Excalidraw export, same convention as the other preview
endpoints. force=True (the "Refresh now" button, as opposed to just
reopening the dialog) bypasses the fetch throttle."""
frame, widget = frame_widget
_require_widget_type(widget, "whiteboard")
wcfg = db.get(WhiteboardWidgetConfig, widget.id)
png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, widget, force=force)
if png_bytes is None:
if not wcfg.url:
raise HTTPException(400, "No whiteboard configured on this widget yet")
raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
png = render_preview_png(
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
display_mode="letterbox",
)
return Response(content=png, media_type="image/png")
+14 -21
View File
@@ -97,12 +97,11 @@ def fetch_source_and_faces(
client: ImmichClient, display_mode: str, asset_id: str
) -> tuple[Image.Image, list[dict] | None]:
"""The shared first half of rendering: download the Immich preview
and (only if display_mode needs it) its detected faces. Used by both
render_asset (device-facing) and the web UI's rendered-preview
endpoint (routers/api_frames.py) so they can't drift apart. Takes
display_mode directly (a photos widget's own setting, see
PhotoWidgetConfig) rather than a whole Frame -- this function only
ever needed that one attribute off it."""
and (only if display_mode needs it) its detected faces. Used by the
web UI's rendered-preview endpoint (routers/api_widgets.py's
api_widget_preview_rendered). Takes display_mode directly (a photos
widget's own setting, see PhotoWidgetConfig) rather than a whole
Frame -- this function only ever needed that one attribute off it."""
try:
jpeg_bytes = client.download_asset_preview(asset_id)
except httpx.HTTPError as e:
@@ -477,16 +476,16 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
return content
def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed.CalendarSource]:
"""Every calendar included on this frame (FrameCalendar.included) --
the exact set calendar_feed.merge_events needs. A calendar_key of
"ics" resolves against its owner's calendar_ics_url; "caldav:<href>"
resolves against the href itself, authenticated with the owner's
CalDAV account credentials (see caldav_client.py)."""
def calendar_sources_for_widget(db: Session, widget: Widget) -> list[calendar_feed.CalendarSource]:
"""Every calendar included on this calendar widget (FrameCalendar.
included) -- the exact set calendar_feed.merge_events needs. A
calendar_key of "ics" resolves against its owner's calendar_ics_url;
"caldav:<href>" resolves against the href itself, authenticated with
the owner's CalDAV account credentials (see caldav_client.py)."""
rows = db.execute(
select(FrameCalendar, User)
.join(User, User.id == FrameCalendar.user_id)
.where(FrameCalendar.frame_id == frame.id, FrameCalendar.included == True) # noqa: E712
.where(FrameCalendar.widget_id == widget.id, FrameCalendar.included == True) # noqa: E712
).all()
sources = []
for fc, u in rows:
@@ -513,19 +512,13 @@ def get_or_refresh_calendar_events_for_widget(db: Session, frame: Frame, widget:
whole merged result (every included user's events together), not
per-user -- ICS feeds are small and this refetches at most every ~20
minutes regardless of how many are included, so per-user cache
columns would add bookkeeping for a marginal benefit.
calendar_sources_for_frame stays frame_id-scoped (see FrameCalendar's
own docstring) until a later phase re-keys it to widget_id, so every
calendar widget on a frame currently shares the same "included
calendars" set -- not a real limitation yet since nothing supports
more than one calendar widget per frame end to end until that phase
lands."""
columns would add bookkeeping for a marginal benefit."""
cfg = db.get(CalendarWidgetConfig, widget.id)
now = time.time()
if cfg.cached_events is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
return cfg.cached_events, cfg.fetch_summary
sources = calendar_sources_for_frame(db, frame)
sources = calendar_sources_for_widget(db, widget)
today = quiet_hours.local_date(frame)
events, summary = calendar_feed.merge_events(
sources,
+110 -101
View File
@@ -1,6 +1,12 @@
"""The per-frame HTML pages: Photos (/frames/{id}), Configuration,
Calendar, and Stats tabs, all inside the sidebar app shell. Data loading
happens client-side against /api/frames/{id}/... (routers/api_frames.py);
"""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
@@ -20,7 +26,16 @@ from ..image_pipeline import (
PALETTE_LABELS,
palette_to_hex,
)
from ..models import CalendarWidgetConfig, Frame, FrameCalendar, PhotoWidgetConfig, User, UserFrame, WhiteboardWidgetConfig
from ..models import (
CalendarWidgetConfig,
Frame,
FrameCalendar,
PhotoWidgetConfig,
User,
UserFrame,
WhiteboardWidgetConfig,
Widget,
)
from ..quiet_hours import ALL_TIMEZONES
from .common import shell_context, widget_of_type
@@ -36,37 +51,42 @@ def _frame_page(request: Request, db: Session, frame_id: int, template: str, tab
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,
"has_calendar_widget": widget_of_type(db, frame, "calendar") is not None,
"has_whiteboard_widget": widget_of_type(db, frame, "whiteboard") is not None,
**extra,
})
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)):
frame = db.get(Frame, frame_id)
photo_cfg = None
if frame is not None:
photo_widget = widget_of_type(db, frame, "photos")
if photo_widget is not None:
photo_cfg = db.get(PhotoWidgetConfig, photo_widget.id)
return _frame_page(
request, db, frame_id, "frame_photos.html", "photos",
display_mode_labels=DISPLAY_MODE_LABELS,
photo_cfg=photo_cfg,
)
@router.get("/frames/{frame_id}/layout", 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 frame:
"""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."""
@@ -78,20 +98,20 @@ def _user_available_calendars(user: User) -> list[dict]:
return calendars
def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None) -> list[dict]:
"""Per-linked-user calendar list for the Calendar tab's "Included
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_frames.py's api_calendar_select:
only a calendar's owner may turn it on, but anyone linked to the
frame may turn one off)."""
(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.frame_id == frame_id)).scalars().all():
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 = []
@@ -116,12 +136,12 @@ def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None)
return result
def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig | None) -> dict | None:
"""Whose CalDAV calendar this frame's week-view task list currently
def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig) -> dict | None:
"""Whose CalDAV calendar this widget's week-view task list currently
pulls from, and its label -- for showing "using <name>'s Chores
list" to everyone linked, not just whoever set it. None if no
source is configured."""
if calendar_cfg is None or not calendar_cfg.tasks_user_id or not calendar_cfg.tasks_calendar_key:
if not calendar_cfg.tasks_user_id or not calendar_cfg.tasks_calendar_key:
return None
user = db.get(User, calendar_cfg.tasks_user_id)
if user is None:
@@ -134,52 +154,11 @@ def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig | None) -
return {"user_id": user.id, "display_name": user.display_name or user.username, "label": label}
@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,
palette_labels=PALETTE_LABELS,
default_palette_rgb=DEFAULT_PALETTE_RGB,
palette_to_hex=palette_to_hex,
)
WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday",
4: "Friday", 5: "Saturday", 6: "Sunday"}
@router.get("/frames/{frame_id}/calendar", response_class=HTMLResponse)
def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
viewer = current_user(request, db)
frame = db.get(Frame, frame_id)
viewer_task_calendars = []
calendar_cfg = None
if viewer is not None and frame is not None and can_view_frame(db, viewer, frame):
viewer_task_calendars = [c for c in _user_available_calendars(viewer) if c["key"].startswith("caldav:")]
if frame is not None:
calendar_widget = widget_of_type(db, frame, "calendar")
if calendar_widget is not None:
calendar_cfg = db.get(CalendarWidgetConfig, calendar_widget.id)
return _frame_page(
request, db, frame_id, "frame_calendar.html", "calendar",
calendar_views=CALENDAR_VIEW_LABELS,
calendar_users=_calendar_users_for_frame(db, frame_id, viewer.id if viewer else None),
week_start_labels=WEEK_START_LABELS,
calendar_color_labels=PALETTE_LABELS,
default_palette_rgb=DEFAULT_PALETTE_RGB,
palette_to_hex=palette_to_hex,
viewer_task_calendars=viewer_task_calendars,
calendar_cfg=calendar_cfg,
tasks_source=_tasks_source_info(db, calendar_cfg),
)
def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig | None) -> dict | None:
"""Whose account this frame's whiteboard currently fetches with, for
showing "using <name>'s account" to everyone linked, not just
whoever set it. None if no source is configured."""
if whiteboard_cfg is None or not whiteboard_cfg.user_id or not whiteboard_cfg.url:
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:
@@ -187,27 +166,57 @@ def _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig
return {"user_id": user.id, "display_name": user.display_name or user.username, "url": whiteboard_cfg.url}
@router.get("/frames/{frame_id}/whiteboard", response_class=HTMLResponse)
def frame_whiteboard_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
viewer = current_user(request, db)
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)
viewer_has_webdav_creds = False
whiteboard_cfg = None
if viewer is not None and frame is not None and can_view_frame(db, viewer, frame):
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)
viewer_task_calendars = [c for c in _user_available_calendars(user) if c["key"].startswith("caldav:")]
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,
"viewer_task_calendars": viewer_task_calendars,
"tasks_source": _tasks_source_info(db, calendar_cfg),
})
if widget.widget_type == "whiteboard":
whiteboard_cfg = db.get(WhiteboardWidgetConfig, widget.id)
viewer_has_webdav_creds = bool(
viewer.webdav_username or (viewer.webdav_reuse_caldav_creds and viewer.calendar_caldav_username)
user.webdav_username or (user.webdav_reuse_caldav_creds and user.calendar_caldav_username)
)
if frame is not None:
whiteboard_widget = widget_of_type(db, frame, "whiteboard")
if whiteboard_widget is not None:
whiteboard_cfg = db.get(WhiteboardWidgetConfig, whiteboard_widget.id)
return _frame_page(
request, db, frame_id, "frame_whiteboard.html", "whiteboard",
whiteboard_source=_whiteboard_source_info(db, whiteboard_cfg),
viewer_has_webdav_creds=viewer_has_webdav_creds,
)
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,
})
@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")
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
+5 -2
View File
@@ -1,7 +1,10 @@
// Device status bar: always-visible strip (below the page title, above
// the tabs -- see _device_status_bar.html) showing last-seen/firmware/
// battery, so it's not tucked away on just the Stats tab. Shared by
// every frame page; each sets window.FRAME_API before this loads.
// every frame page; each sets window.FRAME_BASE_API before this loads
// -- a stable frame-level base, unlike window.FRAME_API, which the
// Layout page's widget dialogs repoint to a widget-scoped base while
// one is open.
let lastDeviceStatus = null;
@@ -51,7 +54,7 @@ function renderDeviceStatusBar(device) {
async function loadDeviceStatusBar() {
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
const resp = await fetch(`${window.FRAME_BASE_API}/status`);
if (!resp.ok) {
return;
}
-308
View File
@@ -1,308 +0,0 @@
// Calendar tab: view/week-start settings, per-user opt-in, and the
// rendered preview. Extracted from frame_config.js when the Calendar
// card became its own tab (window.FRAME_API is set by the template;
// checkboxes are always sent explicitly as "true"/"false").
// Week-view-only settings (days/layout/start-offset) only matter when
// View is actually "Week"; "Week starts on" also matters for Month, so
// it gets its own, slightly looser condition. The start-offset row is
// further gated on the day count -- it's meaningless at the default 7
// days, where "Week starts on" governs instead (see
// calendar_render.py's _build_week).
function updateCalendarFieldVisibility() {
const view = document.getElementById('calendar_view').value;
const days = Number(document.getElementById('calendar_week_days').value);
const isWeek = view === 'week';
document.getElementById('calendar-week-start-row').style.display =
(view === 'week' || view === 'month') ? '' : 'none';
document.getElementById('calendar-week-days-row').style.display = isWeek ? '' : 'none';
document.getElementById('calendar-week-layout-row').style.display = isWeek ? '' : 'none';
document.getElementById('calendar-week-offset-row').style.display = (isWeek && days !== 7) ? '' : 'none';
}
document.getElementById('calendar_view').addEventListener('change', updateCalendarFieldVisibility);
document.getElementById('calendar_week_days').addEventListener('input', updateCalendarFieldVisibility);
updateCalendarFieldVisibility();
document.getElementById('calendar-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
calendar_view: document.getElementById('calendar_view').value,
calendar_week_start: document.getElementById('calendar_week_start').value,
calendar_week_days: document.getElementById('calendar_week_days').value,
calendar_week_layout: document.getElementById('calendar_week_layout').value,
calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value,
});
try {
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.');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
// Each calendar's own include/mute toggle -- auto-saves on change, not
// batched into the form above, since it's a data-sharing choice (see
// api_frames.py's /calendar-select), not a frame-wide setting. Works the
// same element for your own calendars (full add/remove) and other
// people's (mute only) -- the server enforces which direction is allowed
// and this just reverts the checkbox with an error message if rejected.
document.querySelectorAll('.calendar-toggle').forEach((el) => {
el.addEventListener('change', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/calendar-select`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: Number(el.dataset.userId),
calendar_key: el.dataset.key,
calendar_label: el.dataset.label,
included: el.checked,
}),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, el.checked ? 'Calendar included on this frame.' : 'Calendar removed from this frame.');
} catch (e) {
el.checked = !el.checked;
showStatus(false, e.message);
}
});
});
// Per-calendar color pin -- owner-only (the server enforces it; these
// buttons only ever render for the viewer's own calendars anyway, see
// frame_calendar.html). Clicking the currently-selected swatch again has
// no special "toggle off" behavior -- use the explicit Auto button.
document.querySelectorAll('.calendar-color-picker').forEach((picker) => {
const key = picker.dataset.key;
picker.querySelectorAll('.color-swatch').forEach((btn) => {
btn.addEventListener('click', async () => {
const colorIndex = btn.dataset.index === '' ? null : Number(btn.dataset.index);
try {
const resp = await fetch(`${window.FRAME_API}/calendar-color`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: key, color_index: colorIndex }),
});
if (!resp.ok) throw new Error(await apiError(resp));
picker.querySelectorAll('.color-swatch').forEach((el) => el.classList.remove('selected'));
btn.classList.add('selected');
showStatus(true, 'Color saved.');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
});
});
document.getElementById('weather-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
calendar_weather_enabled: String(document.getElementById('weather_enabled').checked),
calendar_weather_units: document.getElementById('weather_units').value,
});
try {
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.');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
function addWeatherCityRow(label) {
const list = document.getElementById('weather-city-list');
const empty = document.getElementById('weather-city-empty');
if (empty) empty.remove();
const li = document.createElement('li');
li.className = 'checkbox-row';
li.style.cssText = 'justify-content: space-between; margin-top: 6px;';
const span = document.createElement('span');
span.textContent = label;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn-inline secondary weather-city-remove';
btn.dataset.label = label;
btn.textContent = 'Remove';
btn.addEventListener('click', removeWeatherCity);
li.appendChild(span);
li.appendChild(btn);
list.appendChild(li);
}
async function removeWeatherCity(e) {
const label = e.target.dataset.label;
try {
const resp = await fetch(`${window.FRAME_API}/weather-cities/remove`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label }),
});
if (!resp.ok) throw new Error(await apiError(resp));
e.target.closest('li').remove();
const list = document.getElementById('weather-city-list');
if (!list.querySelector('li')) {
list.innerHTML = '<li class="sub" id="weather-city-empty">No cities added yet.</li>';
}
showStatus(true, `${label} removed.`);
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
}
document.querySelectorAll('.weather-city-remove').forEach((el) => el.addEventListener('click', removeWeatherCity));
document.getElementById('weather-city-add').addEventListener('click', async () => {
const input = document.getElementById('weather-city-input');
const name = input.value.trim();
if (!name) return;
try {
const resp = await fetch(`${window.FRAME_API}/weather-cities/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!resp.ok) throw new Error(await apiError(resp));
const data = await resp.json();
addWeatherCityRow(data.city.label);
input.value = '';
showStatus(true, `Added ${data.city.label}.`);
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('tasks_enabled').addEventListener('change', async (e) => {
const el = e.target;
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ calendar_tasks_enabled: String(el.checked) }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadCalendarPreview();
} catch (e) {
el.checked = !el.checked;
showStatus(false, e.message);
}
});
// Rewrites #tasks-current-source in place instead of telling the user
// to reload -- the API always assigns a successful "set" to the caller
// (see api_tasks_source), so after either action we already know
// exactly what the new state is without asking the server again.
function renderTasksCurrentSource(label) {
const container = document.getElementById('tasks-current-source');
container.innerHTML = '';
if (!label) return; // matches the template's no-tasks_source branch: nothing rendered
const p = document.createElement('p');
p.className = 'sub';
p.style.marginTop = '10px';
p.append('Currently using your ');
const labelEl = document.createElement('strong');
labelEl.textContent = label;
p.append(labelEl, ' list. ');
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'btn-inline secondary';
clearBtn.id = 'tasks-source-clear';
clearBtn.textContent = 'Clear';
clearBtn.addEventListener('click', clearTasksSource);
p.append(clearBtn);
container.append(p);
}
// Choosing one of your own CalDAV task lists as this frame's source --
// owner-only (see api_frames.py's api_tasks_source), so these radios
// only ever render for the viewer's own calendars anyway.
document.querySelectorAll('.tasks-source-choice').forEach((el) => {
el.addEventListener('change', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: el.dataset.key }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Task list saved.');
const labelEl = el.closest('li').querySelector('label');
renderTasksCurrentSource(labelEl ? labelEl.textContent.trim() : '');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
});
async function clearTasksSource() {
try {
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: null }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Task list cleared.');
renderTasksCurrentSource(null);
document.querySelectorAll('.tasks-source-choice').forEach((r) => { r.checked = false; });
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
}
const tasksSourceClear = document.getElementById('tasks-source-clear');
if (tasksSourceClear) {
tasksSourceClear.addEventListener('click', clearTasksSource);
}
function loadCalendarPreview() {
document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`;
}
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
loadCalendarPreview();
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);
loadControl();
+21 -12
View File
@@ -1,11 +1,12 @@
// Configuration tab: frame settings + firmware card + take control.
// Frame name/mode live in the page header now (frame_header.js), Calendar
// settings have their own tab (frame_calendar.js), and Order/Display mode
// live on the Photos tab (frame_photos.js) -- photos-specific settings,
// not device-wide configuration. 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.
// Configuration tab: frame-wide settings (orientation, quiet hours,
// palette/color/contrast/dither, firmware, battery alerts) + take
// control. Frame name lives in the page header now (frame_header.js);
// every per-widget setting (album, calendar view/inclusion, whiteboard
// source) lives in its own widget's gear-icon dialog instead (see
// static/frame_layout.js) -- this tab never touches those.
// 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;
@@ -72,7 +73,7 @@ async function takeControl() {
async function loadControl() {
const banner = document.getElementById('control-banner');
try {
const resp = await fetch(`${window.FRAME_API}/queue`);
const resp = await fetch(`${window.FRAME_API}/status`);
if (!resp.ok) return; // unconfigured frame: control still works via 409s
const data = await resp.json();
if (data.control && !data.control.you) {
@@ -190,14 +191,22 @@ document.getElementById('palette-reset').addEventListener('click', () => {
});
// ---- Preview: current photo vs. how it renders with saved settings ----
// Scoped to this frame's photo widget (window.PHOTO_WIDGET_PREVIEW_API,
// set by the template) rather than window.FRAME_API -- palette/color/
// contrast/dither are frame-level, but "the current photo" to preview
// them against is necessarily one specific photo widget's. Null (no
// photo widget on this frame) means the template didn't render the
// preview section at all -- nothing to wire up.
function loadPreview() {
if (!window.PHOTO_WIDGET_PREVIEW_API) return;
const bust = Date.now(); // avoid a stale cached image after settings change
document.getElementById('preview-original').src = `${window.FRAME_API}/preview/original?_=${bust}`;
document.getElementById('preview-rendered').src = `${window.FRAME_API}/preview/rendered?_=${bust}`;
document.getElementById('preview-original').src = `${window.PHOTO_WIDGET_PREVIEW_API}/preview/original?_=${bust}`;
document.getElementById('preview-rendered').src = `${window.PHOTO_WIDGET_PREVIEW_API}/preview/rendered?_=${bust}`;
}
document.getElementById('preview-refresh').addEventListener('click', loadPreview);
const previewRefreshBtn = document.getElementById('preview-refresh');
if (previewRefreshBtn) previewRefreshBtn.addEventListener('click', loadPreview);
loadPreview();
// ---- Battery alerts card ----
+8 -6
View File
@@ -1,7 +1,9 @@
// Page-header controls shared by every per-frame page (Photos/
// Configuration/Calendar/Whiteboard/Stats): the frame-name pencil-edit,
// living outside the tab structure since it applies regardless of which
// tab is open. Depends on window.FRAME_API (set per-page) and
// Page-header controls shared by every per-frame page (Layout/
// Configuration/Stats): the frame-name pencil-edit, living outside the
// tab structure since it applies regardless of which tab is open.
// Depends on window.FRAME_BASE_API (a stable frame-level base set by
// every page -- unlike window.FRAME_API, which the Layout page's
// widget dialogs repoint to a widget-scoped base while one is open) and
// common.js's showStatus/apiError.
(function () {
@@ -12,7 +14,7 @@
var textEl = document.getElementById('frame-name-text');
var saveBtn = document.getElementById('frame-name-save');
var cancelBtn = document.getElementById('frame-name-cancel');
if (!view || !window.FRAME_API) return;
if (!view || !window.FRAME_BASE_API) return;
function openEdit() {
input.value = textEl.textContent.trim();
@@ -36,7 +38,7 @@
return;
}
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
const resp = await fetch(`${window.FRAME_BASE_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ name }),
+71
View File
@@ -191,6 +191,15 @@ function renderCanvas() {
label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type;
box.appendChild(label);
const settingsBtn = document.createElement('button');
settingsBtn.type = 'button';
settingsBtn.className = 'widget-box-settings';
settingsBtn.textContent = '⚙';
settingsBtn.title = `${WIDGET_LABELS[widget.widget_type] || widget.widget_type} settings`;
settingsBtn.addEventListener('pointerdown', (e) => e.stopPropagation());
settingsBtn.addEventListener('click', (e) => { e.stopPropagation(); openWidgetDialog(widget); });
box.appendChild(settingsBtn);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'widget-box-remove';
@@ -246,4 +255,66 @@ window.addEventListener('resize', () => {
resizeTimer = setTimeout(layoutCanvas, 100);
});
// --- gear-icon dialog: each widget's own settings, fetched as an HTML
// fragment (routers/frame_pages.py's widget_dialog) and injected into a
// single shared <dialog>, rather than a separate page per widget type --
// a frame can now have several widgets of the same type, so "the
// Calendar tab" stopped meaning anything unambiguous.
// widget_dialog_{photos,calendar,whiteboard}.js each define an
// init<Type>Dialog()/close<Type>Dialog() pair (loaded unconditionally by
// frame_layout.html, since which one runs depends on which widget's gear
// icon was clicked).
const DIALOG_INIT = { photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog };
const DIALOG_CLOSE = { photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog };
let openDialogWidgetType = null;
async function openWidgetDialog(widget) {
const dialogEl = document.getElementById('widget-dialog');
const bodyEl = document.getElementById('widget-dialog-body');
bodyEl.innerHTML = '<p class="sub">Loading...</p>';
openDialogWidgetType = widget.widget_type;
dialogEl.showModal();
try {
const resp = await fetch(`/frames/${window.FRAME_ID}/widgets/${widget.id}/dialog`);
if (!resp.ok) throw new Error(await apiError(resp));
bodyEl.innerHTML = await resp.text();
// Every dialog script's fetch calls use window.FRAME_API as their
// base -- repointing it at this specific widget (instead of the
// frame-level window.FRAME_BASE_API) is what makes the SAME
// widget_dialog_photos.js/queue.js/etc. code work correctly no
// matter which widget's dialog is currently open. Restored on close.
window.FRAME_API = `${window.FRAME_BASE_API}/widgets/${widget.id}`;
const init = DIALOG_INIT[widget.widget_type];
if (init) init();
} catch (e) {
bodyEl.innerHTML = `<p class="sub">Could not load: ${e.message}</p>`;
}
}
document.getElementById('widget-dialog-close').addEventListener('click', () => {
document.getElementById('widget-dialog').close();
});
// Native <dialog> doesn't close on backdrop click by default -- a click
// that lands outside the dialog's own box (but is still technically
// "on" the dialog element, since the backdrop is part of it) counts as
// a backdrop click.
document.getElementById('widget-dialog').addEventListener('click', (e) => {
const dialogEl = e.currentTarget;
if (e.target !== dialogEl) return; // click landed on dialog content, not the backdrop
const rect = dialogEl.getBoundingClientRect();
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
if (!inside) dialogEl.close();
});
document.getElementById('widget-dialog').addEventListener('close', () => {
const close = DIALOG_CLOSE[openDialogWidgetType];
if (close) close();
openDialogWidgetType = null;
window.FRAME_API = window.FRAME_BASE_API;
document.getElementById('widget-dialog-body').innerHTML = '';
});
loadWidgets();
-128
View File
@@ -1,128 +0,0 @@
// Photos tab: now-displaying, album picker, order/display-mode settings,
// 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,
order: document.getElementById('order').value,
display_mode: document.getElementById('display_mode').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);
-183
View File
@@ -1,183 +0,0 @@
// Whiteboard tab: source URL (owner-gated, see api_frames.py's
// api_whiteboard_source), preview, and take control. window.FRAME_API is
// set by the template.
// Rewrites #whiteboard-current-source in place instead of telling the
// user to reload -- the API always assigns a successful "set" to the
// caller (see api_whiteboard_source), so after either action we already
// know exactly what the new state is without asking the server again.
function renderWhiteboardCurrentSource(url) {
const container = document.getElementById('whiteboard-current-source');
container.innerHTML = '';
const p = document.createElement('p');
p.className = 'sub';
p.style.marginTop = '10px';
if (url) {
p.append('Currently showing ');
const urlEl = document.createElement('strong');
urlEl.textContent = url;
p.append(urlEl, ' using your WebDAV account. ');
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'btn-inline secondary';
clearBtn.id = 'whiteboard-source-clear';
clearBtn.textContent = 'Clear';
clearBtn.addEventListener('click', clearWhiteboardSource);
p.append(clearBtn);
} else {
p.textContent = 'No whiteboard configured yet.';
}
container.append(p);
const label = document.getElementById('whiteboard-source-form-label');
if (label) {
label.textContent = url ? 'Change to one of your own files' : 'Use one of your own files';
}
}
const whiteboardForm = document.getElementById('whiteboard-source-form');
if (whiteboardForm) {
whiteboardForm.addEventListener('submit', async (e) => {
e.preventDefault();
const url = document.getElementById('whiteboard-url-input').value.trim();
if (!url) return;
try {
const resp = await fetch(`${window.FRAME_API}/whiteboard-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
renderWhiteboardCurrentSource(url);
loadWhiteboardPreview();
} catch (e) {
showStatus(false, e.message);
}
});
}
async function clearWhiteboardSource() {
try {
const resp = await fetch(`${window.FRAME_API}/whiteboard-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: null }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Cleared.');
renderWhiteboardCurrentSource(null);
const urlInput = document.getElementById('whiteboard-url-input');
if (urlInput) urlInput.value = '';
loadWhiteboardPreview();
} catch (e) {
showStatus(false, e.message);
}
}
const whiteboardClearBtn = document.getElementById('whiteboard-source-clear');
if (whiteboardClearBtn) {
whiteboardClearBtn.addEventListener('click', clearWhiteboardSource);
}
function loadWhiteboardPreview(force) {
const forceParam = force ? '&force=1' : '';
document.getElementById('whiteboard-preview').src = `${window.FRAME_API}/preview/whiteboard?_=${Date.now()}${forceParam}`;
}
// Loading the tab shows whatever's already cached (cheap, no refetch);
// the button is the one place that means "no really, go check now" --
// bypasses the fetch throttle server-side (see api_frames.py's `force`).
document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true));
loadWhiteboardPreview(false);
// --- file picker (Browse...) ---
const browseToggle = document.getElementById('whiteboard-browse-toggle');
if (browseToggle) {
const browsePanel = document.getElementById('whiteboard-browser');
const browseList = document.getElementById('whiteboard-browse-list');
const browseCurrent = document.getElementById('whiteboard-browse-current');
const browseUp = document.getElementById('whiteboard-browse-up');
const browseError = document.getElementById('whiteboard-browse-error');
const urlInput = document.getElementById('whiteboard-url-input');
let opened = false;
async function browseTo(url) {
browseError.style.display = 'none';
browseList.innerHTML = '<li class="sub">Loading...</li>';
try {
const qs = url ? `?url=${encodeURIComponent(url)}` : '';
const resp = await fetch(`${window.FRAME_API}/whiteboard-browse${qs}`);
if (!resp.ok) throw new Error(await apiError(resp));
const data = await resp.json();
browseCurrent.textContent = data.current_url;
browseUp.disabled = !data.parent_url;
browseUp.onclick = data.parent_url ? () => browseTo(data.parent_url) : null;
browseList.innerHTML = '';
if (data.entries.length === 0) {
browseList.innerHTML = '<li class="sub">(empty folder)</li>';
}
for (const entry of data.entries) {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn-inline secondary';
btn.style.margin = '2px 0';
btn.textContent = (entry.is_dir ? '📁 ' : '📄 ') + entry.name;
if (entry.is_dir) {
btn.addEventListener('click', () => browseTo(entry.url));
} else {
btn.addEventListener('click', () => {
urlInput.value = entry.url;
browsePanel.style.display = 'none';
});
}
li.appendChild(btn);
browseList.appendChild(li);
}
} catch (e) {
browseList.innerHTML = '';
browseError.textContent = e.message;
browseError.style.display = 'block';
}
}
browseToggle.addEventListener('click', () => {
opened = !opened;
browsePanel.style.display = opened ? 'block' : 'none';
if (opened && !browseCurrent.textContent) {
browseTo(null);
}
});
}
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);
loadControl();
+3 -2
View File
@@ -3,8 +3,9 @@
// 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.
// Expects window.FRAME_API = '/api/frames/<id>/widgets/<widget_id>' (set
// by frame_layout.js when the photos dialog opens), and a loadQueue()
// global (widget_dialog_photos.js) to refetch authoritative state.
let upcomingItems = [];
+3 -3
View File
@@ -1,7 +1,7 @@
// Settings page: "Discover calendars" against the CalDAV account already
// saved on this form (same idiom as frame_photos.js's Load Albums using
// the frame's already-saved Immich creds) -- so this only works after
// the CalDAV URL/username/password have been saved once.
// saved on this form (same idiom as widget_dialog_photos.js's Load
// Albums using the frame's already-saved Immich creds) -- so this only
// works after the CalDAV URL/username/password have been saved once.
// Hides the dedicated WebDAV username/password fields while "reuse my
// CalDAV creds" is checked -- they'd be ignored server-side anyway (see
+31 -3
View File
@@ -360,10 +360,9 @@ button.secondary:hover { background: var(--surface-alt); }
color: var(--text);
pointer-events: none;
}
.widget-box-remove {
.widget-box-remove, .widget-box-settings {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
padding: 0;
@@ -373,9 +372,12 @@ button.secondary:hover { background: var(--surface-alt); }
border: none;
background: var(--overlay);
color: #fff;
font-size: 12px;
cursor: pointer;
}
.widget-box-remove:hover { background: var(--overlay-hover); }
.widget-box-remove { right: 4px; }
.widget-box-settings { right: 28px; }
.widget-box-remove:hover, .widget-box-settings:hover { background: var(--overlay-hover); }
.widget-box-resize-handle {
position: absolute;
bottom: 0;
@@ -389,6 +391,32 @@ button.secondary:hover { background: var(--surface-alt); }
border-bottom-right-radius: 4px;
}
#widget-dialog {
position: fixed;
margin: auto;
width: min(680px, calc(100vw - 32px));
max-height: min(720px, calc(100vh - 64px));
padding: 24px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-hover);
}
#widget-dialog::backdrop { background: var(--overlay); }
#widget-dialog-close {
position: absolute;
top: 14px;
right: 14px;
width: 30px;
height: 30px;
font-size: 18px;
z-index: 1;
}
.dialog-title { margin: 0 40px 16px 0; font-size: 18px; }
#widget-dialog-body .card { box-shadow: none; }
#widget-dialog-body .card:first-child { margin-top: 0; }
code {
background: var(--surface-alt);
color: var(--text);
+290
View File
@@ -0,0 +1,290 @@
// Calendar widget dialog: view/week-start settings, per-user opt-in,
// weather, tasks, and the rendered preview. Not a page-load script --
// frame_layout.js fetches this widget's dialog HTML fragment, injects
// it into the shared <dialog>, points window.FRAME_API at this specific
// widget (/api/frames/{id}/widgets/{widget_id}), then calls
// initCalendarDialog(). Checkboxes are always sent explicitly as
// "true"/"false".
// Week-view-only settings (days/layout/start-offset) only matter when
// View is actually "Week"; "Week starts on" also matters for Month, so
// it gets its own, slightly looser condition. The start-offset row is
// further gated on the day count -- it's meaningless at the default 7
// days, where "Week starts on" governs instead (see
// calendar_render.py's _build_week).
function updateCalendarFieldVisibility() {
const view = document.getElementById('calendar_view').value;
const days = Number(document.getElementById('calendar_week_days').value);
const isWeek = view === 'week';
document.getElementById('calendar-week-start-row').style.display =
(view === 'week' || view === 'month') ? '' : 'none';
document.getElementById('calendar-week-days-row').style.display = isWeek ? '' : 'none';
document.getElementById('calendar-week-layout-row').style.display = isWeek ? '' : 'none';
document.getElementById('calendar-week-offset-row').style.display = (isWeek && days !== 7) ? '' : 'none';
}
function addWeatherCityRow(label) {
const list = document.getElementById('weather-city-list');
const empty = document.getElementById('weather-city-empty');
if (empty) empty.remove();
const li = document.createElement('li');
li.className = 'checkbox-row';
li.style.cssText = 'justify-content: space-between; margin-top: 6px;';
const span = document.createElement('span');
span.textContent = label;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn-inline secondary weather-city-remove';
btn.dataset.label = label;
btn.textContent = 'Remove';
btn.addEventListener('click', removeWeatherCity);
li.appendChild(span);
li.appendChild(btn);
list.appendChild(li);
}
async function removeWeatherCity(e) {
const label = e.target.dataset.label;
try {
const resp = await fetch(`${window.FRAME_API}/weather-cities/remove`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label }),
});
if (!resp.ok) throw new Error(await apiError(resp));
e.target.closest('li').remove();
const list = document.getElementById('weather-city-list');
if (!list.querySelector('li')) {
list.innerHTML = '<li class="sub" id="weather-city-empty">No cities added yet.</li>';
}
showStatus(true, `${label} removed.`);
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
}
// Rewrites #tasks-current-source in place instead of telling the user
// to reload -- the API always assigns a successful "set" to the caller
// (see api_widget_tasks_source), so after either action we already know
// exactly what the new state is without asking the server again.
function renderTasksCurrentSource(label) {
const container = document.getElementById('tasks-current-source');
container.innerHTML = '';
if (!label) return; // matches the template's no-tasks_source branch: nothing rendered
const p = document.createElement('p');
p.className = 'sub';
p.style.marginTop = '10px';
p.append('Currently using your ');
const labelEl = document.createElement('strong');
labelEl.textContent = label;
p.append(labelEl, ' list. ');
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'btn-inline secondary';
clearBtn.id = 'tasks-source-clear';
clearBtn.textContent = 'Clear';
clearBtn.addEventListener('click', clearTasksSource);
p.append(clearBtn);
container.append(p);
}
async function clearTasksSource() {
try {
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: null }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Task list cleared.');
renderTasksCurrentSource(null);
document.querySelectorAll('.tasks-source-choice').forEach((r) => { r.checked = false; });
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
}
function loadCalendarPreview() {
document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`;
}
function initCalendarDialog() {
document.getElementById('calendar_view').addEventListener('change', updateCalendarFieldVisibility);
document.getElementById('calendar_week_days').addEventListener('input', updateCalendarFieldVisibility);
updateCalendarFieldVisibility();
document.getElementById('calendar-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
calendar_view: document.getElementById('calendar_view').value,
calendar_week_start: document.getElementById('calendar_week_start').value,
calendar_week_days: document.getElementById('calendar_week_days').value,
calendar_week_layout: document.getElementById('calendar_week_layout').value,
calendar_week_start_offset: document.getElementById('calendar_week_start_offset').value,
});
try {
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.');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
// Each calendar's own include/mute toggle -- auto-saves on change, not
// batched into the form above, since it's a data-sharing choice (see
// api_widget_calendar_select), not a widget-wide setting. Works the
// same element for your own calendars (full add/remove) and other
// people's (mute only) -- the server enforces which direction is
// allowed and this just reverts the checkbox with an error message if
// rejected.
document.querySelectorAll('.calendar-toggle').forEach((el) => {
el.addEventListener('change', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/calendar-select`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: Number(el.dataset.userId),
calendar_key: el.dataset.key,
calendar_label: el.dataset.label,
included: el.checked,
}),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, el.checked ? 'Calendar included on this widget.' : 'Calendar removed from this widget.');
} catch (e) {
el.checked = !el.checked;
showStatus(false, e.message);
}
});
});
// Per-calendar color pin -- owner-only (the server enforces it; these
// buttons only ever render for the viewer's own calendars anyway).
// Clicking the currently-selected swatch again has no special
// "toggle off" behavior -- use the explicit Auto button.
document.querySelectorAll('.calendar-color-picker').forEach((picker) => {
const key = picker.dataset.key;
picker.querySelectorAll('.color-swatch').forEach((btn) => {
btn.addEventListener('click', async () => {
const colorIndex = btn.dataset.index === '' ? null : Number(btn.dataset.index);
try {
const resp = await fetch(`${window.FRAME_API}/calendar-color`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: key, color_index: colorIndex }),
});
if (!resp.ok) throw new Error(await apiError(resp));
picker.querySelectorAll('.color-swatch').forEach((el) => el.classList.remove('selected'));
btn.classList.add('selected');
showStatus(true, 'Color saved.');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
});
});
document.getElementById('weather-config-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = new URLSearchParams({
calendar_weather_enabled: String(document.getElementById('weather_enabled').checked),
calendar_weather_units: document.getElementById('weather_units').value,
});
try {
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.');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
document.querySelectorAll('.weather-city-remove').forEach((el) => el.addEventListener('click', removeWeatherCity));
document.getElementById('weather-city-add').addEventListener('click', async () => {
const input = document.getElementById('weather-city-input');
const name = input.value.trim();
if (!name) return;
try {
const resp = await fetch(`${window.FRAME_API}/weather-cities/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!resp.ok) throw new Error(await apiError(resp));
const data = await resp.json();
addWeatherCityRow(data.city.label);
input.value = '';
showStatus(true, `Added ${data.city.label}.`);
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
document.getElementById('tasks_enabled').addEventListener('change', async (e) => {
const el = e.target;
try {
const resp = await fetch(`${window.FRAME_API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ calendar_tasks_enabled: String(el.checked) }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
loadCalendarPreview();
} catch (e) {
el.checked = !el.checked;
showStatus(false, e.message);
}
});
// Choosing one of your own CalDAV task lists as this widget's source --
// owner-only (see api_widget_tasks_source), so these radios only ever
// render for the viewer's own calendars anyway.
document.querySelectorAll('.tasks-source-choice').forEach((el) => {
el.addEventListener('change', async () => {
try {
const resp = await fetch(`${window.FRAME_API}/tasks-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ calendar_key: el.dataset.key }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Task list saved.');
const labelEl = el.closest('li').querySelector('label');
renderTasksCurrentSource(labelEl ? labelEl.textContent.trim() : '');
loadCalendarPreview();
} catch (e) {
showStatus(false, e.message);
}
});
});
const tasksSourceClear = document.getElementById('tasks-source-clear');
if (tasksSourceClear) {
tasksSourceClear.addEventListener('click', clearTasksSource);
}
document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
loadCalendarPreview();
}
function closeCalendarDialog() {
// Nothing to tear down -- no poll interval, unlike the photos dialog.
}
+116
View File
@@ -0,0 +1,116 @@
// Photos widget dialog: now-displaying, album picker, order/display-mode
// settings, and the upcoming grid (rendering/drag logic in queue.js).
// Not a page-load script -- frame_layout.js fetches this widget's dialog
// HTML fragment, injects it into the shared <dialog>, points
// window.FRAME_API at this specific widget (/api/frames/{id}/widgets/
// {widget_id}), then calls initPhotosDialog(). closePhotosDialog() stops
// the poll interval when the dialog closes, same "expects window.
// FRAME_API + a global loadQueue()" contract queue.js has always had.
let photosPollTimer = 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');
if (!currentEl) return; // dialog closed mid-flight
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>';
}
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,
order: document.getElementById('order').value,
display_mode: document.getElementById('display_mode').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));
}
}
function initPhotosDialog() {
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);
}
});
loadQueue();
// Slow poll: picks up real changes (new photo displayed, queue edited
// from elsewhere) without a manual refresh. Skipped mid-drag.
photosPollTimer = setInterval(loadQueue, 10000);
}
function closePhotosDialog() {
clearInterval(photosPollTimer);
photosPollTimer = null;
}
@@ -0,0 +1,163 @@
// Whiteboard widget dialog: source URL (owner-gated, see
// api_widgets.py's api_widget_whiteboard_source), preview, and the file
// browser. Not a page-load script -- frame_layout.js fetches this
// widget's dialog HTML fragment, injects it into the shared <dialog>,
// points window.FRAME_API at this specific widget (/api/frames/{id}/
// widgets/{widget_id}), then calls initWhiteboardDialog().
// Rewrites #whiteboard-current-source in place instead of telling the
// user to reload -- the API always assigns a successful "set" to the
// caller (see api_widget_whiteboard_source), so after either action we
// already know exactly what the new state is without asking the server
// again.
function renderWhiteboardCurrentSource(url) {
const container = document.getElementById('whiteboard-current-source');
container.innerHTML = '';
const p = document.createElement('p');
p.className = 'sub';
p.style.marginTop = '10px';
if (url) {
p.append('Currently showing ');
const urlEl = document.createElement('strong');
urlEl.textContent = url;
p.append(urlEl, ' using your WebDAV account. ');
const clearBtn = document.createElement('button');
clearBtn.type = 'button';
clearBtn.className = 'btn-inline secondary';
clearBtn.id = 'whiteboard-source-clear';
clearBtn.textContent = 'Clear';
clearBtn.addEventListener('click', clearWhiteboardSource);
p.append(clearBtn);
} else {
p.textContent = 'No whiteboard configured yet.';
}
container.append(p);
const label = document.getElementById('whiteboard-source-form-label');
if (label) {
label.textContent = url ? 'Change to one of your own files' : 'Use one of your own files';
}
}
async function clearWhiteboardSource() {
try {
const resp = await fetch(`${window.FRAME_API}/whiteboard-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: null }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Cleared.');
renderWhiteboardCurrentSource(null);
const urlInput = document.getElementById('whiteboard-url-input');
if (urlInput) urlInput.value = '';
loadWhiteboardPreview(false);
} catch (e) {
showStatus(false, e.message);
}
}
function loadWhiteboardPreview(force) {
const forceParam = force ? '&force=1' : '';
document.getElementById('whiteboard-preview').src = `${window.FRAME_API}/preview/whiteboard?_=${Date.now()}${forceParam}`;
}
function initWhiteboardDialog() {
const whiteboardForm = document.getElementById('whiteboard-source-form');
if (whiteboardForm) {
whiteboardForm.addEventListener('submit', async (e) => {
e.preventDefault();
const url = document.getElementById('whiteboard-url-input').value.trim();
if (!url) return;
try {
const resp = await fetch(`${window.FRAME_API}/whiteboard-source`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!resp.ok) throw new Error(await apiError(resp));
showStatus(true, 'Saved.');
renderWhiteboardCurrentSource(url);
loadWhiteboardPreview(false);
} catch (e) {
showStatus(false, e.message);
}
});
}
const whiteboardClearBtn = document.getElementById('whiteboard-source-clear');
if (whiteboardClearBtn) {
whiteboardClearBtn.addEventListener('click', clearWhiteboardSource);
}
// Shows whatever's already cached (cheap, no refetch) on open; the
// button is the one place that means "no really, go check now" --
// bypasses the fetch throttle server-side (see api_widget_preview_
// whiteboard's `force` param).
document.getElementById('whiteboard-preview-refresh').addEventListener('click', () => loadWhiteboardPreview(true));
loadWhiteboardPreview(false);
// --- file picker (Browse...) ---
const browseToggle = document.getElementById('whiteboard-browse-toggle');
if (browseToggle) {
const browsePanel = document.getElementById('whiteboard-browser');
const browseList = document.getElementById('whiteboard-browse-list');
const browseCurrent = document.getElementById('whiteboard-browse-current');
const browseUp = document.getElementById('whiteboard-browse-up');
const browseError = document.getElementById('whiteboard-browse-error');
const urlInput = document.getElementById('whiteboard-url-input');
let opened = false;
async function browseTo(url) {
browseError.style.display = 'none';
browseList.innerHTML = '<li class="sub">Loading...</li>';
try {
const qs = url ? `?url=${encodeURIComponent(url)}` : '';
const resp = await fetch(`${window.FRAME_API}/whiteboard-browse${qs}`);
if (!resp.ok) throw new Error(await apiError(resp));
const data = await resp.json();
browseCurrent.textContent = data.current_url;
browseUp.disabled = !data.parent_url;
browseUp.onclick = data.parent_url ? () => browseTo(data.parent_url) : null;
browseList.innerHTML = '';
if (data.entries.length === 0) {
browseList.innerHTML = '<li class="sub">(empty folder)</li>';
}
for (const entry of data.entries) {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn-inline secondary';
btn.style.margin = '2px 0';
btn.textContent = (entry.is_dir ? '📁 ' : '📄 ') + entry.name;
if (entry.is_dir) {
btn.addEventListener('click', () => browseTo(entry.url));
} else {
btn.addEventListener('click', () => {
urlInput.value = entry.url;
browsePanel.style.display = 'none';
});
}
li.appendChild(btn);
browseList.appendChild(li);
}
} catch (e) {
browseList.innerHTML = '';
browseError.textContent = e.message;
browseError.style.display = 'block';
}
}
browseToggle.addEventListener('click', () => {
opened = !opened;
browsePanel.style.display = opened ? 'block' : 'none';
if (opened && !browseCurrent.textContent) {
browseTo(null);
}
});
}
}
function closeWhiteboardDialog() {
// Nothing to tear down -- no poll interval, unlike the photos dialog.
}
+1 -6
View File
@@ -1,10 +1,5 @@
<nav class="tabs">
<a href="/frames/{{ frame.id }}/layout" class="{% if active_tab == 'layout' %}active{% endif %}">Layout</a>
<a href="/frames/{{ frame.id }}" class="{% if active_tab == 'photos' %}active{% endif %}">Photos</a>
<a href="/frames/{{ frame.id }}" class="{% if active_tab == 'layout' %}active{% endif %}">Layout</a>
<a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</a>
<a href="/frames/{{ frame.id }}/calendar"
class="{% if active_tab == 'calendar' %}active{% endif %} {% if not has_calendar_widget %}tab-disabled{% endif %}">Calendar</a>
<a href="/frames/{{ frame.id }}/whiteboard"
class="{% if active_tab == 'whiteboard' %}active{% endif %} {% if not has_whiteboard_widget %}tab-disabled{% endif %}">Whiteboard</a>
<a href="/frames/{{ frame.id }}/stats" class="{% if active_tab == 'stats' %}active{% endif %}">Stats</a>
</nav>
@@ -0,0 +1,173 @@
<h2 class="dialog-title">Calendar widget</h2>
<section class="card">
<h2 class="card-title">Calendar</h2>
<form id="calendar-config-form">
<label>View
<select id="calendar_view">
{% for value, label in calendar_views.items() %}
<option value="{{ value }}" {% if calendar_cfg.view == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<div id="calendar-week-start-row">
<label>Week starts on
<select id="calendar_week_start">
{% for value, label in week_start_labels.items() %}
<option value="{{ value }}" {% if calendar_cfg.week_start == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<p class="sub" style="margin-top: 4px;">Only affects the Week and Month views, and (for Week) only at 7 days.</p>
</div>
<div id="calendar-week-days-row">
<label>Days to show (Week view)
<input type="number" id="calendar_week_days" min="2" max="10" value="{{ calendar_cfg.week_days }}">
</label>
</div>
<div id="calendar-week-layout-row">
<label>Week view layout
<select id="calendar_week_layout">
<option value="horizontal" {% if calendar_cfg.week_layout == "horizontal" %}selected{% endif %}>Days side by side</option>
<option value="vertical" {% if calendar_cfg.week_layout == "vertical" %}selected{% endif %}>Days stacked</option>
</select>
</label>
</div>
<div id="calendar-week-offset-row">
<label>Week view starts (days from today)
<input type="number" id="calendar_week_start_offset" min="-30" max="30" value="{{ calendar_cfg.week_start_offset }}">
</label>
<p class="sub" style="margin-top: 4px;">0 = starts today, negative = starts in the
past, positive = starts in the future. Only used when Days to show isn't 7.</p>
</div>
<button type="submit">Save</button>
</form>
<h2 class="card-title" style="margin-top: 20px;">Included calendars</h2>
<p class="sub">Each linked person adds their own calendars (ICS
subscription or CalDAV, set up in <a href="/settings">Settings</a>)
-- being linked here doesn't include anything automatically.
Anyone linked to this frame can mute a calendar they'd rather
not see here, even one they don't own; only its owner can add
it back.</p>
<ul class="calendar-user-list">
{% for u in calendar_users %}
<li>
<p class="calendar-user-name">{{ u.display_name }}{% if u.is_self %} (you){% endif %}</p>
{% if u.calendars %}
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
{% set current_hex = palette_to_hex(current_palette) %}
{% for c in u.calendars %}
<div class="checkbox-row calendar-row" style="margin-top: 6px;">
<input type="checkbox" class="calendar-toggle"
data-user-id="{{ u.user_id }}" data-key="{{ c.key }}" data-label="{{ c.label }}"
{% if c.included %}checked{% endif %}>
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
{% if u.is_self %}
<span class="calendar-color-picker" data-key="{{ c.key }}">
{% for idx in range(2, 6) %}
<button type="button" class="color-swatch {% if c.color_index == idx %}selected{% endif %}"
data-index="{{ idx }}" title="{{ calendar_color_labels[idx] }}"
style="background-color: {{ current_hex[idx] }};"></button>
{% endfor %}
<button type="button" class="color-swatch color-swatch-auto {% if c.color_index is none %}selected{% endif %}"
data-index="" title="Auto (assigned automatically)">Auto</button>
</span>
{% endif %}
</div>
{% endfor %}
{% elif u.is_self %}
<p class="sub" style="margin-top: 6px;">No calendars set up yet -- add an ICS link or CalDAV account in <a href="/settings">Settings</a>.</p>
{% else %}
<p class="sub" style="margin-top: 6px;">No calendars included.</p>
{% endif %}
</li>
{% endfor %}
</ul>
{% if calendar_cfg.fetch_summary %}
<p class="sub" style="margin-top: 8px; color: var(--danger-text);">Last fetch: {{ calendar_cfg.fetch_summary }}</p>
{% endif %}
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Weather</h2>
<p class="sub">Shown above the event list on Agenda, Agenda (today
&amp; tomorrow), and Week views -- there's no room for it on Month.</p>
<form id="weather-config-form">
<div class="checkbox-row">
<input type="checkbox" id="weather_enabled" {% if calendar_cfg.weather_enabled %}checked{% endif %}>
<label for="weather_enabled">Show weather</label>
</div>
<label>Units
<select id="weather_units">
<option value="fahrenheit" {% if calendar_cfg.weather_units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
<option value="celsius" {% if calendar_cfg.weather_units == "celsius" %}selected{% endif %}>Celsius</option>
</select>
</label>
<button type="submit">Save</button>
</form>
<h3 style="margin-top: 20px; font-size: 14px;">Cities</h3>
<p class="sub">Every city shows on every day -- add more than one if
people split their time between places.</p>
<ul class="calendar-user-list" id="weather-city-list">
{% for c in calendar_cfg.weather_cities or [] %}
<li class="checkbox-row" style="justify-content: space-between; margin-top: 6px;">
<span>{{ c.label }}</span>
<button type="button" class="btn-inline secondary weather-city-remove" data-label="{{ c.label }}">Remove</button>
</li>
{% else %}
<li class="sub" id="weather-city-empty">No cities added yet.</li>
{% endfor %}
</ul>
<div class="checkbox-row" style="margin-top: 10px;">
<input type="text" id="weather-city-input" placeholder="e.g. Portland, OR" style="margin-top: 0; flex: 1;">
<button type="button" class="btn-inline" id="weather-city-add">Add</button>
</div>
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Tasks</h2>
<p class="sub">Week view only -- takes the place of one day slot
instead of adding an extra one.</p>
<div class="checkbox-row" id="tasks-enabled-row">
<input type="checkbox" id="tasks_enabled" {% if calendar_cfg.tasks_enabled %}checked{% endif %}>
<label for="tasks_enabled">Show a task list</label>
</div>
<div id="tasks-current-source">
{% if tasks_source %}
<p class="sub" style="margin-top: 10px;">
Currently using <strong>{{ tasks_source.display_name }}</strong>'s
<strong>{{ tasks_source.label }}</strong> list.
<button type="button" class="btn-inline secondary" id="tasks-source-clear">Clear</button>
</p>
{% endif %}
</div>
{% if viewer_task_calendars %}
<p class="sub" style="margin-top: 10px;">Use one of your own CalDAV task lists:</p>
<ul class="calendar-user-list">
{% for c in viewer_task_calendars %}
<li class="checkbox-row" style="margin-top: 6px;">
<input type="radio" name="tasks-source-choice" class="tasks-source-choice" data-key="{{ c.key }}"
{% if tasks_source and tasks_source.user_id == user.id and calendar_cfg.tasks_calendar_key == c.key %}checked{% endif %}>
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
</li>
{% endfor %}
</ul>
{% else %}
<p class="sub" style="margin-top: 10px;">You don't have any CalDAV
task lists available -- set up a CalDAV account in
<a href="/settings">Settings</a> first (a plain ICS subscription
doesn't carry tasks).</p>
{% endif %}
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
<img class="preview-img" id="calendar-preview" alt="Calendar preview">
<button type="button" class="secondary" id="calendar-preview-refresh">Refresh preview</button>
</section>
@@ -0,0 +1,55 @@
<h2 class="dialog-title">Photos widget</h2>
<section class="card">
<h2 class="card-title">Album</h2>
<form id="photos-form">
<label>Album
<select id="album_id">
{% if photo_cfg and photo_cfg.album_id %}<option value="{{ photo_cfg.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 photo_cfg and photo_cfg.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
<label>Order
<select id="order">
<option value="sequential" {% if not photo_cfg or photo_cfg.order == "sequential" %}selected{% endif %}>Sequential</option>
<option value="shuffle" {% if photo_cfg and photo_cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
</select>
</label>
<label>Display mode
<select id="display_mode">
{% for mode, label in display_mode_labels.items() %}
<option value="{{ mode }}" {% if photo_cfg and photo_cfg.display_mode == mode %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<p class="sub" style="margin-top: 8px;">How a photo's aspect ratio
is reconciled with the panel's: <strong>Crop to fill</strong>
trims the excess; <strong>Crop to faces</strong> does the same
but shifts the crop to keep people on screen; <strong>Stretch to
fill</strong> fills the panel exactly without cropping (photos
not matching the panel's aspect ratio look stretched);
<strong>Shrink to fit</strong> shows the whole photo, letterboxed
if needed.</p>
<button type="button" class="secondary" id="load-albums">Load Albums</button>
<button type="submit">Save</button>
</form>
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Now displaying</h2>
<div id="current-thumb"><p class="sub">Loading...</p></div>
</section>
<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>
@@ -0,0 +1,58 @@
<h2 class="dialog-title">Whiteboard widget</h2>
<section class="card">
<h2 class="card-title">Whiteboard source</h2>
<p class="sub">Renders a Nextcloud Whiteboard (or any Excalidraw
scene) fetched over WebDAV -- credentials set up in
<a href="/settings">Settings</a>.</p>
<div id="whiteboard-current-source">
{% if whiteboard_source %}
<p class="sub" style="margin-top: 10px;">
Currently showing <strong>{{ whiteboard_source.url }}</strong>
using <strong>{{ whiteboard_source.display_name }}</strong>'s
WebDAV account.
<button type="button" class="btn-inline secondary" id="whiteboard-source-clear">Clear</button>
</p>
{% else %}
<p class="sub" style="margin-top: 10px;">No whiteboard configured yet.</p>
{% endif %}
</div>
{% if viewer_has_webdav_creds %}
<form id="whiteboard-source-form" style="margin-top: 16px;">
<label><span id="whiteboard-source-form-label">{{ "Change to one of your own files" if whiteboard_source else "Use one of your own files" }}</span>
<input type="text" id="whiteboard-url-input"
placeholder="https://cloud.example.com/remote.php/dav/files/you/Boards/family.whiteboard"
value="{{ whiteboard_source.url if whiteboard_source and whiteboard_source.user_id == user.id else '' }}">
</label>
<p class="sub" style="margin-top: 4px;">The direct WebDAV URL
to the specific file -- in Nextcloud's Files app, this is
the file's path under
<code>remote.php/dav/files/&lt;your-username&gt;/</code>.
<button type="button" class="btn-inline secondary" id="whiteboard-browse-toggle">Browse...</button></p>
<div id="whiteboard-browser" style="display: none; margin-top: 8px; border: 1px solid var(--border-color, #ccc); border-radius: 6px; padding: 8px;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
<button type="button" class="btn-inline secondary" id="whiteboard-browse-up" disabled>Up</button>
<span class="sub" id="whiteboard-browse-current" style="word-break: break-all;"></span>
</div>
<ul id="whiteboard-browse-list" style="list-style: none; margin: 0; padding: 0; max-height: 260px; overflow-y: auto;"></ul>
<p class="sub" id="whiteboard-browse-error" style="display: none; color: var(--error-color, #c00);"></p>
</div>
<button type="submit">Save</button>
</form>
{% else %}
<p class="sub" style="margin-top: 10px;">Set up WebDAV credentials
in <a href="/settings">Settings</a> first to point this widget at
one of your own files.</p>
{% endif %}
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
<img class="preview-img" id="whiteboard-preview" alt="Whiteboard preview">
<button type="button" class="secondary" id="whiteboard-preview-refresh">Refresh now</button>
</section>
-206
View File
@@ -1,206 +0,0 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Calendar{% endblock %}
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% 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>
{% if not has_calendar_widget %}
<div class="info-box">This frame doesn't have a <strong>Calendar</strong> widget on
screen yet -- settings below won't show up anywhere until one is added.</div>
{% endif %}
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Calendar</h2>
<form id="calendar-config-form">
<label>View
<select id="calendar_view">
{% for value, label in calendar_views.items() %}
<option value="{{ value }}" {% if calendar_cfg and calendar_cfg.view == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<div id="calendar-week-start-row">
<label>Week starts on
<select id="calendar_week_start">
{% for value, label in week_start_labels.items() %}
<option value="{{ value }}" {% if calendar_cfg and calendar_cfg.week_start == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<p class="sub" style="margin-top: 4px;">Only affects the Week and Month views, and (for Week) only at 7 days.</p>
</div>
<div id="calendar-week-days-row">
<label>Days to show (Week view)
<input type="number" id="calendar_week_days" min="2" max="10" value="{{ calendar_cfg.week_days if calendar_cfg else 7 }}">
</label>
</div>
<div id="calendar-week-layout-row">
<label>Week view layout
<select id="calendar_week_layout">
<option value="horizontal" {% if not calendar_cfg or calendar_cfg.week_layout == "horizontal" %}selected{% endif %}>Days side by side</option>
<option value="vertical" {% if calendar_cfg and calendar_cfg.week_layout == "vertical" %}selected{% endif %}>Days stacked</option>
</select>
</label>
</div>
<div id="calendar-week-offset-row">
<label>Week view starts (days from today)
<input type="number" id="calendar_week_start_offset" min="-30" max="30" value="{{ calendar_cfg.week_start_offset if calendar_cfg else 0 }}">
</label>
<p class="sub" style="margin-top: 4px;">0 = starts today, negative = starts in the
past, positive = starts in the future. Only used when Days to show isn't 7.</p>
</div>
<button type="submit">Save</button>
</form>
<h2 class="card-title" style="margin-top: 20px;">Included calendars</h2>
<p class="sub">Each linked person adds their own calendars (ICS
subscription or CalDAV, set up in <a href="/settings">Settings</a>)
-- being linked here doesn't include anything automatically.
Anyone linked to this frame can mute a calendar they'd rather
not see here, even one they don't own; only its owner can add
it back.</p>
<ul class="calendar-user-list">
{% for u in calendar_users %}
<li>
<p class="calendar-user-name">{{ u.display_name }}{% if u.is_self %} (you){% endif %}</p>
{% if u.calendars %}
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
{% set current_hex = palette_to_hex(current_palette) %}
{% for c in u.calendars %}
<div class="checkbox-row calendar-row" style="margin-top: 6px;">
<input type="checkbox" class="calendar-toggle"
data-user-id="{{ u.user_id }}" data-key="{{ c.key }}" data-label="{{ c.label }}"
{% if c.included %}checked{% endif %}>
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
{% if u.is_self %}
<span class="calendar-color-picker" data-key="{{ c.key }}">
{% for idx in range(2, 6) %}
<button type="button" class="color-swatch {% if c.color_index == idx %}selected{% endif %}"
data-index="{{ idx }}" title="{{ calendar_color_labels[idx] }}"
style="background-color: {{ current_hex[idx] }};"></button>
{% endfor %}
<button type="button" class="color-swatch color-swatch-auto {% if c.color_index is none %}selected{% endif %}"
data-index="" title="Auto (assigned automatically)">Auto</button>
</span>
{% endif %}
</div>
{% endfor %}
{% elif u.is_self %}
<p class="sub" style="margin-top: 6px;">No calendars set up yet -- add an ICS link or CalDAV account in <a href="/settings">Settings</a>.</p>
{% else %}
<p class="sub" style="margin-top: 6px;">No calendars included.</p>
{% endif %}
</li>
{% endfor %}
</ul>
{% if calendar_cfg and calendar_cfg.fetch_summary %}
<p class="sub" style="margin-top: 8px; color: var(--danger-text);">Last fetch: {{ calendar_cfg.fetch_summary }}</p>
{% endif %}
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Weather</h2>
<p class="sub">Shown above the event list on Agenda, Agenda (today
&amp; tomorrow), and Week views -- there's no room for it on Month.</p>
<form id="weather-config-form">
<div class="checkbox-row">
<input type="checkbox" id="weather_enabled" {% if calendar_cfg and calendar_cfg.weather_enabled %}checked{% endif %}>
<label for="weather_enabled">Show weather</label>
</div>
<label>Units
<select id="weather_units">
<option value="fahrenheit" {% if not calendar_cfg or calendar_cfg.weather_units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
<option value="celsius" {% if calendar_cfg and calendar_cfg.weather_units == "celsius" %}selected{% endif %}>Celsius</option>
</select>
</label>
<button type="submit">Save</button>
</form>
<h3 style="margin-top: 20px; font-size: 14px;">Cities</h3>
<p class="sub">Every city shows on every day -- add more than one if
people split their time between places.</p>
<ul class="calendar-user-list" id="weather-city-list">
{% for c in (calendar_cfg.weather_cities if calendar_cfg else []) or [] %}
<li class="checkbox-row" style="justify-content: space-between; margin-top: 6px;">
<span>{{ c.label }}</span>
<button type="button" class="btn-inline secondary weather-city-remove" data-label="{{ c.label }}">Remove</button>
</li>
{% else %}
<li class="sub" id="weather-city-empty">No cities added yet.</li>
{% endfor %}
</ul>
<div class="checkbox-row" style="margin-top: 10px;">
<input type="text" id="weather-city-input" placeholder="e.g. Portland, OR" style="margin-top: 0; flex: 1;">
<button type="button" class="btn-inline" id="weather-city-add">Add</button>
</div>
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Tasks</h2>
<p class="sub">Week view only -- takes the place of one day slot
instead of adding an extra one.</p>
<div class="checkbox-row" id="tasks-enabled-row">
<input type="checkbox" id="tasks_enabled" {% if calendar_cfg and calendar_cfg.tasks_enabled %}checked{% endif %}>
<label for="tasks_enabled">Show a task list</label>
</div>
<div id="tasks-current-source">
{% if tasks_source %}
<p class="sub" style="margin-top: 10px;">
Currently using <strong>{{ tasks_source.display_name }}</strong>'s
<strong>{{ tasks_source.label }}</strong> list.
<button type="button" class="btn-inline secondary" id="tasks-source-clear">Clear</button>
</p>
{% endif %}
</div>
{% if viewer_task_calendars %}
<p class="sub" style="margin-top: 10px;">Use one of your own CalDAV task lists:</p>
<ul class="calendar-user-list">
{% for c in viewer_task_calendars %}
<li class="checkbox-row" style="margin-top: 6px;">
<input type="radio" name="tasks-source-choice" class="tasks-source-choice" data-key="{{ c.key }}"
{% if tasks_source and tasks_source.user_id == user.id and calendar_cfg and calendar_cfg.tasks_calendar_key == c.key %}checked{% endif %}>
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
</li>
{% endfor %}
</ul>
{% else %}
<p class="sub" style="margin-top: 10px;">You don't have any CalDAV
task lists available -- set up a CalDAV account in
<a href="/settings">Settings</a> first (a plain ICS subscription
doesn't carry tasks).</p>
{% endif %}
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Preview</h2>
<p class="sub">How this frame's calendar currently renders.</p>
<img class="preview-img" id="calendar-preview" alt="Calendar preview">
<button type="button" class="secondary" id="calendar-preview-refresh">Refresh preview</button>
</section>
</div>
</div>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/device_status_bar.js"></script>
<script src="/static/frame_header.js"></script>
<script src="/static/frame_calendar.js"></script>
{% endblock %}
+20 -13
View File
@@ -158,19 +158,25 @@
<section class="card">
<h2 class="card-title">Preview</h2>
<p class="sub">The current photo, and exactly how it renders on the
panel with this frame's saved settings above.</p>
<div class="preview-compare">
<div class="preview-pane">
<p class="sub">Now displaying</p>
<img class="preview-img" id="preview-original" alt="Original photo">
{% if photo_widget_id %}
<p class="sub">The current photo (from this frame's photo widget), and
exactly how it renders on the panel with this frame's saved settings
above.</p>
<div class="preview-compare">
<div class="preview-pane">
<p class="sub">Now displaying</p>
<img class="preview-img" id="preview-original" alt="Original photo">
</div>
<div class="preview-pane">
<p class="sub">How it will look on the frame</p>
<img class="preview-img" id="preview-rendered" alt="Rendered preview">
</div>
</div>
<div class="preview-pane">
<p class="sub">How it will look on the frame</p>
<img class="preview-img" id="preview-rendered" alt="Rendered preview">
</div>
</div>
<button type="button" class="secondary" id="preview-refresh">Refresh preview</button>
<button type="button" class="secondary" id="preview-refresh">Refresh preview</button>
{% else %}
<p class="sub">This frame doesn't have a photo widget to preview
against yet -- add one from the Layout tab.</p>
{% endif %}
</section>
</div>
</div>
@@ -180,8 +186,9 @@
{% block scripts %}
<script>
window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
window.FRAME_BASE_API = window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }};
window.PHOTO_WIDGET_PREVIEW_API = {{ (("/api/frames/" ~ frame.id ~ "/widgets/" ~ photo_widget_id) | tojson) if photo_widget_id else "null" }};
</script>
<script src="/static/device_status_bar.js"></script>
<script src="/static/frame_header.js"></script>
+15 -3
View File
@@ -18,8 +18,8 @@
<h2 class="card-title">Widgets</h2>
<p class="sub">Drag a widget to move it, drag its bottom-right corner
to resize it -- like arranging widgets on a phone's home screen.
Widgets can't overlap. Each widget's own settings (which album,
which calendars, etc.) live on its type's own tab.</p>
Widgets can't overlap. Click a widget's gear icon for its own
settings (which album, which calendars, etc.).</p>
<div id="widget-canvas-wrap">
<div id="widget-canvas"></div>
</div>
@@ -35,12 +35,24 @@
</div>
</div>
<dialog id="widget-dialog">
<button type="button" id="widget-dialog-close" class="icon-btn" aria-label="Close" title="Close">&times;</button>
<div id="widget-dialog-body"><p class="sub">Loading...</p></div>
</dialog>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script>
window.FRAME_BASE_API = window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};
window.FRAME_ID = {{ frame.id | tojson }};
</script>
<script src="/static/device_status_bar.js"></script>
<script src="/static/frame_header.js"></script>
<script src="/static/widget_dialog_photos.js"></script>
<script src="/static/queue.js"></script>
<script src="/static/widget_dialog_calendar.js"></script>
<script src="/static/widget_dialog_whiteboard.js"></script>
<script src="/static/frame_layout.js"></script>
{% endblock %}
-84
View File
@@ -1,84 +0,0 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %}
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% 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 photo_cfg and photo_cfg.album_id %}<option value="{{ photo_cfg.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 photo_cfg and photo_cfg.queue_target_len == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
<label>Order
<select id="order">
<option value="sequential" {% if not photo_cfg or photo_cfg.order == "sequential" %}selected{% endif %}>Sequential</option>
<option value="shuffle" {% if photo_cfg and photo_cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
</select>
</label>
<label>Display mode
<select id="display_mode">
{% for mode, label in display_mode_labels.items() %}
<option value="{{ mode }}" {% if photo_cfg and photo_cfg.display_mode == mode %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<p class="sub" style="margin-top: 8px;">How a photo's aspect ratio
is reconciled with the panel's: <strong>Crop to fill</strong>
trims the excess; <strong>Crop to faces</strong> does the same
but shifts the crop to keep people on screen; <strong>Stretch to
fill</strong> fills the panel exactly without cropping (photos
not matching the panel's aspect ratio look stretched);
<strong>Shrink to fit</strong> shows the whole photo, letterboxed
if needed.</p>
<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/device_status_bar.js"></script>
<script src="/static/frame_header.js"></script>
<script src="/static/queue.js"></script>
<script src="/static/frame_photos.js"></script>
{% endblock %}
+1 -1
View File
@@ -21,7 +21,7 @@
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script>window.FRAME_BASE_API = window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/battery_chart.js"></script>
<script src="/static/device_status_bar.js"></script>
<script src="/static/frame_header.js"></script>
@@ -1,91 +0,0 @@
{% extends "app_base.html" %}
{% block title %}{{ frame.name or "Frame" }} · Whiteboard{% endblock %}
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
{% block device_status %}{% include "_device_status_bar.html" %}{% 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>
{% if not has_whiteboard_widget %}
<div class="info-box">This frame doesn't have a <strong>Whiteboard</strong> widget on
screen yet -- settings below won't show up anywhere until one is added.</div>
{% endif %}
<div class="layout">
<div class="main-col">
<section class="card">
<h2 class="card-title">Whiteboard source</h2>
<p class="sub">Renders a Nextcloud Whiteboard (or any Excalidraw
scene) fetched over WebDAV -- credentials set up in
<a href="/settings">Settings</a>.</p>
<div id="whiteboard-current-source">
{% if whiteboard_source %}
<p class="sub" style="margin-top: 10px;">
Currently showing <strong>{{ whiteboard_source.url }}</strong>
using <strong>{{ whiteboard_source.display_name }}</strong>'s
WebDAV account.
<button type="button" class="btn-inline secondary" id="whiteboard-source-clear">Clear</button>
</p>
{% else %}
<p class="sub" style="margin-top: 10px;">No whiteboard configured yet.</p>
{% endif %}
</div>
{% if viewer_has_webdav_creds %}
<form id="whiteboard-source-form" style="margin-top: 16px;">
<label><span id="whiteboard-source-form-label">{{ "Change to one of your own files" if whiteboard_source else "Use one of your own files" }}</span>
<input type="text" id="whiteboard-url-input"
placeholder="https://cloud.example.com/remote.php/dav/files/you/Boards/family.whiteboard"
value="{{ whiteboard_source.url if whiteboard_source and whiteboard_source.user_id == user.id else '' }}">
</label>
<p class="sub" style="margin-top: 4px;">The direct WebDAV URL
to the specific file -- in Nextcloud's Files app, this is
the file's path under
<code>remote.php/dav/files/&lt;your-username&gt;/</code>.
<button type="button" class="btn-inline secondary" id="whiteboard-browse-toggle">Browse...</button></p>
<div id="whiteboard-browser" style="display: none; margin-top: 8px; border: 1px solid var(--border-color, #ccc); border-radius: 6px; padding: 8px;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
<button type="button" class="btn-inline secondary" id="whiteboard-browse-up" disabled>Up</button>
<span class="sub" id="whiteboard-browse-current" style="word-break: break-all;"></span>
</div>
<ul id="whiteboard-browse-list" style="list-style: none; margin: 0; padding: 0; max-height: 260px; overflow-y: auto;"></ul>
<p class="sub" id="whiteboard-browse-error" style="display: none; color: var(--error-color, #c00);"></p>
</div>
<button type="submit">Save</button>
</form>
{% else %}
<p class="sub" style="margin-top: 10px;">Set up WebDAV credentials
in <a href="/settings">Settings</a> first to point this frame at
one of your own files.</p>
{% endif %}
</section>
</div>
<div class="side-col">
<section class="card">
<h2 class="card-title">Preview</h2>
<p class="sub">How this frame's whiteboard currently renders.</p>
<img class="preview-img" id="whiteboard-preview" alt="Whiteboard preview">
<button type="button" class="secondary" id="whiteboard-preview-refresh">Refresh now</button>
</section>
</div>
</div>
<div id="result"></div>
{% endblock %}
{% block scripts %}
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
<script src="/static/device_status_bar.js"></script>
<script src="/static/frame_header.js"></script>
<script src="/static/frame_whiteboard.js"></script>
{% endblock %}
+26 -15
View File
@@ -1,9 +1,10 @@
"""GET /api/frames/{id}/preview/calendar -- the Calendar tab's live
render preview. No prior coverage existed for this endpoint; added
after a Phase 3 refactor (calendar_render.py's size-tier rewrite, see
the widget-system plan) left a stale photo_inlay=None kwarg here that
would have TypeError'd on the very next request -- nothing in the
existing suite actually called this endpoint to catch it."""
"""GET /api/frames/{id}/widgets/{widget_id}/preview/calendar -- the
calendar dialog's live render preview. No prior coverage existed for
this endpoint; added after a Phase 3 refactor (calendar_render.py's
size-tier rewrite, see the widget-system plan) left a stale
photo_inlay=None kwarg here that would have TypeError'd on the very next
request -- nothing in the existing suite actually called this endpoint
to catch it."""
from __future__ import annotations
@@ -25,36 +26,46 @@ def _configure_calendar_widget(db_session) -> Widget:
return widget
def test_preview_calendar_requires_a_calendar_widget(client, db_session):
def _photo_widget_id(db_session) -> int:
return db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").one().id
def test_preview_calendar_404s_for_a_widget_id_that_does_not_exist(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.get("/api/frames/1/preview/calendar")
resp = client.get("/api/frames/1/widgets/999999/preview/calendar")
assert resp.status_code == 404
def test_preview_calendar_400s_when_widget_is_not_a_calendar(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
photo_widget_id = _photo_widget_id(db_session)
resp = client.get(f"/api/frames/1/widgets/{photo_widget_id}/preview/calendar")
assert resp.status_code == 400
assert "widget" in resp.json()["detail"].lower()
def test_preview_calendar_requires_an_included_calendar(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_configure_calendar_widget(db_session)
resp = client.get("/api/frames/1/preview/calendar")
widget = _configure_calendar_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/calendar")
assert resp.status_code == 400
assert "calendar" in resp.json()["detail"].lower()
def test_preview_calendar_renders_a_png(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_configure_calendar_widget(db_session)
widget = _configure_calendar_widget(db_session)
alice = db_session.get(Frame, 1).owner
alice.calendar_ics_url = "http://example.invalid/alice.ics"
db_session.add(FrameCalendar(frame_id=1, user_id=alice.id, calendar_key="ics",
db_session.add(FrameCalendar(widget_id=widget.id, user_id=alice.id, calendar_key="ics",
calendar_label="My calendar", included=True))
db_session.commit()
monkeypatch.setattr(
"app.routers.api_frames.get_or_refresh_calendar_events_for_widget",
"app.routers.api_widgets.get_or_refresh_calendar_events_for_widget",
lambda db, frame, widget: ([], ""),
)
resp = client.get("/api/frames/1/preview/calendar", headers=csrf_headers(client))
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/calendar", headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
assert resp.content[:8] == b"\x89PNG\r\n\x1a\n"
+93 -2
View File
@@ -17,12 +17,15 @@ from app.models import (
CalendarWidgetConfig,
Frame,
FrameButtonAction,
FrameCalendar,
PhotoWidgetConfig,
ServerSettings,
Widget,
WhiteboardWidgetConfig,
)
from .conftest import make_user
def test_migrations_list_is_sequential_and_unique():
versions = [v for v, _ in MIGRATIONS]
@@ -170,11 +173,29 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
"""Exercises _migration_16's actual CREATE TABLE statements (the real
"existing production database upgrading past this migration"
scenario) rather than the fresh-install create_all() shortcut, which
every other test in this file goes through instead."""
every other test in this file goes through instead. frame_calendars
also gets rebuilt back to its pre-rekey (frame_id-keyed) shape,
matching what _migration_9/_migration_11 originally produced, so
_ensure_frame_calendars_rekeyed has a real frame_id-shaped table to
migrate."""
with db_module.engine.begin() as conn:
for table in ("frame_button_actions", "whiteboard_widget_configs",
"calendar_widget_configs", "photo_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars"))
conn.execute(text(
"CREATE TABLE frame_calendars ("
"id INTEGER PRIMARY KEY, "
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
"calendar_key TEXT NOT NULL, "
"calendar_label TEXT NOT NULL DEFAULT '', "
"included INTEGER NOT NULL DEFAULT 1, "
"color_index INTEGER)"
))
conn.execute(text(
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
))
conn.execute(text("UPDATE schema_version SET version = 15"))
frame = Frame(
@@ -183,8 +204,20 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
queue=["legacy-asset", "next-asset"], created_at=time.time(),
)
db_session.add(frame)
db_session.flush()
user = make_user(db_session, "legacy-owner")
db_session.commit()
frame_id = frame.id
frame_id, user_id = frame.id, user.id
# An orphaned frame_calendars row (this frame's mode was never
# "calendar", so it has no calendar widget for _ensure_frame_
# calendars_rekeyed to attach it to) -- exercises that it's dropped
# cleanly rather than raising.
with db_module.engine.begin() as conn:
conn.execute(text(
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included) "
"VALUES (:frame_id, :user_id, 'ics', 'Legacy Cal', 1)"
), {"frame_id": frame_id, "user_id": user_id})
run_migrations()
@@ -198,3 +231,61 @@ def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db
assert config.album_id == "legacy-album"
assert config.current_asset_id == "legacy-asset"
assert config.queue == ["legacy-asset", "next-asset"]
def test_frame_calendars_rekey_attaches_existing_rows_to_their_calendar_widget(db_session):
"""A frame whose mode was "calendar" (not "photos") gets a calendar
widget from _ensure_widgets_backfilled -- _ensure_frame_calendars_
rekeyed should then attach the pre-existing frame_id-keyed
FrameCalendar row inserted below to that widget's id, not drop it.
This is the regression case for the ordering bug the two migration
tests above's setup was designed to catch: the rekey must run AFTER
the widget backfill, not as a numbered migration racing ahead of
it (see _ensure_frame_calendars_rekeyed's own docstring)."""
with db_module.engine.begin() as conn:
for table in ("frame_button_actions", "whiteboard_widget_configs",
"calendar_widget_configs", "photo_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("DROP TABLE frame_calendars"))
conn.execute(text(
"CREATE TABLE frame_calendars ("
"id INTEGER PRIMARY KEY, "
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
"calendar_key TEXT NOT NULL, "
"calendar_label TEXT NOT NULL DEFAULT '', "
"included INTEGER NOT NULL DEFAULT 1, "
"color_index INTEGER)"
))
conn.execute(text(
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
))
conn.execute(text("UPDATE schema_version SET version = 15"))
frame = Frame(
name="Calendar Frame", device_token="tok-cal", manage_token="mtok-cal",
mode="calendar", created_at=time.time(),
)
db_session.add(frame)
db_session.flush()
user = make_user(db_session, "cal-owner")
db_session.commit()
frame_id, user_id = frame.id, user.id
with db_module.engine.begin() as conn:
conn.execute(text(
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included, color_index) "
"VALUES (:frame_id, :user_id, 'ics', 'Legacy Cal', 1, 3)"
), {"frame_id": frame_id, "user_id": user_id})
run_migrations()
widget = db_session.scalars(
select(Widget).where(Widget.frame_id == frame_id, Widget.widget_type == "calendar")
).one()
row = db_session.scalars(select(FrameCalendar).where(FrameCalendar.widget_id == widget.id)).one()
assert row.user_id == user_id
assert row.calendar_key == "ics"
assert row.calendar_label == "Legacy Cal"
assert row.included is True
assert row.color_index == 3
+81 -41
View File
@@ -2,7 +2,8 @@
permission pattern, repeated across calendar-select, tasks-source, and
whiteboard-source -- exercised at the HTTP layer (not just unit-level)
since the whole point is verifying the *endpoint's* authorization check,
not just a helper function's logic."""
not just a helper function's logic. All three now live under
/api/frames/{id}/widgets/{widget_id}/... (see routers/api_widgets.py)."""
from __future__ import annotations
@@ -30,12 +31,18 @@ def _setup_two_linked_users(client, db_session) -> Frame:
return frame
def _photo_widget_id(db_session, frame: Frame) -> int:
"""Frame #1's auto-migrated widget -- used to exercise the "wrong
widget type" 400 case (e.g. posting a whiteboard-source to a photos
widget)."""
return db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
def _add_whiteboard_widget(db_session, frame: Frame) -> Widget:
"""Frame #1's auto-migrated widget is a photos widget (its mode was
"photos" before the widget system existed) -- these tests need a
whiteboard widget too, which nothing creates yet until the
widget-placement UI (a later phase) ships, so it's added directly
here the same way the widget unit tests do."""
whiteboard widget too, added directly here the same way the widget
unit tests do."""
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
sort_order=1, created_at=time.time())
db_session.add(widget)
@@ -61,7 +68,7 @@ def test_whiteboard_source_owner_can_set_it(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_whiteboard_widget(db_session, frame)
# alice is still logged in from /setup
resp = client.post("/api/frames/1/whiteboard-source", json={
resp = client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source", json={
"url": "https://cloud.example.com/dav/files/alice/board.whiteboard",
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
@@ -72,7 +79,7 @@ def test_whiteboard_source_owner_can_set_it(client, db_session):
def test_whiteboard_source_set_always_targets_the_caller(client, db_session):
"""bob has no way to point the frame at someone else's account --
"""bob has no way to point the widget at someone else's account --
there's no target-user field in the request at all, so a "set" call
from bob always attaches to bob, even if he pastes alice's URL."""
frame = _setup_two_linked_users(client, db_session)
@@ -80,7 +87,7 @@ def test_whiteboard_source_set_always_targets_the_caller(client, db_session):
client.cookies.clear()
login(client, "bob")
resp = client.post("/api/frames/1/whiteboard-source", json={
resp = client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source", json={
"url": "https://cloud.example.com/dav/files/alice/board.whiteboard",
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
@@ -93,12 +100,14 @@ def test_whiteboard_source_set_always_targets_the_caller(client, db_session):
def test_whiteboard_source_anyone_linked_can_clear(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_whiteboard_widget(db_session, frame)
client.post("/api/frames/1/whiteboard-source", json={"url": "https://cloud.example.com/board.whiteboard"},
headers=csrf_headers(client))
client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source",
json={"url": "https://cloud.example.com/board.whiteboard"},
headers=csrf_headers(client))
client.cookies.clear()
login(client, "bob")
resp = client.post("/api/frames/1/whiteboard-source", json={"url": None}, headers=csrf_headers(client))
resp = client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source", json={"url": None},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
cfg = db_session.get(WhiteboardWidgetConfig, widget.id)
@@ -108,35 +117,43 @@ def test_whiteboard_source_anyone_linked_can_clear(client, db_session):
def test_whiteboard_source_rejects_non_http_url(client, db_session):
frame = _setup_two_linked_users(client, db_session)
_add_whiteboard_widget(db_session, frame)
resp = client.post("/api/frames/1/whiteboard-source", json={"url": "javascript:alert(1)"},
headers=csrf_headers(client))
widget = _add_whiteboard_widget(db_session, frame)
resp = client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source",
json={"url": "javascript:alert(1)"}, headers=csrf_headers(client))
assert resp.status_code == 400
def test_whiteboard_source_unlinked_user_cannot_touch_it(client, db_session):
frame = _setup_two_linked_users(client, db_session)
_add_whiteboard_widget(db_session, frame)
widget = _add_whiteboard_widget(db_session, frame)
make_user(db_session, "mallory") # exists, but never linked to frame 1
client.cookies.clear()
login(client, "mallory")
resp = client.post("/api/frames/1/whiteboard-source", json={"url": "https://x.example.com/b.whiteboard"},
resp = client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source",
json={"url": "https://x.example.com/b.whiteboard"},
headers=csrf_headers(client, "/settings"))
assert resp.status_code == 404
def test_whiteboard_source_404s_when_frame_has_no_whiteboard_widget(client, db_session):
"""Distinct from the unlinked-user 404 above -- this is a linked,
fully-permitted owner hitting the endpoint on a frame that simply
doesn't have a whiteboard widget yet (frame #1's auto-migrated
widget is a photos widget)."""
def test_whiteboard_source_404s_for_a_widget_id_that_does_not_exist(client, db_session):
_setup_two_linked_users(client, db_session)
resp = client.post("/api/frames/1/whiteboard-source", json={"url": "https://x.example.com/b.whiteboard"},
headers=csrf_headers(client))
resp = client.post("/api/frames/1/widgets/999999/whiteboard-source",
json={"url": "https://x.example.com/b.whiteboard"}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_whiteboard_source_400s_when_widget_is_not_a_whiteboard(client, db_session):
"""A linked, fully-permitted owner hitting this endpoint on a widget
that exists but is the wrong type (frame #1's auto-migrated widget
is photos, not whiteboard)."""
frame = _setup_two_linked_users(client, db_session)
photo_widget_id = _photo_widget_id(db_session, frame)
resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/whiteboard-source",
json={"url": "https://x.example.com/b.whiteboard"}, headers=csrf_headers(client))
assert resp.status_code == 400
# --- tasks-source ---
def test_tasks_source_set_always_targets_the_caller(client, db_session):
@@ -145,8 +162,8 @@ def test_tasks_source_set_always_targets_the_caller(client, db_session):
client.cookies.clear()
login(client, "bob")
resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/some/tasks/"},
headers=csrf_headers(client))
resp = client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source",
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
bob_row = db_session.query(User).filter_by(username="bob").one()
@@ -158,12 +175,13 @@ def test_tasks_source_set_always_targets_the_caller(client, db_session):
def test_tasks_source_anyone_linked_can_clear(client, db_session):
frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/alice/tasks/"},
headers=csrf_headers(client))
client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source",
json={"calendar_key": "caldav:/alice/tasks/"}, headers=csrf_headers(client))
client.cookies.clear()
login(client, "bob")
resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": None}, headers=csrf_headers(client))
resp = client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source", json={"calendar_key": None},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
cfg = db_session.get(CalendarWidgetConfig, widget.id)
@@ -171,67 +189,89 @@ def test_tasks_source_anyone_linked_can_clear(client, db_session):
assert cfg.tasks_calendar_key is None
def test_tasks_source_404s_when_frame_has_no_calendar_widget(client, db_session):
def test_tasks_source_404s_for_a_widget_id_that_does_not_exist(client, db_session):
_setup_two_linked_users(client, db_session)
resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/some/tasks/"},
headers=csrf_headers(client))
resp = client.post("/api/frames/1/widgets/999999/tasks-source",
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_tasks_source_400s_when_widget_is_not_a_calendar(client, db_session):
frame = _setup_two_linked_users(client, db_session)
photo_widget_id = _photo_widget_id(db_session, frame)
resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/tasks-source",
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client))
assert resp.status_code == 400
# --- calendar-select ---
def test_calendar_select_bob_cannot_add_alices_calendar(client, db_session):
_setup_two_linked_users(client, db_session)
frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.cookies.clear()
login(client, "bob")
resp = client.post("/api/frames/1/calendar-select", json={
resp = client.post(f"/api/frames/1/widgets/{widget.id}/calendar-select", json={
"user_id": alice_id, "calendar_key": "ics", "included": True,
}, headers=csrf_headers(client))
assert resp.status_code == 403
def test_calendar_select_owner_can_add_their_own(client, db_session):
_setup_two_linked_users(client, db_session)
frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
resp = client.post("/api/frames/1/calendar-select", json={
resp = client.post(f"/api/frames/1/widgets/{widget.id}/calendar-select", json={
"user_id": alice_id, "calendar_key": "ics", "included": True,
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
row = db_session.query(FrameCalendar).filter_by(frame_id=1, user_id=alice_id, calendar_key="ics").one()
row = db_session.query(FrameCalendar).filter_by(widget_id=widget.id, user_id=alice_id, calendar_key="ics").one()
assert row.included is True
def test_calendar_select_bob_can_mute_alices_calendar(client, db_session):
"""Muting is a display-preference veto anyone linked gets, unlike
adding -- the one-sided half of this endpoint's permission split."""
_setup_two_linked_users(client, db_session)
frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.post("/api/frames/1/calendar-select", json={
client.post(f"/api/frames/1/widgets/{widget.id}/calendar-select", json={
"user_id": alice_id, "calendar_key": "ics", "included": True,
}, headers=csrf_headers(client))
client.cookies.clear()
login(client, "bob")
resp = client.post("/api/frames/1/calendar-select", json={
resp = client.post(f"/api/frames/1/widgets/{widget.id}/calendar-select", json={
"user_id": alice_id, "calendar_key": "ics", "included": False,
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
row = db_session.query(FrameCalendar).filter_by(frame_id=1, user_id=alice_id, calendar_key="ics").one()
row = db_session.query(FrameCalendar).filter_by(widget_id=widget.id, user_id=alice_id, calendar_key="ics").one()
assert row.included is False
def test_calendar_select_cannot_mute_a_calendar_that_was_never_added(client, db_session):
_setup_two_linked_users(client, db_session)
frame = _setup_two_linked_users(client, db_session)
widget = _add_calendar_widget(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
client.cookies.clear()
login(client, "bob")
resp = client.post("/api/frames/1/calendar-select", json={
resp = client.post(f"/api/frames/1/widgets/{widget.id}/calendar-select", json={
"user_id": alice_id, "calendar_key": "ics", "included": False,
}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_calendar_select_400s_when_widget_is_not_a_calendar(client, db_session):
frame = _setup_two_linked_users(client, db_session)
photo_widget_id = _photo_widget_id(db_session, frame)
alice_id = db_session.query(User).filter_by(username="alice").one().id
resp = client.post(f"/api/frames/1/widgets/{photo_widget_id}/calendar-select", json={
"user_id": alice_id, "calendar_key": "ics", "included": True,
}, headers=csrf_headers(client))
assert resp.status_code == 400
@@ -1,9 +1,9 @@
"""get_or_refresh_whiteboard_for_widget's fetch throttle (and force=True
bypassing it) plus the whiteboard-browse HTTP endpoint.
whiteboard.fetch_and_render is monkeypatched -- it talks to a real
WebDAV server and the Node render sidecar (see render-service/), neither
of which this suite needs a real copy of to verify the *throttle*/
*permission* logic around it."""
bypassing it) plus the whiteboard-browse HTTP endpoint (both now under
/api/frames/{id}/widgets/{widget_id}/...). whiteboard.fetch_and_render is
monkeypatched -- it talks to a real WebDAV server and the Node render
sidecar (see render-service/), neither of which this suite needs a real
copy of to verify the *throttle*/*permission* logic around it."""
from __future__ import annotations
@@ -12,7 +12,7 @@ import time
from app import whiteboard
from app.models import Frame, User, Widget, WhiteboardWidgetConfig
from .conftest import csrf_headers, link_user, login, make_user
from .conftest import link_user, login, make_user
def _configure_whiteboard(db_session, frame: Frame, user: User) -> Widget:
@@ -28,6 +28,19 @@ def _configure_whiteboard(db_session, frame: Frame, user: User) -> Widget:
return widget
def _add_bare_whiteboard_widget(db_session, frame: Frame) -> Widget:
"""A whiteboard widget with no source configured yet -- for tests
that need the widget to exist (so the URL resolves) but want to
exercise the "not configured" branches themselves."""
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(WhiteboardWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
def test_unforced_call_within_throttle_uses_cache(client, db_session, monkeypatch):
from app.routers.common import get_or_refresh_whiteboard_for_widget
@@ -101,11 +114,11 @@ def test_preview_endpoint_force_query_param_bypasses_throttle(client, db_session
monkeypatch.setattr(whiteboard, "fetch_and_render",
lambda url, u, p: calls.append(1) or tiny_png)
resp = client.get("/api/frames/1/preview/whiteboard")
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/whiteboard")
assert resp.status_code == 200
assert calls == [] # fresh cache, no force -- no refetch
resp = client.get("/api/frames/1/preview/whiteboard?force=1")
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/whiteboard?force=1")
assert resp.status_code == 200
assert len(calls) == 1 # force=1 -- refetched despite fresh cache
@@ -114,7 +127,9 @@ def test_preview_endpoint_force_query_param_bypasses_throttle(client, db_session
def test_browse_requires_webdav_creds(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.get("/api/frames/1/whiteboard-browse")
frame = db_session.get(Frame, 1)
widget = _add_bare_whiteboard_widget(db_session, frame)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/whiteboard-browse")
assert resp.status_code == 400
assert "credentials" in resp.json()["detail"].lower()
@@ -125,8 +140,10 @@ def test_browse_requires_a_base_url_when_none_supplied(client, db_session):
alice.webdav_username = "alice"
alice.webdav_password = "secret"
db_session.commit()
frame = db_session.get(Frame, 1)
widget = _add_bare_whiteboard_widget(db_session, frame)
resp = client.get("/api/frames/1/whiteboard-browse")
resp = client.get(f"/api/frames/1/widgets/{widget.id}/whiteboard-browse")
assert resp.status_code == 400
assert "browse root" in resp.json()["detail"].lower()
@@ -141,6 +158,7 @@ def test_browse_uses_the_caller_own_creds_not_the_frames_owner(client, db_sessio
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
widget = _add_bare_whiteboard_widget(db_session, frame)
bob.webdav_username = "bob"
bob.webdav_password = "bobsecret"
bob.webdav_base_url = "https://cloud.example.com/dav/files/bob/"
@@ -157,7 +175,7 @@ def test_browse_uses_the_caller_own_creds_not_the_frames_owner(client, db_sessio
client.cookies.clear()
login(client, "bob")
resp = client.get("/api/frames/1/whiteboard-browse")
resp = client.get(f"/api/frames/1/widgets/{widget.id}/whiteboard-browse")
assert resp.status_code == 200, resp.text
assert seen_creds == [("https://cloud.example.com/dav/files/bob/", "bob", "bobsecret")]
assert resp.json()["entries"][0]["name"] == "Board.whiteboard"
@@ -0,0 +1,160 @@
"""api_widgets.py's config-save (form-urlencoded, dispatched by
widget_type) and the photo-queue endpoints it shares the file with --
the moved-and-consolidated counterparts of the old frame-level
api_config_save/api_queue/etc. No prior HTTP-level coverage existed for
either the old or new shape of these endpoints; added after manual
browser testing caught api_widget_config_save expecting a JSON body
while the (unmodified, copied-over) dialog JS posts form-urlencoded data
-- a real bug an HTTP-level test would have caught immediately."""
from __future__ import annotations
from app.models import CalendarWidgetConfig, Frame, PhotoWidgetConfig, Widget
from .conftest import csrf_headers
_ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}]
def _photo_widget(db_session) -> Widget:
return db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").one()
def _add_calendar_widget(db_session) -> Widget:
import time
widget = Widget(frame_id=1, widget_type="calendar", x=0, y=0, w=3, h=2,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
def _mock_immich(monkeypatch):
monkeypatch.setattr("app.routers.api_widgets.immich_client_for", lambda frame: object())
monkeypatch.setattr("app.routers.api_widgets.list_assets", lambda client, album_id: _ASSETS)
# --- api_widget_config_save ---
def test_config_save_updates_a_photos_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _photo_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"album_id": "album-42", "order": "shuffle", "display_mode": "stretch_fill",
"queue_target_len": "15"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(PhotoWidgetConfig, widget.id)
assert cfg.album_id == "album-42"
assert cfg.order == "shuffle"
assert cfg.display_mode == "stretch_fill"
assert cfg.queue_target_len == 15
def test_config_save_updates_a_calendar_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_calendar_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"calendar_view": "week", "calendar_week_start": "1", "calendar_week_days": "5",
"calendar_week_layout": "vertical", "calendar_weather_enabled": "true",
"calendar_weather_units": "celsius", "calendar_tasks_enabled": "true"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(CalendarWidgetConfig, widget.id)
assert cfg.view == "week"
assert cfg.week_start == 1
assert cfg.week_days == 5
assert cfg.week_layout == "vertical"
assert cfg.weather_enabled is True
assert cfg.weather_units == "celsius"
assert cfg.tasks_enabled is True
def test_config_save_only_partially_updates_provided_fields(client, db_session):
"""Fields not present in the POST are left untouched -- the whole
point of the partial-update convention (each dialog's own form only
ever posts its own fields)."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _photo_widget(db_session)
cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.order = "shuffle"
db_session.commit()
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"queue_target_len": "10"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(PhotoWidgetConfig, widget.id)
assert cfg.queue_target_len == 10
assert cfg.order == "shuffle" # untouched
def test_config_save_calendar_fields_are_a_no_op_on_a_photos_widget(client, db_session):
"""Posting calendar-shaped fields at a photos widget's config
endpoint doesn't error -- it just doesn't apply, since the dispatch
is purely by widget.widget_type."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _photo_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"calendar_view": "week"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
def test_config_save_404s_for_a_widget_id_that_does_not_exist(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/api/frames/1/widgets/999999/config", data={"order": "shuffle"},
headers=csrf_headers(client))
assert resp.status_code == 404
# --- photo queue (moved from the old frame-level /api/frames/{id}/queue) ---
def test_queue_requires_a_configured_album(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _photo_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/queue")
assert resp.status_code == 400
def test_queue_returns_current_and_upcoming(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
frame.immich_url = "http://immich.example.com"
frame.immich_api_key = "key"
widget = _photo_widget(db_session)
cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.album_id = "album-1"
db_session.commit()
_mock_immich(monkeypatch)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/queue")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["current"]["id"] == "asset-1"
assert [u["id"] for u in data["upcoming"]] == ["asset-2", "asset-3"]
assert data["control"]["you"] is True
def test_queue_400s_when_widget_is_not_photos(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_calendar_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/queue")
assert resp.status_code == 400