From 404a35e1b1e87bf7eaef8939f119785416c6a2de Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Sun, 9 Aug 2026 21:06:31 -0400 Subject: [PATCH] Sync whiteboard images and add board ownership --- apps/kiosk-client/src/lib/kiosk.svelte.ts | 15 ++- apps/kiosk-client/src/routes/+page.svelte | 6 +- apps/web-client/src/lib/api.ts | 26 +++++ apps/web-client/src/lib/room.svelte.ts | 92 ++++++++++++++- apps/web-client/src/routes/room/+page.svelte | 9 +- packages/shared-types/src/control.ts | 1 + packages/shared-types/src/room.ts | 1 + packages/shared-types/src/whiteboard.ts | 24 ++++ packages/ui/src/WhiteboardCanvas.svelte | 111 ++++++++++++++++++- server/src/auth.rs | 35 ++++++ server/src/main.rs | 13 ++- server/src/routes/mod.rs | 2 + server/src/routes/whiteboard.rs | 34 ++++++ 13 files changed, 355 insertions(+), 14 deletions(-) create mode 100644 server/src/routes/whiteboard.rs diff --git a/apps/kiosk-client/src/lib/kiosk.svelte.ts b/apps/kiosk-client/src/lib/kiosk.svelte.ts index 56c58f4..03fbff8 100644 --- a/apps/kiosk-client/src/lib/kiosk.svelte.ts +++ b/apps/kiosk-client/src/lib/kiosk.svelte.ts @@ -2,12 +2,14 @@ import type { DataEnvelope, KioskLayout, RoomMode, - WhiteboardElement + WhiteboardElement, + WhiteboardFile } from "@pistation/shared-types"; import { isNightModeActive, isTopic, mergeWhiteboardElements, + mergeWhiteboardFiles, PIN_GRACE_SECONDS } from "@pistation/shared-types"; import type { ConnectionStatus } from "@pistation/client-core"; @@ -52,6 +54,7 @@ export class KioskController { mode = $state("idle"); annotations = $state(createAnnotationState()); whiteboardElements = $state([]); + whiteboardFiles = $state([]); participants = $state([]); isScreenActive = $state(false); screenWidth = $state(0); @@ -61,6 +64,13 @@ export class KioskController { isNight = $state(false); connectionError = $state(null); + get resolvedWhiteboardFiles(): WhiteboardFile[] { + return this.whiteboardFiles.map((file) => ({ + ...file, + url: file.url.startsWith("http") ? file.url : `${this.mediaBaseUrl}${file.url}` + })); + } + private config: KioskConfig | null = null; private connection: NativeRoom | null = null; private timers: ReturnType[] = []; @@ -281,12 +291,15 @@ export class KioskController { const event = envelope.payload; if (event.type === "whiteboard.patch") { this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, event.elements); + this.whiteboardFiles = mergeWhiteboardFiles(this.whiteboardFiles, event.files ?? []); } if (event.type === "whiteboard.snapshot") { this.whiteboardElements = event.elements; + this.whiteboardFiles = mergeWhiteboardFiles(this.whiteboardFiles, event.files ?? []); } if (event.type === "whiteboard.clear") { this.whiteboardElements = []; + this.whiteboardFiles = []; } } } diff --git a/apps/kiosk-client/src/routes/+page.svelte b/apps/kiosk-client/src/routes/+page.svelte index e595156..26cfa82 100644 --- a/apps/kiosk-client/src/routes/+page.svelte +++ b/apps/kiosk-client/src/routes/+page.svelte @@ -61,7 +61,11 @@

