Widget system Phase 4b: per-widget gear-icon config dialogs
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:
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
@@ -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,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 = [];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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,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
|
||||
& 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 × 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/<your-username>/</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>
|
||||
@@ -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
|
||||
& 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">×</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 %}
|
||||
|
||||
@@ -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 × 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 %}
|
||||
@@ -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/<your-username>/</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 %}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user