From 644fdefa66d291c33328ddd79fadbd7e574ab2d4 Mon Sep 17 00:00:00 2001
From: Thomas Faour
Date: Thu, 23 Jul 2026 17:02:08 -0400
Subject: [PATCH] Add whiteboard frame mode (Nextcloud Whiteboard / Excalidraw
over WebDAV)
New third mode alongside photos/calendar: fetches a .whiteboard file
over plain WebDAV (Basic auth -- generic, not Nextcloud-specific) and
renders it via a small Node.js sidecar using Excalidraw's own real
export code (@excalidraw/utils + @resvg/resvg-js, no headless browser),
since a .whiteboard file turns out to be Excalidraw scene JSON, not an
image. The sidecar runs as a second process inside this same container
(Dockerfile installs Node, start.sh backgrounds it before exec'ing
uvicorn) rather than a separate docker-compose service -- lightweight,
stateless, reachable only at 127.0.0.1 from the Python process, nothing
worth independently scaling.
The rendered PNG is treated exactly like a photo from there on --
composed/quantized through the existing image_pipeline (letterboxed,
never cropped) rather than a second parallel rendering pipeline.
WebDAV credentials support the common "it's actually the same Nextcloud
account as my CalDAV" case (an explicit opt-in checkbox, not silently
inferred) while still working with any WebDAV server generically.
Frame-level source (URL + owning account) follows the same owner-
controls-their-own-data permission split as calendar sources and the
week view's task list: only the account owner can point a frame at it,
anyone linked can clear it.
Honest limitation: this environment has no Node.js/npm, so
render-service/ is written carefully against each library's documented
API (verified via the npm registry, including transitive dependency
licenses after the CalDAV/AGPL surprise earlier this session) but has
never actually been executed. First real docker build is the first
true test -- see render-service/README.md.
---
.gitignore | 6 ++
server/.dockerignore | 7 ++
server/Dockerfile | 21 ++++-
server/README.md | 23 +++++
server/app/migration.py | 20 ++++
server/app/models.py | 40 +++++++-
server/app/routers/api_frames.py | 59 ++++++++++++
server/app/routers/common.py | 51 +++++++++-
server/app/routers/device.py | 52 +++++++++-
server/app/routers/frame_pages.py | 28 ++++++
server/app/routers/pages.py | 8 ++
server/app/static/frame_header.js | 5 +-
server/app/static/frame_whiteboard.js | 79 ++++++++++++++++
server/app/static/settings.js | 13 +++
server/app/templates/_frame_mode_select.html | 1 +
server/app/templates/_frame_tabs.html | 2 +
server/app/templates/frame_whiteboard.html | 80 ++++++++++++++++
server/app/templates/settings.html | 26 +++++
server/app/webdav_client.py | 46 +++++++++
server/app/whiteboard.py | 75 +++++++++++++++
server/render-service/README.md | 33 +++++++
server/render-service/package.json | 17 ++++
server/render-service/server.js | 99 ++++++++++++++++++++
server/start.sh | 10 ++
24 files changed, 792 insertions(+), 9 deletions(-)
create mode 100644 server/.dockerignore
create mode 100644 server/app/static/frame_whiteboard.js
create mode 100644 server/app/templates/frame_whiteboard.html
create mode 100644 server/app/webdav_client.py
create mode 100644 server/app/whiteboard.py
create mode 100644 server/render-service/README.md
create mode 100644 server/render-service/package.json
create mode 100644 server/render-service/server.js
create mode 100755 server/start.sh
diff --git a/.gitignore b/.gitignore
index 500e022..6e00562 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,12 @@ server/**/__pycache__/
server/.venv/
server/*.egg-info/
server/data/
+# render-service/ (whiteboard mode's Node sidecar) -- installed fresh
+# inside the Docker image, never committed. No package-lock.json exists
+# yet either (no Node/npm available in this project's dev environment to
+# generate one -- see render-service/README.md); if one's added later, do
+# NOT ignore it, lockfiles belong in git.
+server/render-service/node_modules/
# Real deploy config, copied from docker-compose.yml.example -- holds the
# Immich API key, must never be committed.
server/docker-compose.yml
diff --git a/server/.dockerignore b/server/.dockerignore
new file mode 100644
index 0000000..f4935e8
--- /dev/null
+++ b/server/.dockerignore
@@ -0,0 +1,7 @@
+__pycache__/
+**/__pycache__/
+.venv/
+*.egg-info/
+data/
+render-service/node_modules/
+.git/
diff --git a/server/Dockerfile b/server/Dockerfile
index 1c8c4f2..581ca9a 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -6,14 +6,31 @@ WORKDIR /app
# database backing the web UI's "Timezone" setting (used by "Quiet hours")
# would have no named zones to resolve without this -- ZoneInfo() would
# raise for anything other than "UTC".
-RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
+#
+# Node.js + fonts: whiteboard frame mode's render-service/ (own README
+# there) runs as a second process in this same container rather than a
+# separate compose service -- it's a lightweight, stateless, localhost-
+# only sidecar with nothing worth independently scaling or restarting.
+# NodeSource's setup script is used instead of Debian bookworm's own
+# apt Node package, which is both older than jsdom's minimum (20.19+)
+# and inconsistently available. fonts-dejavu-core gives the sidecar's
+# SVG rasterizer something to render whiteboard text with.
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ tzdata curl ca-certificates gnupg fontconfig fonts-dejavu-core \
+ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
+ && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
+COPY render-service ./render-service
+RUN cd render-service && npm install --omit=dev
+
COPY app ./app
+COPY start.sh .
+RUN chmod +x start.sh
EXPOSE 8420
-CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8420"]
+CMD ["./start.sh"]
diff --git a/server/README.md b/server/README.md
index 33f982b..d76df26 100644
--- a/server/README.md
+++ b/server/README.md
@@ -224,6 +224,29 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
explicit, informed call by the project owner, not a default -- anyone
redistributing this project (vs. just self-hosting it) should
re-evaluate that tradeoff for their own situation before doing so.
+- Whiteboard frame mode (`app/webdav_client.py`, `app/whiteboard.py`)
+ fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard`
+ file -- which turns out to be Excalidraw scene JSON (elements/appState/
+ files), not an image -- and renders it via `render-service/`, a small
+ Node.js sidecar using Excalidraw's own real export code
+ (`@excalidraw/utils`'s `exportToSvg`) plus `@resvg/resvg-js` (a native
+ Rust SVG rasterizer, no headless browser) to turn that into a PNG. That
+ sidecar runs as a **second process inside this same container**
+ (`Dockerfile` installs Node, `start.sh` launches it in the background
+ before `exec`-ing uvicorn), reachable only at `127.0.0.1:3001` from the
+ Python process -- not a second docker-compose service, since it's
+ lightweight, stateless, and has nothing worth independently scaling or
+ restarting. License check (after getting burned once already in this
+ same file, on the CalDAV dependency below, into checking transitive
+ deps and not just top-level ones): Excalidraw, `@excalidraw/utils`,
+ every one of its own runtime dependencies, `@resvg/resvg-js`
+ (MPL-2.0 -- weak/file-level copyleft, doesn't extend to code that just
+ calls into it), `jsdom`, and `express` are all MIT/Apache-2.0/Zlib/
+ MPL-2.0 -- no repeat of the AGPL surprise. **Not runtime-tested against
+ a real `npm install`/`docker build`** -- this project's dev environment
+ has no Node.js/npm, only network access to the npm registry API (used
+ to verify the above and pick real, current dependency versions). See
+ `render-service/README.md` for exactly what is and isn't verified.
- Calendar event titles can contain emoji, which `ImageFont.load_default()`
(used for every other bit of text this project renders) has no glyphs
for -- PIL/FreeType substitute a visible ".notdef" tofu box rather than
diff --git a/server/app/migration.py b/server/app/migration.py
index ca4e75a..827b465 100644
--- a/server/app/migration.py
+++ b/server/app/migration.py
@@ -199,6 +199,25 @@ def _migration_13(conn) -> None:
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start_offset INTEGER NOT NULL DEFAULT 0"))
+def _migration_14(conn) -> None:
+ """Whiteboard frame mode: generic WebDAV credentials per user
+ (webdav_username/password, plus webdav_reuse_caldav_creds as a
+ convenience when it's the same Nextcloud account as an already-
+ configured CalDAV one -- see models.py's User docstring), and the
+ frame-level whiteboard source (whiteboard_user_id/url) + rendered-
+ PNG cache (see webdav_client.py, whiteboard.py,
+ routers/device.py's RENDERERS["whiteboard"]). Every new column has a
+ behavior-preserving default -- no existing frame's render changes
+ until its mode is actually switched to "whiteboard"."""
+ conn.execute(text("ALTER TABLE users ADD COLUMN webdav_username TEXT NOT NULL DEFAULT ''"))
+ conn.execute(text("ALTER TABLE users ADD COLUMN webdav_password TEXT NOT NULL DEFAULT ''"))
+ conn.execute(text("ALTER TABLE users ADD COLUMN webdav_reuse_caldav_creds INTEGER NOT NULL DEFAULT 0"))
+ conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL"))
+ conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_url TEXT NOT NULL DEFAULT ''"))
+ conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_checked_at REAL NOT NULL DEFAULT 0.0"))
+ conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_cached_image BLOB"))
+
+
MIGRATIONS = [
(1, _migration_1),
(2, _migration_2),
@@ -213,6 +232,7 @@ MIGRATIONS = [
(11, _migration_11),
(12, _migration_12),
(13, _migration_13),
+ (14, _migration_14),
]
diff --git a/server/app/models.py b/server/app/models.py
index dd77673..965fea0 100644
--- a/server/app/models.py
+++ b/server/app/models.py
@@ -19,7 +19,7 @@ from __future__ import annotations
import time
-from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, String
+from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, LargeBinary, String
from sqlalchemy.ext.mutable import MutableList
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -68,6 +68,18 @@ class User(Base):
# add, without hitting the CalDAV server on every page load.
calendar_caldav_calendars: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
calendar_caldav_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
+ # WebDAV credentials for whiteboard frame mode (see webdav_client.py,
+ # whiteboard.py) -- generic WebDAV, not Nextcloud-specific, but
+ # webdav_reuse_caldav_creds is a convenience for the common case
+ # where it IS the same Nextcloud account as calendar_caldav_*: skip
+ # re-entering the same username/password, since Nextcloud's CalDAV
+ # and general-file-WebDAV both sit under the one account. Doesn't
+ # try to be clever and derive the reuse automatically -- an explicit
+ # opt-in, same as everywhere else in this project defaults features
+ # off rather than silently inferring them.
+ webdav_username: Mapped[str] = mapped_column(String, default="")
+ webdav_password: Mapped[str] = mapped_column(String, default="")
+ webdav_reuse_caldav_creds: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[float] = mapped_column(Float, default=time.time)
__table_args__ = (
@@ -102,8 +114,9 @@ class Frame(Base):
# the migrated legacy frame until its device first reports an id.
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
name: Mapped[str] = mapped_column(String, default="")
- # Renderer dispatch seam for future calendar/canva modes -- only
- # "photos" is registered today (see routers/device.py RENDERERS).
+ # Renderer dispatch seam -- "photos", "calendar", or "whiteboard"
+ # (see routers/device.py RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS
+ # and routers/common.py FRAME_MODES).
mode: Mapped[str] = mapped_column(String, default="photos")
# Whose Immich library this frame pulls from; NULL = unclaimed.
owner_user_id: Mapped[int | None] = mapped_column(
@@ -238,6 +251,27 @@ class Frame(Base):
# by due date -- see caldav_client.fetch_tasks.
calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
+ # -- whiteboard mode (see webdav_client.py, whiteboard.py,
+ # routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide
+ # setting like calendar mode's own frame_calendars source, not
+ # personal data, but still owner-gated the same way: only
+ # whiteboard_user_id may point the frame at their own account (see
+ # routers/api_frames.py's api_whiteboard_source), since it's their
+ # credentials being used to fetch it. --
+ whiteboard_user_id: Mapped[int | None] = mapped_column(
+ ForeignKey("users.id", ondelete="SET NULL"), nullable=True
+ )
+ # The specific .whiteboard file's WebDAV URL -- pasted directly, same
+ # idiom as calendar_ics_url, not discovered/browsed (unlike CalDAV's
+ # account-has-several-calendars case, a WebDAV account doesn't need
+ # a picker step here since the user already knows which one file).
+ whiteboard_url: Mapped[str] = mapped_column(String, default="")
+ whiteboard_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
+ # Cached rendered PNG bytes (see whiteboard.fetch_and_render) --
+ # BLOB rather than the JSON columns the rest of this cache-pattern
+ # family uses, since this is binary image data, not JSON-shaped.
+ whiteboard_cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
+
# -- state --
current_asset_id: Mapped[str] = mapped_column(String, default="")
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py
index f5ab3d2..785e023 100644
--- a/server/app/routers/api_frames.py
+++ b/server/app/routers/api_frames.py
@@ -46,6 +46,7 @@ from .common import (
get_or_refresh_calendar_events,
get_or_refresh_tasks,
get_or_refresh_weather,
+ get_or_refresh_whiteboard,
immich_client_for,
immich_creds,
list_assets,
@@ -616,6 +617,64 @@ def api_tasks_source(
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)
+ with frame_locked(db, frame.id) as cfg:
+ if body.url is None:
+ cfg.whiteboard_user_id = None
+ cfg.whiteboard_url = ""
+ cfg.whiteboard_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.whiteboard_user_id = user.id
+ cfg.whiteboard_url = stripped
+ cfg.whiteboard_checked_at = 0.0 # pick up the change promptly
+ return {"status": "saved", "url": body.url}
+
+
+@router.get("/api/frames/{frame_id}/preview/whiteboard")
+def api_preview_whiteboard(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."""
+ png_bytes = get_or_refresh_whiteboard(db, frame)
+ if png_bytes is None:
+ if not frame.whiteboard_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
diff --git a/server/app/routers/common.py b/server/app/routers/common.py
index 41fb69a..9a0578a 100644
--- a/server/app/routers/common.py
+++ b/server/app/routers/common.py
@@ -16,7 +16,7 @@ from PIL import Image
from sqlalchemy import select
from sqlalchemy.orm import Session
-from .. import caldav_client, calendar_feed, quiet_hours, weather
+from .. import caldav_client, calendar_feed, quiet_hours, weather, whiteboard
from ..db import frame_locked
from ..image_pipeline import render_frame
from ..immich_client import ImmichClient
@@ -24,7 +24,7 @@ from ..models import BatteryLog, Frame, FrameCalendar, User
logger = logging.getLogger(__name__)
-FRAME_MODES = ("photos", "calendar")
+FRAME_MODES = ("photos", "calendar", "whiteboard")
# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
@@ -542,3 +542,50 @@ def get_or_refresh_tasks(db: Session, frame: Frame) -> list[dict]:
locked.calendar_tasks_cached = tasks
locked.calendar_tasks_checked_at = now
return tasks
+
+
+def webdav_creds_for(user: User) -> tuple[str, str] | None:
+ """(username, password) for `user`'s WebDAV access -- their own
+ dedicated webdav_username/password, or (if they opted in)
+ calendar_caldav_username/password reused from their CalDAV account
+ (see models.py's User docstring on webdav_reuse_caldav_creds). None
+ if neither is actually set up."""
+ if user.webdav_reuse_caldav_creds:
+ if user.calendar_caldav_username:
+ return user.calendar_caldav_username, user.calendar_caldav_password
+ return None
+ if user.webdav_username:
+ return user.webdav_username, user.webdav_password
+ return None
+
+
+def get_or_refresh_whiteboard(db: Session, frame: Frame) -> bytes | None:
+ """Frame-level throttled render cache (calendar_feed.CHECK_INTERVAL_S)
+ -- None if no whiteboard source is configured, credentials are
+ missing (e.g. the owning user unlinked their WebDAV/CalDAV account),
+ or the most recent fetch/render failed and nothing was ever cached
+ yet. A failure after a previous success keeps showing the last
+ good render rather than going blank for one bad refresh cycle, same
+ reasoning as get_or_refresh_weather/get_or_refresh_tasks."""
+ if not frame.whiteboard_url or not frame.whiteboard_user_id:
+ return None
+ now = time.time()
+ if (frame.whiteboard_cached_image is not None
+ and now - frame.whiteboard_checked_at < calendar_feed.CHECK_INTERVAL_S):
+ return frame.whiteboard_cached_image
+
+ user = db.get(User, frame.whiteboard_user_id)
+ creds = webdav_creds_for(user) if user else None
+ if creds is None:
+ return frame.whiteboard_cached_image
+
+ try:
+ png = whiteboard.fetch_and_render(frame.whiteboard_url, creds[0], creds[1])
+ except whiteboard.WhiteboardRenderError as e:
+ logger.warning("Could not refresh whiteboard for frame %d: %s", frame.id, e)
+ return frame.whiteboard_cached_image
+
+ with frame_locked(db, frame.id) as locked:
+ locked.whiteboard_cached_image = png
+ locked.whiteboard_checked_at = now
+ return png
diff --git a/server/app/routers/device.py b/server/app/routers/device.py
index e27a3af..86bb971 100644
--- a/server/app/routers/device.py
+++ b/server/app/routers/device.py
@@ -12,12 +12,14 @@ see manage_overlay.py and common.build_manage_content)."""
from __future__ import annotations
+import io
import logging
import time
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, RedirectResponse, Response
+from PIL import Image
from pydantic import BaseModel
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
@@ -26,7 +28,7 @@ from .. import calendar_render, mail, photo_queue, quiet_hours
from ..auth import get_server_settings, require_device
from ..db import frame_locked, get_db
from ..firmware import firmware_path
-from ..image_pipeline import render_placeholder
+from ..image_pipeline import render_frame, render_placeholder
from ..models import BatteryLog, Frame
from .common import (
BATTERY_HISTORY_MAX,
@@ -37,6 +39,7 @@ from .common import (
get_or_refresh_calendar_events,
get_or_refresh_tasks,
get_or_refresh_weather,
+ get_or_refresh_whiteboard,
immich_client_for,
immich_creds,
list_assets,
@@ -204,17 +207,64 @@ def _back_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
+# --- whiteboard mode ---
+
+def _render_whiteboard_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
+ is_normal_wake: bool) -> bytes:
+ """Fetches (throttled, see get_or_refresh_whiteboard) and renders the
+ frame's configured .whiteboard file. The rendered PNG is treated
+ exactly like a photo from here on -- run through the same
+ render_frame composition/quantization pipeline as photos mode,
+ letterboxed (never cropped: unlike a photo, losing part of a
+ whiteboard to a crop loses actual content, not just some background)
+ -- rather than a second parallel image pipeline just for this mode."""
+ png_bytes = get_or_refresh_whiteboard(db, frame)
+ if png_bytes is None:
+ return render_placeholder(
+ ["This frame's whiteboard isn't set up yet",
+ "Add a WebDAV/Nextcloud whiteboard file URL on",
+ "this frame's Whiteboard tab."],
+ orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage,
+ )
+
+ source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
+ return render_frame(
+ source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
+ display_mode="letterbox", manage=manage,
+ )
+
+
+def _advance_whiteboard_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
+ """NEXT in whiteboard mode: there's no "next" concept for a single
+ static board, so this instead forces an immediate re-fetch/re-render
+ bypassing the throttle -- a "check now" button for "someone just
+ updated the board, show it right away" rather than waiting out
+ calendar_feed.CHECK_INTERVAL_S."""
+ with frame_locked(db, frame.id) as locked:
+ locked.whiteboard_checked_at = 0.0
+ return _render_whiteboard_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
+
+
+def _back_whiteboard_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
+ """Same "check now" behavior as _advance_whiteboard_mode -- there's
+ no separate "back" concept for a single static board either."""
+ return _advance_whiteboard_mode(db, frame, manage)
+
+
RENDERERS = {
"photos": _render_photos_mode,
"calendar": _render_calendar_mode,
+ "whiteboard": _render_whiteboard_mode,
}
ADVANCE_RENDERERS = {
"photos": _advance_photos_mode,
"calendar": _advance_calendar_mode,
+ "whiteboard": _advance_whiteboard_mode,
}
BACK_RENDERERS = {
"photos": _back_photos_mode,
"calendar": _back_calendar_mode,
+ "whiteboard": _back_whiteboard_mode,
}
diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py
index 476a471..be46090 100644
--- a/server/app/routers/frame_pages.py
+++ b/server/app/routers/frame_pages.py
@@ -152,6 +152,34 @@ def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(g
)
+def _whiteboard_source_info(db: Session, frame: Frame) -> dict | None:
+ """Whose account this frame's whiteboard currently fetches with, for
+ showing "using 's account" to everyone linked, not just
+ whoever set it. None if no source is configured."""
+ if not frame.whiteboard_user_id or not frame.whiteboard_url:
+ return None
+ user = db.get(User, frame.whiteboard_user_id)
+ if user is None:
+ return None
+ return {"user_id": user.id, "display_name": user.display_name or user.username, "url": frame.whiteboard_url}
+
+
+@router.get("/frames/{frame_id}/whiteboard", response_class=HTMLResponse)
+def frame_whiteboard_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
+ viewer = current_user(request, db)
+ frame = db.get(Frame, frame_id)
+ viewer_has_webdav_creds = False
+ if viewer is not None and frame is not None and can_view_frame(db, viewer, frame):
+ viewer_has_webdav_creds = bool(
+ viewer.webdav_username or (viewer.webdav_reuse_caldav_creds and viewer.calendar_caldav_username)
+ )
+ return _frame_page(
+ request, db, frame_id, "frame_whiteboard.html", "whiteboard",
+ whiteboard_source=_whiteboard_source_info(db, frame) if frame is not None else None,
+ viewer_has_webdav_creds=viewer_has_webdav_creds,
+ )
+
+
@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse)
def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
return _frame_page(request, db, frame_id, "frame_stats.html", "stats")
\ No newline at end of file
diff --git a/server/app/routers/pages.py b/server/app/routers/pages.py
index af84c5b..7264287 100644
--- a/server/app/routers/pages.py
+++ b/server/app/routers/pages.py
@@ -428,6 +428,9 @@ def settings_submit(
calendar_caldav_url: str = Form(""),
calendar_caldav_username: str = Form(""),
calendar_caldav_password: str = Form(""),
+ webdav_username: str = Form(""),
+ webdav_password: str = Form(""),
+ webdav_reuse_caldav_creds: bool = Form(False),
current_password: str = Form(""),
new_password: str = Form(""),
db: Session = Depends(get_db),
@@ -473,6 +476,11 @@ def settings_submit(
if calendar_caldav_password.strip():
user.calendar_caldav_password = calendar_caldav_password.strip()
+ user.webdav_reuse_caldav_creds = webdav_reuse_caldav_creds
+ user.webdav_username = webdav_username.strip()
+ if webdav_password.strip():
+ user.webdav_password = webdav_password.strip()
+
if new_password:
if not user.password_hash or not verify_password(current_password, user.password_hash):
error = "Current password is wrong -- password not changed."
diff --git a/server/app/static/frame_header.js b/server/app/static/frame_header.js
index 2cd2c97..c5b00e3 100644
--- a/server/app/static/frame_header.js
+++ b/server/app/static/frame_header.js
@@ -71,9 +71,12 @@
});
if (!resp.ok) throw new Error(await apiError(resp));
previous = mode;
- showStatus(true, mode === 'calendar' ? 'Switched to Calendar mode.' : 'Switched to Photos mode.');
+ var modeLabels = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard' };
+ showStatus(true, `Switched to ${modeLabels[mode] || mode} mode.`);
var calTab = document.querySelector('.tabs a[href$="/calendar"]');
if (calTab) calTab.classList.toggle('tab-disabled', mode !== 'calendar');
+ var wbTab = document.querySelector('.tabs a[href$="/whiteboard"]');
+ if (wbTab) wbTab.classList.toggle('tab-disabled', mode !== 'whiteboard');
} catch (e) {
sel.value = previous;
showStatus(false, e.message);
diff --git a/server/app/static/frame_whiteboard.js b/server/app/static/frame_whiteboard.js
new file mode 100644
index 0000000..0e3d946
--- /dev/null
+++ b/server/app/static/frame_whiteboard.js
@@ -0,0 +1,79 @@
+// 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.
+
+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. Reload to see the updated source.');
+ loadWhiteboardPreview();
+ } catch (e) {
+ showStatus(false, e.message);
+ }
+ });
+}
+
+const whiteboardClearBtn = document.getElementById('whiteboard-source-clear');
+if (whiteboardClearBtn) {
+ whiteboardClearBtn.addEventListener('click', async () => {
+ 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. Reload to see the change.');
+ loadWhiteboardPreview();
+ } catch (e) {
+ showStatus(false, e.message);
+ }
+ });
+}
+
+function loadWhiteboardPreview() {
+ document.getElementById('whiteboard-preview').src = `${window.FRAME_API}/preview/whiteboard?_=${Date.now()}`;
+}
+document.getElementById('whiteboard-preview-refresh').addEventListener('click', loadWhiteboardPreview);
+loadWhiteboardPreview();
+
+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();
diff --git a/server/app/static/settings.js b/server/app/static/settings.js
index f245a8f..b21e98c 100644
--- a/server/app/static/settings.js
+++ b/server/app/static/settings.js
@@ -3,6 +3,19 @@
// the frame's already-saved Immich creds) -- so this only works after
// the CalDAV URL/username/password have been saved once.
+// Hides the dedicated WebDAV username/password fields while "reuse my
+// CalDAV creds" is checked -- they'd be ignored server-side anyway (see
+// routers/common.py's webdav_creds_for), no reason to leave them visibly
+// editable and implying they still do something.
+const reuseCaldavCreds = document.getElementById('webdav_reuse_caldav_creds');
+if (reuseCaldavCreds) {
+ const updateWebdavFieldVisibility = () => {
+ document.getElementById('webdav-creds-fields').style.display = reuseCaldavCreds.checked ? 'none' : '';
+ };
+ reuseCaldavCreds.addEventListener('change', updateWebdavFieldVisibility);
+ updateWebdavFieldVisibility();
+}
+
const discoverBtn = document.getElementById('caldav-discover');
if (discoverBtn) {
discoverBtn.addEventListener('click', async () => {
diff --git a/server/app/templates/_frame_mode_select.html b/server/app/templates/_frame_mode_select.html
index f955961..a0a0577 100644
--- a/server/app/templates/_frame_mode_select.html
+++ b/server/app/templates/_frame_mode_select.html
@@ -1,4 +1,5 @@
diff --git a/server/app/templates/_frame_tabs.html b/server/app/templates/_frame_tabs.html
index 33406a2..684339f 100644
--- a/server/app/templates/_frame_tabs.html
+++ b/server/app/templates/_frame_tabs.html
@@ -3,5 +3,7 @@
ConfigurationCalendar
+ WhiteboardStats
diff --git a/server/app/templates/frame_whiteboard.html b/server/app/templates/frame_whiteboard.html
new file mode 100644
index 0000000..7f5cc38
--- /dev/null
+++ b/server/app/templates/frame_whiteboard.html
@@ -0,0 +1,80 @@
+{% extends "app_base.html" %}
+
+{% block title %}{{ frame.name or "Frame" }} ยท Whiteboard{% endblock %}
+{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
+{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
+
+{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
+{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ {% if frame.mode != 'whiteboard' %}
+
This frame is currently in {{ frame.mode|capitalize }} mode --
+ settings below take effect once you switch it to Whiteboard mode
+ using the selector at the top of the page.
+ {% endif %}
+
+
+
+
+
Whiteboard source
+
Renders a Nextcloud Whiteboard (or any Excalidraw
+ scene) fetched over WebDAV -- credentials set up in
+ Settings.
+
+ {% if whiteboard_source %}
+
+ Currently showing {{ whiteboard_source.url }}
+ using {{ whiteboard_source.display_name }}'s
+ WebDAV account.
+
+
Set up WebDAV credentials
+ in Settings first to point this frame at
+ one of your own files.
+ {% endif %}
+
+
+
+
+
+
Preview
+
How this frame's whiteboard currently renders.
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+
+
+
+{% endblock %}
diff --git a/server/app/templates/settings.html b/server/app/templates/settings.html
index 6ccfd77..27de27b 100644
--- a/server/app/templates/settings.html
+++ b/server/app/templates/settings.html
@@ -71,6 +71,32 @@
you're linked to from that frame's Calendar tab, so a frame only
shows calendars people have actually chosen to share with it.
+
Whiteboard (WebDAV)
+
Credentials for whiteboard frame mode -- fetching a
+ specific file (e.g. a Nextcloud Whiteboard board) over WebDAV.
+ Any WebDAV server works, not just Nextcloud.
+
+
+
+
+
+
+
+
+
Doesn't show up anywhere by
+ itself -- point a specific frame's Whiteboard tab at a file URL
+ using this account.