{:else if kiosk.mode === "whiteboard"} - + {:else if isPresenting} {:else if kiosk.layout && kiosk.isNight} diff --git a/apps/web-client/src/lib/api.ts b/apps/web-client/src/lib/api.ts index f726e50..e3cd780 100644 --- a/apps/web-client/src/lib/api.ts +++ b/apps/web-client/src/lib/api.ts @@ -73,6 +73,32 @@ export function refreshSession(sessionId: string): Promise { + let response: Response; + + try { + response = await fetch(`${apiBaseUrl}/api/whiteboard/image`, { + method: "PUT", + headers: { "content-type": blob.type, authorization: `Bearer ${sessionId}` }, + body: blob + }); + } catch { + throw new ApiError(0, "network", "Could not reach the PiStation server."); + } + + const payload = await response.json().catch(() => null); + + if (!response.ok) { + const message = (payload as { message?: string })?.message ?? "Upload failed."; + throw new ApiError(response.status, "upload", message); + } + + return payload as { imageUrl: string }; +} + export function adminLogin(email: string, password: string): Promise { return request("/api/admin/login", { method: "POST", diff --git a/apps/web-client/src/lib/room.svelte.ts b/apps/web-client/src/lib/room.svelte.ts index 2f14116..5791f5f 100644 --- a/apps/web-client/src/lib/room.svelte.ts +++ b/apps/web-client/src/lib/room.svelte.ts @@ -7,9 +7,15 @@ import type { StrokeStyle, TopicPayloadMap, WhiteboardElement, - WhiteboardEvent + WhiteboardEvent, + WhiteboardFile +} from "@pistation/shared-types"; +import { + DEFAULT_STROKE_STYLE, + isTopic, + mergeWhiteboardElements, + mergeWhiteboardFiles } from "@pistation/shared-types"; -import { DEFAULT_STROKE_STYLE, isTopic, mergeWhiteboardElements } from "@pistation/shared-types"; import type { AnnotationState, ConnectionStatus } from "@pistation/client-core"; import { applyAnnotationEvent, @@ -30,7 +36,7 @@ import { Track } from "livekit-client"; -import { refreshSession } from "./api"; +import { apiBaseUrl, refreshSession, uploadWhiteboardImage } from "./api"; import type { StoredSession } from "./session"; export type AnnotationTool = "pen" | "highlighter" | "arrow" | "rectangle" | "ellipse" | "laser"; @@ -54,6 +60,8 @@ export class RoomController { mode = $state("idle"); annotations = $state(createAnnotationState()); whiteboardElements = $state([]); + whiteboardFiles = $state([]); + whiteboardOwnerId = $state(null); screenTrack = $state(null); localScreenTrack = $state(null); localCameraTrack = $state(null); @@ -229,6 +237,17 @@ export class RoomController { this.participants = [self, ...others]; this.sharingParticipantName = this.broadcaster?.displayName ?? null; + + // Whoever opened the whiteboard has gone, so it would otherwise be stuck open for + // everyone still in the room. + if ( + this.whiteboardOwnerId !== null && + !this.participants.some( + (participant) => participant.participantId === this.whiteboardOwnerId + ) + ) { + this.whiteboardOwnerId = null; + } } /// The room has a single broadcast slot. Whoever holds it, with either a screen or a @@ -343,8 +362,27 @@ export class RoomController { setMode(mode: RoomMode): void { if (!this.canPresent) return; + if (this.mode === "whiteboard" && mode !== "whiteboard" && !this.canStopWhiteboard) return; + + const ownerId = mode === "whiteboard" ? this.session.participantId : null; + this.whiteboardOwnerId = ownerId; this.mode = mode; - this.send("control", { type: "mode.set", mode }); + this.send("control", { type: "mode.set", mode, ownerId }); + } + + /// A whiteboard belongs to whoever opened it, so nobody else can close it out from under + /// them. If that person has left the room the board is unowned and anyone may close it. + get canStopWhiteboard(): boolean { + if (this.whiteboardOwnerId === null) return true; + return this.whiteboardOwnerId === this.session.participantId; + } + + get whiteboardOwnerName(): string | null { + if (this.whiteboardOwnerId === null) return null; + const owner = this.participants.find( + (participant) => participant.participantId === this.whiteboardOwnerId + ); + return owner?.displayName ?? null; } beginStroke(point: NormalizedPoint): void { @@ -429,6 +467,39 @@ export class RoomController { this.send("whiteboard", { type: "whiteboard.patch", elements }); } + /// Image bytes are far too large for a data channel packet, so the binary goes to the + /// server over HTTP and only the URL it returns travels to the other participants. + async pushWhiteboardImage(file: { + id: string; + dataUrl: string; + mimeType: string; + }): Promise { + try { + const blob = await (await fetch(file.dataUrl)).blob(); + const { imageUrl } = await uploadWhiteboardImage(this.session.sessionId, blob); + + const shared: WhiteboardFile = { id: file.id, url: imageUrl, mimeType: file.mimeType }; + this.whiteboardFiles = mergeWhiteboardFiles(this.whiteboardFiles, [shared]); + this.send("whiteboard", { type: "whiteboard.patch", elements: [], files: [shared] }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + this.errorMessage = `That image could not be shared: ${detail}`; + } + } + + clearWhiteboard(): void { + this.whiteboardElements = []; + this.whiteboardFiles = []; + this.send("whiteboard", { type: "whiteboard.clear" }); + } + + get resolvedWhiteboardFiles(): WhiteboardFile[] { + return this.whiteboardFiles.map((file) => ({ + ...file, + url: file.url.startsWith("http") ? file.url : `${apiBaseUrl}${file.url}` + })); + } + private eraseAt(point: NormalizedPoint): void { const strokeIds = eraseAtPoint(this.annotations, point, 0.02); if (strokeIds.length === 0) return; @@ -485,7 +556,7 @@ export class RoomController { } if (isTopic(envelope, "control")) { - this.handleControlEvent(envelope.payload); + this.handleControlEvent(envelope.payload, envelope.senderId); return; } @@ -494,14 +565,18 @@ export class RoomController { } } - private handleControlEvent(event: ControlEvent): void { + private handleControlEvent(event: ControlEvent, senderId: string): void { if (event.type === "mode.set") { this.mode = event.mode; + this.whiteboardOwnerId = + event.mode === "whiteboard" ? (event.ownerId ?? senderId) : null; return; } if (event.type === "room.state") { this.mode = event.state.mode; + this.whiteboardOwnerId = + event.state.mode === "whiteboard" ? (event.state.whiteboardOwnerId ?? null) : null; return; } @@ -512,6 +587,7 @@ export class RoomController { roomName: this.session.roomName, mode: this.mode, presenterId: this.session.participantId, + whiteboardOwnerId: this.whiteboardOwnerId, annotationsLocked: false, updatedAt: Date.now() } @@ -522,16 +598,19 @@ export class RoomController { private handleWhiteboardEvent(event: WhiteboardEvent): void { if (event.type === "whiteboard.patch") { this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, event.elements); + this.whiteboardFiles = mergeWhiteboardFiles(this.whiteboardFiles, event.files ?? []); return; } if (event.type === "whiteboard.snapshot") { this.whiteboardElements = event.elements; + this.whiteboardFiles = mergeWhiteboardFiles(this.whiteboardFiles, event.files ?? []); return; } if (event.type === "whiteboard.clear") { this.whiteboardElements = []; + this.whiteboardFiles = []; return; } @@ -539,6 +618,7 @@ export class RoomController { this.send("whiteboard", { type: "whiteboard.snapshot", elements: this.whiteboardElements, + files: this.whiteboardFiles, backgroundColor: "#0b0d10" }); } diff --git a/apps/web-client/src/routes/room/+page.svelte b/apps/web-client/src/routes/room/+page.svelte index 0629b33..eb2aff1 100644 --- a/apps/web-client/src/routes/room/+page.svelte +++ b/apps/web-client/src/routes/room/+page.svelte @@ -167,7 +167,11 @@