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
|
what tells the "same event, more than one calendar" case apart from
|
||||||
an ordinary single-calendar event at render time -- see
|
an ordinary single-calendar event at render time -- see
|
||||||
_draw_color_bar. Each source's own manually pinned color
|
_draw_color_bar. Each source's own manually pinned color
|
||||||
(FrameCalendar.color_index -- see routers/api_frames.py's
|
(FrameCalendar.color_index -- see routers/api_widgets.py's
|
||||||
api_calendar_color) resolves against whichever palette this frame
|
api_widget_calendar_color) resolves against whichever palette this frame
|
||||||
actually renders with, so a pinned "Blue" stays this frame's actual
|
actually renders with, so a pinned "Blue" stays this frame's actual
|
||||||
blue; a source with no color pinned falls back to the old
|
blue; a source with no color pinned falls back to the old
|
||||||
auto-cycle-by-owner-name behavior. owners_seen is shared across every
|
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",
|
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
|
||||||
days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None,
|
days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None,
|
||||||
start_offset: int = 0) -> Image.Image:
|
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
|
columns (layout="horizontal", the original fixed-at-7 behavior
|
||||||
generalized) or stacked bands (layout="vertical", reusing
|
generalized) or stacked bands (layout="vertical", reusing
|
||||||
_draw_agenda_day the same way _build_today_tomorrow does, just for
|
_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 --
|
weekday, "start on the most recent Monday") exactly like before --
|
||||||
otherwise "start of the week" doesn't mean much for an arbitrary day
|
otherwise "start of the week" doesn't mean much for an arbitrary day
|
||||||
count, so it instead starts `start_offset` days from today (0 =
|
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)
|
img = Image.new("RGB", (target_w, target_h), BG)
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
tier = _size_tier(target_w, target_h)
|
tier = _size_tier(target_w, target_h)
|
||||||
|
|||||||
+63
-1
@@ -15,7 +15,7 @@ import secrets
|
|||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import inspect, select, text
|
||||||
|
|
||||||
from . import config, grid
|
from . import config, grid
|
||||||
from .db import SessionLocal, engine
|
from .db import SessionLocal, engine
|
||||||
@@ -378,6 +378,7 @@ def run_migrations() -> None:
|
|||||||
_ensure_frame_one()
|
_ensure_frame_one()
|
||||||
_ensure_server_settings()
|
_ensure_server_settings()
|
||||||
_ensure_widgets_backfilled()
|
_ensure_widgets_backfilled()
|
||||||
|
_ensure_frame_calendars_rekeyed()
|
||||||
|
|
||||||
|
|
||||||
def new_device_token() -> str:
|
def new_device_token() -> str:
|
||||||
@@ -604,3 +605,64 @@ def _ensure_widgets_backfilled() -> None:
|
|||||||
continue
|
continue
|
||||||
_backfill_frame_widgets(db, frame)
|
_backfill_frame_widgets(db, frame)
|
||||||
db.commit()
|
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_username: Mapped[str] = mapped_column(String, default="")
|
||||||
webdav_password: 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)
|
webdav_reuse_caldav_creds: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
# Optional starting folder for the file-picker on a frame's Whiteboard
|
# Optional starting folder for the whiteboard dialog's file-picker
|
||||||
# tab (see routers/api_frames.py's whiteboard-browse) -- purely a
|
# (see routers/api_widgets.py's api_widget_whiteboard_browse) --
|
||||||
# convenience for browsing to a file rather than typing its full URL.
|
# purely a convenience for browsing to a file rather than typing its
|
||||||
|
# full URL.
|
||||||
# Never used for fetching/rendering itself, which always uses the
|
# Never used for fetching/rendering itself, which always uses the
|
||||||
# frame's own saved whiteboard_url regardless of whether this is set.
|
# frame's own saved whiteboard_url regardless of whether this is set.
|
||||||
webdav_base_url: Mapped[str] = mapped_column(String, default="")
|
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
|
# _draw_tasks). CalDAV only (a task list is a VTODO collection, not
|
||||||
# something a plain ICS subscription meaningfully has); source is
|
# something a plain ICS subscription meaningfully has); source is
|
||||||
# one specific linked user's own CalDAV calendar, same
|
# one specific linked user's own CalDAV calendar, same
|
||||||
# owner-controls-their-own-data permission split as FrameCalendar --
|
# owner-controls-their-own-data permission split as FrameCalendar.
|
||||||
# see routers/api_frames.py's api_tasks_source. calendar_tasks_user_id
|
# calendar_tasks_user_id
|
||||||
# SET NULL on the user's deletion clears the source rather than
|
# SET NULL on the user's deletion clears the source rather than
|
||||||
# leaving a dangling reference (checked_at isn't reset by that, but
|
# leaving a dangling reference (checked_at isn't reset by that, but
|
||||||
# the next refresh attempt finds no source and just returns []).
|
# 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
|
# routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide
|
||||||
# setting like calendar mode's own frame_calendars source, not
|
# setting like calendar mode's own frame_calendars source, not
|
||||||
# personal data, but still owner-gated the same way: only
|
# personal data, but still owner-gated the same way: only
|
||||||
# whiteboard_user_id may point the frame at their own account (see
|
# whiteboard_user_id may point the frame at their own account, since
|
||||||
# routers/api_frames.py's api_whiteboard_source), since it's their
|
# it's their credentials being used to fetch it. --
|
||||||
# credentials being used to fetch it. --
|
|
||||||
whiteboard_user_id: Mapped[int | None] = mapped_column(
|
whiteboard_user_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
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
|
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
|
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
|
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"
|
__tablename__ = "frame_calendars"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
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"))
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
calendar_key: Mapped[str] = mapped_column(String)
|
calendar_key: Mapped[str] = mapped_column(String)
|
||||||
# Snapshot label for display -- so the list still reads sensibly even
|
# 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
|
# text/background) pinning this calendar's events to a specific
|
||||||
# panel color rather than calendar_render.py's old owner-name
|
# panel color rather than calendar_render.py's old owner-name
|
||||||
# auto-cycle. NULL keeps the auto-cycle behavior. Only the calendar's
|
# 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)
|
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
|
||||||
|
|
||||||
__table_args__ = (
|
__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
|
minus calendar_photo_inlay (dropped: arbitrary widget placement
|
||||||
subsumes what a fixed 50/50 inlay split did, so it's not a special
|
subsumes what a fixed 50/50 inlay split did, so it's not a special
|
||||||
case anymore, just place a photo widget alongside). "Included
|
case anymore, just place a photo widget alongside). "Included
|
||||||
calendars" stays on FrameCalendar (frame_id-keyed for now; re-keyed
|
calendars" is its own table (FrameCalendar), widget_id-keyed so each
|
||||||
to widget_id in a later phase once more than one calendar widget per
|
calendar widget on a frame has its own independent set."""
|
||||||
frame is actually supported end to end)."""
|
|
||||||
|
|
||||||
__tablename__ = "calendar_widget_configs"
|
__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
|
Auth: session-only (require_frame_view for reads, require_frame_control
|
||||||
for mutations -- the "take control" soft lock). The limited manage-QR
|
for mutations -- the "take control" soft lock). The limited manage-QR
|
||||||
surface lives separately under /api/m/ (routers/manage.py), and device
|
surface lives separately under /api/m/ (routers/manage.py), and device
|
||||||
traffic under /frame/* (routers/device.py).
|
traffic under /frame/* (routers/device.py).
|
||||||
|
|
||||||
Config saves are PARTIAL updates: each page's form posts only its own
|
Config saves are PARTIAL updates: only provided fields are touched.
|
||||||
fields (the old single Settings form split across the Photos and
|
Checkboxes are sent explicitly as "true"/"false" strings by the page JS
|
||||||
Configuration tabs), so every field is optional and only provided ones
|
-- an absent field means "not this form's field", never "unchecked".
|
||||||
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
|
from __future__ import annotations
|
||||||
@@ -20,48 +21,16 @@ import time
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
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 import select
|
||||||
from sqlalchemy.orm import Session
|
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 ..auth import require_frame_control, require_frame_view, require_user_api
|
||||||
from ..db import frame_locked, get_db, widget_locked
|
from ..db import frame_locked, get_db
|
||||||
from ..image_pipeline import (
|
from ..image_pipeline import PALETTE_LABELS, hex_to_rgb
|
||||||
DEFAULT_DISPLAY_MODE,
|
|
||||||
DISPLAY_MODES,
|
|
||||||
PALETTE_LABELS,
|
|
||||||
hex_to_rgb,
|
|
||||||
render_preview_png,
|
|
||||||
)
|
|
||||||
from ..firmware import firmware_path, parse_app_version
|
from ..firmware import firmware_path, parse_app_version
|
||||||
from ..models import (
|
from ..models import BatteryLog, Frame, Widget
|
||||||
BatteryLog,
|
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -69,8 +38,6 @@ router = APIRouter()
|
|||||||
|
|
||||||
MIN_REFRESH_INTERVAL_S = 60
|
MIN_REFRESH_INTERVAL_S = 60
|
||||||
MAX_REFRESH_INTERVAL_S = 86400
|
MAX_REFRESH_INTERVAL_S = 86400
|
||||||
MIN_QUEUE_TARGET_LEN = 5
|
|
||||||
MAX_QUEUE_TARGET_LEN = 5000
|
|
||||||
|
|
||||||
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
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")
|
@router.post("/api/frames/{frame_id}/config")
|
||||||
def api_config_save(
|
def api_config_save(
|
||||||
name: str | None = Form(None),
|
name: str | None = Form(None),
|
||||||
album_id: str | None = Form(None),
|
|
||||||
order: str | None = Form(None),
|
|
||||||
refresh_interval_s: int | 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),
|
orientation: str | None = Form(None),
|
||||||
quiet_hours_enabled: bool | None = Form(None),
|
quiet_hours_enabled: bool | None = Form(None),
|
||||||
quiet_hours_start: str | None = Form(None),
|
quiet_hours_start: str | None = Form(None),
|
||||||
@@ -134,27 +97,18 @@ def api_config_save(
|
|||||||
color_boost: float | None = Form(None),
|
color_boost: float | None = Form(None),
|
||||||
contrast_boost: float | None = Form(None),
|
contrast_boost: float | None = Form(None),
|
||||||
dither_strength: 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),
|
frame: Frame = Depends(require_frame_control),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Partial update, split across up to three sequential lock spans --
|
"""Partial update of frame-wide settings only -- per-widget settings
|
||||||
frame-level settings, the frame's photo widget, the frame's calendar
|
(album, calendar view/inclusion, whiteboard source, etc.) live on
|
||||||
widget -- rather than one, now that those settings live on separate
|
routers/api_widgets.py's /widgets/{widget_id}/... endpoints instead,
|
||||||
rows (see models.Widget's per-type extension tables). Never nested
|
since a frame can hold more than one widget of the same type and
|
||||||
(see db.widget_locked's own docstring on why that would deadlock).
|
"the frame's calendar settings" stopped being unambiguous the moment
|
||||||
|
that became possible. `mode` and `calendar_photo_inlay` are no
|
||||||
`mode` and `calendar_photo_inlay` are no longer accepted here: mode
|
longer accepted here either: mode no longer governs anything (a
|
||||||
no longer governs anything (a frame's widgets do), and photo inlay
|
frame's widgets do), and photo inlay has no widget-system equivalent
|
||||||
has no widget-system equivalent (place an independent photo widget
|
(place an independent photo widget alongside instead). All three are
|
||||||
alongside instead -- see CalendarWidgetConfig's docstring). Both are
|
|
||||||
harmless no-ops if an old cached page still POSTs them -- FastAPI
|
harmless no-ops if an old cached page still POSTs them -- FastAPI
|
||||||
silently ignores form fields with no matching parameter.
|
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
|
_reset_widget_layout_for_new_orientation) -- widget placement is
|
||||||
grid-cell-relative to the panel's long/short axis, which swaps on a
|
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
|
landscape<->portrait change, so an old placement is usually not just
|
||||||
visually wrong but literally out of bounds on the new grid.
|
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."""
|
|
||||||
with frame_locked(db, frame.id) as cfg:
|
with frame_locked(db, frame.id) as cfg:
|
||||||
if name is not None:
|
if name is not None:
|
||||||
cfg.name = name.strip()[:64] or cfg.name
|
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.dither_strength = max(0.0, min(1.0, dither_strength))
|
||||||
cfg.stats_config_saves += 1
|
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"}
|
return {"status": "saved"}
|
||||||
|
|
||||||
|
|
||||||
@@ -317,62 +198,35 @@ def api_stats(frame: Frame = Depends(require_frame_view)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/frames/{frame_id}/queue")
|
@router.get("/api/frames/{frame_id}/status")
|
||||||
def api_queue(
|
def api_status(
|
||||||
request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
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)
|
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()
|
now = time.time()
|
||||||
|
overdue_gap = quiet_hours.max_expected_gap_s(frame) * OVERDUE_FACTOR
|
||||||
return {
|
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": {
|
"control": {
|
||||||
"controller": snapshot["controller"],
|
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
|
||||||
"you": snapshot["controller_id"] == user.id,
|
"you": frame.controlled_by_user_id == user.id,
|
||||||
},
|
},
|
||||||
"device": {
|
"device": {
|
||||||
"last_seen": snapshot["last_seen"] or None,
|
"last_seen": frame.last_seen or None,
|
||||||
"overdue": bool(
|
"overdue": bool(frame.last_seen and now - frame.last_seen > overdue_gap),
|
||||||
snapshot["last_seen"] and now - snapshot["last_seen"] > snapshot["overdue_gap"]
|
"firmware_version": frame.device_firmware_version or None,
|
||||||
),
|
"firmware_available": frame.firmware_available_version or None,
|
||||||
"firmware_version": snapshot["firmware_version"] or None,
|
|
||||||
"firmware_available": snapshot["firmware_available"] or None,
|
|
||||||
"battery": (
|
"battery": (
|
||||||
{"percent": snapshot["battery_percent"], "as_of": snapshot["battery_as_of"]}
|
{"percent": frame.battery_percent, "as_of": frame.battery_as_of}
|
||||||
if snapshot["battery_percent"] >= 0
|
if frame.battery_percent >= 0 else None
|
||||||
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]}
|
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")
|
@router.post("/api/frames/{frame_id}/firmware")
|
||||||
def api_firmware_upload(
|
def api_firmware_upload(
|
||||||
|
|||||||
@@ -1,33 +1,113 @@
|
|||||||
"""CRUD + grid placement for a frame's widgets (see models.Widget) --
|
"""Everything scoped to one specific widget rather than "the frame":
|
||||||
backs the Layout tab's placement canvas (static/frame_widget_canvas.js).
|
placement CRUD (backing the Layout tab's canvas, static/frame_layout.js)
|
||||||
Every mutation re-validates bounds/minimum footprint/no-overlap
|
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
|
server-side regardless of what the client already checked -- the
|
||||||
client's own checks are UX, not the source of truth (this project's
|
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)."""
|
usual posture, e.g. api_frames.py's own field clamps)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
import time
|
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 pydantic import BaseModel
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
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 ..auth import require_frame_control, require_frame_view, require_user_api
|
||||||
from ..db import frame_locked, get_db
|
from ..db import frame_locked, get_db, widget_locked
|
||||||
from ..models import Frame, WIDGET_CONFIG_MODELS, Widget
|
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 ..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()
|
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:
|
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,
|
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}
|
"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")
|
@router.get("/api/frames/{frame_id}/widgets")
|
||||||
def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||||
user = require_user_api(request, db)
|
user = require_user_api(request, db)
|
||||||
@@ -140,3 +220,575 @@ def api_widget_delete(
|
|||||||
db.delete(widget)
|
db.delete(widget)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "deleted"}
|
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
|
client: ImmichClient, display_mode: str, asset_id: str
|
||||||
) -> tuple[Image.Image, list[dict] | None]:
|
) -> tuple[Image.Image, list[dict] | None]:
|
||||||
"""The shared first half of rendering: download the Immich preview
|
"""The shared first half of rendering: download the Immich preview
|
||||||
and (only if display_mode needs it) its detected faces. Used by both
|
and (only if display_mode needs it) its detected faces. Used by the
|
||||||
render_asset (device-facing) and the web UI's rendered-preview
|
web UI's rendered-preview endpoint (routers/api_widgets.py's
|
||||||
endpoint (routers/api_frames.py) so they can't drift apart. Takes
|
api_widget_preview_rendered). Takes display_mode directly (a photos
|
||||||
display_mode directly (a photos widget's own setting, see
|
widget's own setting, see PhotoWidgetConfig) rather than a whole
|
||||||
PhotoWidgetConfig) rather than a whole Frame -- this function only
|
Frame -- this function only ever needed that one attribute off it."""
|
||||||
ever needed that one attribute off it."""
|
|
||||||
try:
|
try:
|
||||||
jpeg_bytes = client.download_asset_preview(asset_id)
|
jpeg_bytes = client.download_asset_preview(asset_id)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
@@ -477,16 +476,16 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
|||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed.CalendarSource]:
|
def calendar_sources_for_widget(db: Session, widget: Widget) -> list[calendar_feed.CalendarSource]:
|
||||||
"""Every calendar included on this frame (FrameCalendar.included) --
|
"""Every calendar included on this calendar widget (FrameCalendar.
|
||||||
the exact set calendar_feed.merge_events needs. A calendar_key of
|
included) -- the exact set calendar_feed.merge_events needs. A
|
||||||
"ics" resolves against its owner's calendar_ics_url; "caldav:<href>"
|
calendar_key of "ics" resolves against its owner's calendar_ics_url;
|
||||||
resolves against the href itself, authenticated with the owner's
|
"caldav:<href>" resolves against the href itself, authenticated with
|
||||||
CalDAV account credentials (see caldav_client.py)."""
|
the owner's CalDAV account credentials (see caldav_client.py)."""
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
select(FrameCalendar, User)
|
select(FrameCalendar, User)
|
||||||
.join(User, User.id == FrameCalendar.user_id)
|
.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()
|
).all()
|
||||||
sources = []
|
sources = []
|
||||||
for fc, u in rows:
|
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
|
whole merged result (every included user's events together), not
|
||||||
per-user -- ICS feeds are small and this refetches at most every ~20
|
per-user -- ICS feeds are small and this refetches at most every ~20
|
||||||
minutes regardless of how many are included, so per-user cache
|
minutes regardless of how many are included, so per-user cache
|
||||||
columns would add bookkeeping for a marginal benefit.
|
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."""
|
|
||||||
cfg = db.get(CalendarWidgetConfig, widget.id)
|
cfg = db.get(CalendarWidgetConfig, widget.id)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if cfg.cached_events is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
|
if cfg.cached_events is not None and now - cfg.checked_at < calendar_feed.CHECK_INTERVAL_S:
|
||||||
return cfg.cached_events, cfg.fetch_summary
|
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)
|
today = quiet_hours.local_date(frame)
|
||||||
events, summary = calendar_feed.merge_events(
|
events, summary = calendar_feed.merge_events(
|
||||||
sources,
|
sources,
|
||||||
|
|||||||
+110
-101
@@ -1,6 +1,12 @@
|
|||||||
"""The per-frame HTML pages: Photos (/frames/{id}), Configuration,
|
"""The per-frame HTML pages: Layout (/frames/{id}, the widget placement
|
||||||
Calendar, and Stats tabs, all inside the sidebar app shell. Data loading
|
canvas), Configuration, and Stats, all inside the sidebar app shell.
|
||||||
happens client-side against /api/frames/{id}/... (routers/api_frames.py);
|
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."""
|
these routes just authorize and render the scaffold."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -20,7 +26,16 @@ from ..image_pipeline import (
|
|||||||
PALETTE_LABELS,
|
PALETTE_LABELS,
|
||||||
palette_to_hex,
|
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 ..quiet_hours import ALL_TIMEZONES
|
||||||
from .common import shell_context, widget_of_type
|
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):
|
if frame is None or not can_view_frame(db, user, frame):
|
||||||
raise HTTPException(404, "No such frame")
|
raise HTTPException(404, "No such frame")
|
||||||
ctx = shell_context(request, db, user, active_frame=frame)
|
ctx = shell_context(request, db, user, active_frame=frame)
|
||||||
ctx.update({
|
ctx.update({"frame": frame, "active_tab": tab, **extra})
|
||||||
"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,
|
|
||||||
})
|
|
||||||
return templates.TemplateResponse(template, ctx)
|
return templates.TemplateResponse(template, ctx)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/frames/{frame_id}", response_class=HTMLResponse)
|
@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)):
|
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")
|
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]:
|
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
|
the single ICS subscription (if set) plus every CalDAV calendar last
|
||||||
discovered from Settings' "Discover calendars" button. Doesn't hit
|
discovered from Settings' "Discover calendars" button. Doesn't hit
|
||||||
the network -- reads the cached list a user refreshes themselves."""
|
the network -- reads the cached list a user refreshes themselves."""
|
||||||
@@ -78,20 +98,20 @@ def _user_available_calendars(user: User) -> list[dict]:
|
|||||||
return calendars
|
return calendars
|
||||||
|
|
||||||
|
|
||||||
def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None) -> list[dict]:
|
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 tab's "Included
|
"""Per-linked-user calendar list for the calendar dialog's "Included
|
||||||
calendars" section. The viewer's own row lists EVERY calendar they
|
calendars" section. The viewer's own row lists EVERY calendar they
|
||||||
have available, each with a full add/remove toggle; every other
|
have available, each with a full add/remove toggle; every other
|
||||||
linked user's row lists ONLY the calendars they've already included
|
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:
|
(mute-only for the viewer -- see api_widgets.py's
|
||||||
only a calendar's owner may turn it on, but anyone linked to the
|
api_widget_calendar_select: only a calendar's owner may turn it on,
|
||||||
frame may turn one off)."""
|
but anyone linked to the frame may turn one off)."""
|
||||||
users = db.execute(
|
users = db.execute(
|
||||||
select(User).join(UserFrame, UserFrame.user_id == User.id)
|
select(User).join(UserFrame, UserFrame.user_id == User.id)
|
||||||
.where(UserFrame.frame_id == frame_id).order_by(User.username)
|
.where(UserFrame.frame_id == frame_id).order_by(User.username)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
included_by_user: dict[int, list[FrameCalendar]] = {}
|
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)
|
included_by_user.setdefault(fc.user_id, []).append(fc)
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
@@ -116,12 +136,12 @@ def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None)
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig | None) -> dict | None:
|
def _tasks_source_info(db: Session, calendar_cfg: CalendarWidgetConfig) -> dict | None:
|
||||||
"""Whose CalDAV calendar this frame's week-view task list currently
|
"""Whose CalDAV calendar this widget's week-view task list currently
|
||||||
pulls from, and its label -- for showing "using <name>'s Chores
|
pulls from, and its label -- for showing "using <name>'s Chores
|
||||||
list" to everyone linked, not just whoever set it. None if no
|
list" to everyone linked, not just whoever set it. None if no
|
||||||
source is configured."""
|
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
|
return None
|
||||||
user = db.get(User, calendar_cfg.tasks_user_id)
|
user = db.get(User, calendar_cfg.tasks_user_id)
|
||||||
if user is None:
|
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}
|
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 _whiteboard_source_info(db: Session, whiteboard_cfg: WhiteboardWidgetConfig) -> dict | None:
|
||||||
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
"""Whose account this widget currently fetches with, for showing
|
||||||
return _frame_page(
|
"using <name>'s account" to everyone linked, not just whoever set
|
||||||
request, db, frame_id, "frame_config.html", "config",
|
it. None if no source is configured."""
|
||||||
timezones=ALL_TIMEZONES,
|
if not whiteboard_cfg.user_id or not whiteboard_cfg.url:
|
||||||
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:
|
|
||||||
return None
|
return None
|
||||||
user = db.get(User, whiteboard_cfg.user_id)
|
user = db.get(User, whiteboard_cfg.user_id)
|
||||||
if user is None:
|
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}
|
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)
|
WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday",
|
||||||
def frame_whiteboard_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
4: "Friday", 5: "Saturday", 6: "Sunday"}
|
||||||
viewer = current_user(request, db)
|
|
||||||
|
|
||||||
|
@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)
|
frame = db.get(Frame, frame_id)
|
||||||
viewer_has_webdav_creds = False
|
if frame is None or not can_view_frame(db, user, frame):
|
||||||
whiteboard_cfg = None
|
raise HTTPException(404, "No such frame")
|
||||||
if viewer is not None and frame is not None and can_view_frame(db, viewer, 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_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,
|
||||||
|
})
|
||||||
|
|
||||||
|
raise HTTPException(400, f"Unknown widget type: {widget.widget_type}")
|
||||||
@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")
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
// Device status bar: always-visible strip (below the page title, above
|
// Device status bar: always-visible strip (below the page title, above
|
||||||
// the tabs -- see _device_status_bar.html) showing last-seen/firmware/
|
// 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
|
// 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;
|
let lastDeviceStatus = null;
|
||||||
|
|
||||||
@@ -51,7 +54,7 @@ function renderDeviceStatusBar(device) {
|
|||||||
|
|
||||||
async function loadDeviceStatusBar() {
|
async function loadDeviceStatusBar() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/queue`);
|
const resp = await fetch(`${window.FRAME_BASE_API}/status`);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
return;
|
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.
|
// Configuration tab: frame-wide settings (orientation, quiet hours,
|
||||||
// Frame name/mode live in the page header now (frame_header.js), Calendar
|
// palette/color/contrast/dither, firmware, battery alerts) + take
|
||||||
// settings have their own tab (frame_calendar.js), and Order/Display mode
|
// control. Frame name lives in the page header now (frame_header.js);
|
||||||
// live on the Photos tab (frame_photos.js) -- photos-specific settings,
|
// every per-widget setting (album, calendar view/inclusion, whiteboard
|
||||||
// not device-wide configuration. window.FRAME_API is set by the template.
|
// source) lives in its own widget's gear-icon dialog instead (see
|
||||||
// Checkboxes are always sent explicitly as "true"/"false" -- the server
|
// static/frame_layout.js) -- this tab never touches those.
|
||||||
// treats absent fields as "leave unchanged", so a checkbox must never be
|
// window.FRAME_API is set by the template. Checkboxes are always sent
|
||||||
// simply omitted.
|
// explicitly as "true"/"false" -- the server treats absent fields as
|
||||||
|
// "leave unchanged", so a checkbox must never be simply omitted.
|
||||||
|
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
|
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
|
||||||
@@ -72,7 +73,7 @@ async function takeControl() {
|
|||||||
async function loadControl() {
|
async function loadControl() {
|
||||||
const banner = document.getElementById('control-banner');
|
const banner = document.getElementById('control-banner');
|
||||||
try {
|
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
|
if (!resp.ok) return; // unconfigured frame: control still works via 409s
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.control && !data.control.you) {
|
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 ----
|
// ---- 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() {
|
function loadPreview() {
|
||||||
|
if (!window.PHOTO_WIDGET_PREVIEW_API) return;
|
||||||
const bust = Date.now(); // avoid a stale cached image after settings change
|
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-original').src = `${window.PHOTO_WIDGET_PREVIEW_API}/preview/original?_=${bust}`;
|
||||||
document.getElementById('preview-rendered').src = `${window.FRAME_API}/preview/rendered?_=${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();
|
loadPreview();
|
||||||
|
|
||||||
// ---- Battery alerts card ----
|
// ---- Battery alerts card ----
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// Page-header controls shared by every per-frame page (Photos/
|
// Page-header controls shared by every per-frame page (Layout/
|
||||||
// Configuration/Calendar/Whiteboard/Stats): the frame-name pencil-edit,
|
// Configuration/Stats): the frame-name pencil-edit, living outside the
|
||||||
// living outside the tab structure since it applies regardless of which
|
// tab structure since it applies regardless of which tab is open.
|
||||||
// tab is open. Depends on window.FRAME_API (set per-page) and
|
// 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.
|
// common.js's showStatus/apiError.
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
@@ -12,7 +14,7 @@
|
|||||||
var textEl = document.getElementById('frame-name-text');
|
var textEl = document.getElementById('frame-name-text');
|
||||||
var saveBtn = document.getElementById('frame-name-save');
|
var saveBtn = document.getElementById('frame-name-save');
|
||||||
var cancelBtn = document.getElementById('frame-name-cancel');
|
var cancelBtn = document.getElementById('frame-name-cancel');
|
||||||
if (!view || !window.FRAME_API) return;
|
if (!view || !window.FRAME_BASE_API) return;
|
||||||
|
|
||||||
function openEdit() {
|
function openEdit() {
|
||||||
input.value = textEl.textContent.trim();
|
input.value = textEl.textContent.trim();
|
||||||
@@ -36,7 +38,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${window.FRAME_API}/config`, {
|
const resp = await fetch(`${window.FRAME_BASE_API}/config`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: new URLSearchParams({ name }),
|
body: new URLSearchParams({ name }),
|
||||||
|
|||||||
@@ -191,6 +191,15 @@ function renderCanvas() {
|
|||||||
label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type;
|
label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type;
|
||||||
box.appendChild(label);
|
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');
|
const removeBtn = document.createElement('button');
|
||||||
removeBtn.type = 'button';
|
removeBtn.type = 'button';
|
||||||
removeBtn.className = 'widget-box-remove';
|
removeBtn.className = 'widget-box-remove';
|
||||||
@@ -246,4 +255,66 @@ window.addEventListener('resize', () => {
|
|||||||
resizeTimer = setTimeout(layoutCanvas, 100);
|
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();
|
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
|
// machine below (hold-to-arm on touch so page scrolling still works) is
|
||||||
// battle-tested; treat changes with suspicion.
|
// battle-tested; treat changes with suspicion.
|
||||||
//
|
//
|
||||||
// Expects window.FRAME_API = '/api/frames/<id>' set by the page, and a
|
// Expects window.FRAME_API = '/api/frames/<id>/widgets/<widget_id>' (set
|
||||||
// loadQueue() global (frame_photos.js) to refetch authoritative state.
|
// by frame_layout.js when the photos dialog opens), and a loadQueue()
|
||||||
|
// global (widget_dialog_photos.js) to refetch authoritative state.
|
||||||
|
|
||||||
let upcomingItems = [];
|
let upcomingItems = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Settings page: "Discover calendars" against the CalDAV account already
|
// Settings page: "Discover calendars" against the CalDAV account already
|
||||||
// saved on this form (same idiom as frame_photos.js's Load Albums using
|
// saved on this form (same idiom as widget_dialog_photos.js's Load
|
||||||
// the frame's already-saved Immich creds) -- so this only works after
|
// Albums using the frame's already-saved Immich creds) -- so this only
|
||||||
// the CalDAV URL/username/password have been saved once.
|
// works after the CalDAV URL/username/password have been saved once.
|
||||||
|
|
||||||
// Hides the dedicated WebDAV username/password fields while "reuse my
|
// Hides the dedicated WebDAV username/password fields while "reuse my
|
||||||
// CalDAV creds" is checked -- they'd be ignored server-side anyway (see
|
// 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);
|
color: var(--text);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.widget-box-remove {
|
.widget-box-remove, .widget-box-settings {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
right: 4px;
|
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -373,9 +372,12 @@ button.secondary:hover { background: var(--surface-alt); }
|
|||||||
border: none;
|
border: none;
|
||||||
background: var(--overlay);
|
background: var(--overlay);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
cursor: pointer;
|
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 {
|
.widget-box-resize-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -389,6 +391,32 @@ button.secondary:hover { background: var(--surface-alt); }
|
|||||||
border-bottom-right-radius: 4px;
|
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 {
|
code {
|
||||||
background: var(--surface-alt);
|
background: var(--surface-alt);
|
||||||
color: var(--text);
|
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">
|
<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 == 'layout' %}active{% endif %}">Layout</a>
|
||||||
<a href="/frames/{{ frame.id }}" class="{% if active_tab == 'photos' %}active{% endif %}">Photos</a>
|
|
||||||
<a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</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>
|
<a href="/frames/{{ frame.id }}/stats" class="{% if active_tab == 'stats' %}active{% endif %}">Stats</a>
|
||||||
</nav>
|
</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,8 +158,10 @@
|
|||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2 class="card-title">Preview</h2>
|
<h2 class="card-title">Preview</h2>
|
||||||
<p class="sub">The current photo, and exactly how it renders on the
|
{% if photo_widget_id %}
|
||||||
panel with this frame's saved settings above.</p>
|
<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-compare">
|
||||||
<div class="preview-pane">
|
<div class="preview-pane">
|
||||||
<p class="sub">Now displaying</p>
|
<p class="sub">Now displaying</p>
|
||||||
@@ -171,6 +173,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,8 +186,9 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<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.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>
|
||||||
<script src="/static/device_status_bar.js"></script>
|
<script src="/static/device_status_bar.js"></script>
|
||||||
<script src="/static/frame_header.js"></script>
|
<script src="/static/frame_header.js"></script>
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
<h2 class="card-title">Widgets</h2>
|
<h2 class="card-title">Widgets</h2>
|
||||||
<p class="sub">Drag a widget to move it, drag its bottom-right corner
|
<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.
|
to resize it -- like arranging widgets on a phone's home screen.
|
||||||
Widgets can't overlap. Each widget's own settings (which album,
|
Widgets can't overlap. Click a widget's gear icon for its own
|
||||||
which calendars, etc.) live on its type's own tab.</p>
|
settings (which album, which calendars, etc.).</p>
|
||||||
<div id="widget-canvas-wrap">
|
<div id="widget-canvas-wrap">
|
||||||
<div id="widget-canvas"></div>
|
<div id="widget-canvas"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,12 +35,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div id="result"></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% 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/device_status_bar.js"></script>
|
||||||
<script src="/static/frame_header.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>
|
<script src="/static/frame_layout.js"></script>
|
||||||
{% endblock %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% 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/battery_chart.js"></script>
|
||||||
<script src="/static/device_status_bar.js"></script>
|
<script src="/static/device_status_bar.js"></script>
|
||||||
<script src="/static/frame_header.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
|
"""GET /api/frames/{id}/widgets/{widget_id}/preview/calendar -- the
|
||||||
render preview. No prior coverage existed for this endpoint; added
|
calendar dialog's live render preview. No prior coverage existed for
|
||||||
after a Phase 3 refactor (calendar_render.py's size-tier rewrite, see
|
this endpoint; added after a Phase 3 refactor (calendar_render.py's
|
||||||
the widget-system plan) left a stale photo_inlay=None kwarg here that
|
size-tier rewrite, see the widget-system plan) left a stale
|
||||||
would have TypeError'd on the very next request -- nothing in the
|
photo_inlay=None kwarg here that would have TypeError'd on the very next
|
||||||
existing suite actually called this endpoint to catch it."""
|
request -- nothing in the existing suite actually called this endpoint
|
||||||
|
to catch it."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -25,36 +26,46 @@ def _configure_calendar_widget(db_session) -> Widget:
|
|||||||
return 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"})
|
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 resp.status_code == 400
|
||||||
assert "widget" in resp.json()["detail"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_preview_calendar_requires_an_included_calendar(client, db_session):
|
def test_preview_calendar_requires_an_included_calendar(client, db_session):
|
||||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||||
_configure_calendar_widget(db_session)
|
widget = _configure_calendar_widget(db_session)
|
||||||
resp = client.get("/api/frames/1/preview/calendar")
|
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/calendar")
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert "calendar" in resp.json()["detail"].lower()
|
assert "calendar" in resp.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
def test_preview_calendar_renders_a_png(client, db_session, monkeypatch):
|
def test_preview_calendar_renders_a_png(client, db_session, monkeypatch):
|
||||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
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 = db_session.get(Frame, 1).owner
|
||||||
alice.calendar_ics_url = "http://example.invalid/alice.ics"
|
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))
|
calendar_label="My calendar", included=True))
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
monkeypatch.setattr(
|
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: ([], ""),
|
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.status_code == 200, resp.text
|
||||||
assert resp.headers["content-type"] == "image/png"
|
assert resp.headers["content-type"] == "image/png"
|
||||||
assert resp.content[:8] == b"\x89PNG\r\n\x1a\n"
|
assert resp.content[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ from app.models import (
|
|||||||
CalendarWidgetConfig,
|
CalendarWidgetConfig,
|
||||||
Frame,
|
Frame,
|
||||||
FrameButtonAction,
|
FrameButtonAction,
|
||||||
|
FrameCalendar,
|
||||||
PhotoWidgetConfig,
|
PhotoWidgetConfig,
|
||||||
ServerSettings,
|
ServerSettings,
|
||||||
Widget,
|
Widget,
|
||||||
WhiteboardWidgetConfig,
|
WhiteboardWidgetConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .conftest import make_user
|
||||||
|
|
||||||
|
|
||||||
def test_migrations_list_is_sequential_and_unique():
|
def test_migrations_list_is_sequential_and_unique():
|
||||||
versions = [v for v, _ in MIGRATIONS]
|
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
|
"""Exercises _migration_16's actual CREATE TABLE statements (the real
|
||||||
"existing production database upgrading past this migration"
|
"existing production database upgrading past this migration"
|
||||||
scenario) rather than the fresh-install create_all() shortcut, which
|
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:
|
with db_module.engine.begin() as conn:
|
||||||
for table in ("frame_button_actions", "whiteboard_widget_configs",
|
for table in ("frame_button_actions", "whiteboard_widget_configs",
|
||||||
"calendar_widget_configs", "photo_widget_configs", "widgets"):
|
"calendar_widget_configs", "photo_widget_configs", "widgets"):
|
||||||
conn.execute(text(f"DROP TABLE {table}"))
|
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"))
|
conn.execute(text("UPDATE schema_version SET version = 15"))
|
||||||
|
|
||||||
frame = Frame(
|
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(),
|
queue=["legacy-asset", "next-asset"], created_at=time.time(),
|
||||||
)
|
)
|
||||||
db_session.add(frame)
|
db_session.add(frame)
|
||||||
|
db_session.flush()
|
||||||
|
user = make_user(db_session, "legacy-owner")
|
||||||
db_session.commit()
|
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()
|
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.album_id == "legacy-album"
|
||||||
assert config.current_asset_id == "legacy-asset"
|
assert config.current_asset_id == "legacy-asset"
|
||||||
assert config.queue == ["legacy-asset", "next-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
|
permission pattern, repeated across calendar-select, tasks-source, and
|
||||||
whiteboard-source -- exercised at the HTTP layer (not just unit-level)
|
whiteboard-source -- exercised at the HTTP layer (not just unit-level)
|
||||||
since the whole point is verifying the *endpoint's* authorization check,
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -30,12 +31,18 @@ def _setup_two_linked_users(client, db_session) -> Frame:
|
|||||||
return 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:
|
def _add_whiteboard_widget(db_session, frame: Frame) -> Widget:
|
||||||
"""Frame #1's auto-migrated widget is a photos widget (its mode was
|
"""Frame #1's auto-migrated widget is a photos widget (its mode was
|
||||||
"photos" before the widget system existed) -- these tests need a
|
"photos" before the widget system existed) -- these tests need a
|
||||||
whiteboard widget too, which nothing creates yet until the
|
whiteboard widget too, added directly here the same way the widget
|
||||||
widget-placement UI (a later phase) ships, so it's added directly
|
unit tests do."""
|
||||||
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,
|
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
|
||||||
sort_order=1, created_at=time.time())
|
sort_order=1, created_at=time.time())
|
||||||
db_session.add(widget)
|
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)
|
frame = _setup_two_linked_users(client, db_session)
|
||||||
widget = _add_whiteboard_widget(db_session, frame)
|
widget = _add_whiteboard_widget(db_session, frame)
|
||||||
# alice is still logged in from /setup
|
# 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",
|
"url": "https://cloud.example.com/dav/files/alice/board.whiteboard",
|
||||||
}, headers=csrf_headers(client))
|
}, headers=csrf_headers(client))
|
||||||
assert resp.status_code == 200, resp.text
|
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):
|
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
|
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."""
|
from bob always attaches to bob, even if he pastes alice's URL."""
|
||||||
frame = _setup_two_linked_users(client, db_session)
|
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()
|
client.cookies.clear()
|
||||||
login(client, "bob")
|
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",
|
"url": "https://cloud.example.com/dav/files/alice/board.whiteboard",
|
||||||
}, headers=csrf_headers(client))
|
}, headers=csrf_headers(client))
|
||||||
assert resp.status_code == 200, resp.text
|
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):
|
def test_whiteboard_source_anyone_linked_can_clear(client, db_session):
|
||||||
frame = _setup_two_linked_users(client, db_session)
|
frame = _setup_two_linked_users(client, db_session)
|
||||||
widget = _add_whiteboard_widget(db_session, frame)
|
widget = _add_whiteboard_widget(db_session, frame)
|
||||||
client.post("/api/frames/1/whiteboard-source", json={"url": "https://cloud.example.com/board.whiteboard"},
|
client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source",
|
||||||
|
json={"url": "https://cloud.example.com/board.whiteboard"},
|
||||||
headers=csrf_headers(client))
|
headers=csrf_headers(client))
|
||||||
|
|
||||||
client.cookies.clear()
|
client.cookies.clear()
|
||||||
login(client, "bob")
|
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
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
cfg = db_session.get(WhiteboardWidgetConfig, widget.id)
|
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):
|
def test_whiteboard_source_rejects_non_http_url(client, db_session):
|
||||||
frame = _setup_two_linked_users(client, db_session)
|
frame = _setup_two_linked_users(client, db_session)
|
||||||
_add_whiteboard_widget(db_session, frame)
|
widget = _add_whiteboard_widget(db_session, frame)
|
||||||
resp = client.post("/api/frames/1/whiteboard-source", json={"url": "javascript:alert(1)"},
|
resp = client.post(f"/api/frames/1/widgets/{widget.id}/whiteboard-source",
|
||||||
headers=csrf_headers(client))
|
json={"url": "javascript:alert(1)"}, headers=csrf_headers(client))
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_whiteboard_source_unlinked_user_cannot_touch_it(client, db_session):
|
def test_whiteboard_source_unlinked_user_cannot_touch_it(client, db_session):
|
||||||
frame = _setup_two_linked_users(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
|
make_user(db_session, "mallory") # exists, but never linked to frame 1
|
||||||
client.cookies.clear()
|
client.cookies.clear()
|
||||||
login(client, "mallory")
|
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"))
|
headers=csrf_headers(client, "/settings"))
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_whiteboard_source_404s_when_frame_has_no_whiteboard_widget(client, db_session):
|
def test_whiteboard_source_404s_for_a_widget_id_that_does_not_exist(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)."""
|
|
||||||
_setup_two_linked_users(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"},
|
resp = client.post("/api/frames/1/widgets/999999/whiteboard-source",
|
||||||
headers=csrf_headers(client))
|
json={"url": "https://x.example.com/b.whiteboard"}, headers=csrf_headers(client))
|
||||||
assert resp.status_code == 404
|
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 ---
|
# --- tasks-source ---
|
||||||
|
|
||||||
def test_tasks_source_set_always_targets_the_caller(client, db_session):
|
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()
|
client.cookies.clear()
|
||||||
login(client, "bob")
|
login(client, "bob")
|
||||||
|
|
||||||
resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/some/tasks/"},
|
resp = client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source",
|
||||||
headers=csrf_headers(client))
|
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client))
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
bob_row = db_session.query(User).filter_by(username="bob").one()
|
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):
|
def test_tasks_source_anyone_linked_can_clear(client, db_session):
|
||||||
frame = _setup_two_linked_users(client, db_session)
|
frame = _setup_two_linked_users(client, db_session)
|
||||||
widget = _add_calendar_widget(db_session, frame)
|
widget = _add_calendar_widget(db_session, frame)
|
||||||
client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/alice/tasks/"},
|
client.post(f"/api/frames/1/widgets/{widget.id}/tasks-source",
|
||||||
headers=csrf_headers(client))
|
json={"calendar_key": "caldav:/alice/tasks/"}, headers=csrf_headers(client))
|
||||||
|
|
||||||
client.cookies.clear()
|
client.cookies.clear()
|
||||||
login(client, "bob")
|
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
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
cfg = db_session.get(CalendarWidgetConfig, widget.id)
|
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
|
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)
|
_setup_two_linked_users(client, db_session)
|
||||||
resp = client.post("/api/frames/1/tasks-source", json={"calendar_key": "caldav:/some/tasks/"},
|
resp = client.post("/api/frames/1/widgets/999999/tasks-source",
|
||||||
headers=csrf_headers(client))
|
json={"calendar_key": "caldav:/some/tasks/"}, headers=csrf_headers(client))
|
||||||
assert resp.status_code == 404
|
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 ---
|
# --- calendar-select ---
|
||||||
|
|
||||||
def test_calendar_select_bob_cannot_add_alices_calendar(client, db_session):
|
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
|
alice_id = db_session.query(User).filter_by(username="alice").one().id
|
||||||
|
|
||||||
client.cookies.clear()
|
client.cookies.clear()
|
||||||
login(client, "bob")
|
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,
|
"user_id": alice_id, "calendar_key": "ics", "included": True,
|
||||||
}, headers=csrf_headers(client))
|
}, headers=csrf_headers(client))
|
||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_select_owner_can_add_their_own(client, db_session):
|
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
|
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,
|
"user_id": alice_id, "calendar_key": "ics", "included": True,
|
||||||
}, headers=csrf_headers(client))
|
}, headers=csrf_headers(client))
|
||||||
assert resp.status_code == 200, resp.text
|
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
|
assert row.included is True
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_select_bob_can_mute_alices_calendar(client, db_session):
|
def test_calendar_select_bob_can_mute_alices_calendar(client, db_session):
|
||||||
"""Muting is a display-preference veto anyone linked gets, unlike
|
"""Muting is a display-preference veto anyone linked gets, unlike
|
||||||
adding -- the one-sided half of this endpoint's permission split."""
|
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
|
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,
|
"user_id": alice_id, "calendar_key": "ics", "included": True,
|
||||||
}, headers=csrf_headers(client))
|
}, headers=csrf_headers(client))
|
||||||
|
|
||||||
client.cookies.clear()
|
client.cookies.clear()
|
||||||
login(client, "bob")
|
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,
|
"user_id": alice_id, "calendar_key": "ics", "included": False,
|
||||||
}, headers=csrf_headers(client))
|
}, headers=csrf_headers(client))
|
||||||
assert resp.status_code == 200, resp.text
|
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
|
assert row.included is False
|
||||||
|
|
||||||
|
|
||||||
def test_calendar_select_cannot_mute_a_calendar_that_was_never_added(client, db_session):
|
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
|
alice_id = db_session.query(User).filter_by(username="alice").one().id
|
||||||
|
|
||||||
client.cookies.clear()
|
client.cookies.clear()
|
||||||
login(client, "bob")
|
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,
|
"user_id": alice_id, "calendar_key": "ics", "included": False,
|
||||||
}, headers=csrf_headers(client))
|
}, headers=csrf_headers(client))
|
||||||
assert resp.status_code == 404
|
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
|
"""get_or_refresh_whiteboard_for_widget's fetch throttle (and force=True
|
||||||
bypassing it) plus the whiteboard-browse HTTP endpoint.
|
bypassing it) plus the whiteboard-browse HTTP endpoint (both now under
|
||||||
whiteboard.fetch_and_render is monkeypatched -- it talks to a real
|
/api/frames/{id}/widgets/{widget_id}/...). whiteboard.fetch_and_render is
|
||||||
WebDAV server and the Node render sidecar (see render-service/), neither
|
monkeypatched -- it talks to a real WebDAV server and the Node render
|
||||||
of which this suite needs a real copy of to verify the *throttle*/
|
sidecar (see render-service/), neither of which this suite needs a real
|
||||||
*permission* logic around it."""
|
copy of to verify the *throttle*/*permission* logic around it."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ import time
|
|||||||
from app import whiteboard
|
from app import whiteboard
|
||||||
from app.models import Frame, User, Widget, WhiteboardWidgetConfig
|
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:
|
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
|
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):
|
def test_unforced_call_within_throttle_uses_cache(client, db_session, monkeypatch):
|
||||||
from app.routers.common import get_or_refresh_whiteboard_for_widget
|
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",
|
monkeypatch.setattr(whiteboard, "fetch_and_render",
|
||||||
lambda url, u, p: calls.append(1) or tiny_png)
|
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 resp.status_code == 200
|
||||||
assert calls == [] # fresh cache, no force -- no refetch
|
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 resp.status_code == 200
|
||||||
assert len(calls) == 1 # force=1 -- refetched despite fresh cache
|
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):
|
def test_browse_requires_webdav_creds(client, db_session):
|
||||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
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 resp.status_code == 400
|
||||||
assert "credentials" in resp.json()["detail"].lower()
|
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_username = "alice"
|
||||||
alice.webdav_password = "secret"
|
alice.webdav_password = "secret"
|
||||||
db_session.commit()
|
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 resp.status_code == 400
|
||||||
assert "browse root" in resp.json()["detail"].lower()
|
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")
|
bob = make_user(db_session, "bob")
|
||||||
frame = db_session.get(Frame, 1)
|
frame = db_session.get(Frame, 1)
|
||||||
link_user(db_session, bob, frame)
|
link_user(db_session, bob, frame)
|
||||||
|
widget = _add_bare_whiteboard_widget(db_session, frame)
|
||||||
bob.webdav_username = "bob"
|
bob.webdav_username = "bob"
|
||||||
bob.webdav_password = "bobsecret"
|
bob.webdav_password = "bobsecret"
|
||||||
bob.webdav_base_url = "https://cloud.example.com/dav/files/bob/"
|
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()
|
client.cookies.clear()
|
||||||
login(client, "bob")
|
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 resp.status_code == 200, resp.text
|
||||||
assert seen_creds == [("https://cloud.example.com/dav/files/bob/", "bob", "bobsecret")]
|
assert seen_creds == [("https://cloud.example.com/dav/files/bob/", "bob", "bobsecret")]
|
||||||
assert resp.json()["entries"][0]["name"] == "Board.whiteboard"
|
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