Sync whiteboard images and add board ownership
CI / server (push) Successful in 33s
CI / frontend (push) Successful in 28s
CI / kiosk (push) Failing after 7m17s
Build and Publish Docker Images / build-and-push (apps/web-client/Dockerfile, pistation-web) (push) Successful in 2m17s
Build and Publish Docker Images / build-and-push (server/Dockerfile, pistation-server) (push) Successful in 2m32s
CI / server (push) Successful in 33s
CI / frontend (push) Successful in 28s
CI / kiosk (push) Failing after 7m17s
Build and Publish Docker Images / build-and-push (apps/web-client/Dockerfile, pistation-web) (push) Successful in 2m17s
Build and Publish Docker Images / build-and-push (server/Dockerfile, pistation-server) (push) Successful in 2m32s
This commit is contained in:
@@ -2,12 +2,14 @@ import type {
|
|||||||
DataEnvelope,
|
DataEnvelope,
|
||||||
KioskLayout,
|
KioskLayout,
|
||||||
RoomMode,
|
RoomMode,
|
||||||
WhiteboardElement
|
WhiteboardElement,
|
||||||
|
WhiteboardFile
|
||||||
} from "@pistation/shared-types";
|
} from "@pistation/shared-types";
|
||||||
import {
|
import {
|
||||||
isNightModeActive,
|
isNightModeActive,
|
||||||
isTopic,
|
isTopic,
|
||||||
mergeWhiteboardElements,
|
mergeWhiteboardElements,
|
||||||
|
mergeWhiteboardFiles,
|
||||||
PIN_GRACE_SECONDS
|
PIN_GRACE_SECONDS
|
||||||
} from "@pistation/shared-types";
|
} from "@pistation/shared-types";
|
||||||
import type { ConnectionStatus } from "@pistation/client-core";
|
import type { ConnectionStatus } from "@pistation/client-core";
|
||||||
@@ -52,6 +54,7 @@ export class KioskController {
|
|||||||
mode = $state<RoomMode>("idle");
|
mode = $state<RoomMode>("idle");
|
||||||
annotations = $state<AnnotationState>(createAnnotationState());
|
annotations = $state<AnnotationState>(createAnnotationState());
|
||||||
whiteboardElements = $state<WhiteboardElement[]>([]);
|
whiteboardElements = $state<WhiteboardElement[]>([]);
|
||||||
|
whiteboardFiles = $state<WhiteboardFile[]>([]);
|
||||||
participants = $state<RoomParticipant[]>([]);
|
participants = $state<RoomParticipant[]>([]);
|
||||||
isScreenActive = $state(false);
|
isScreenActive = $state(false);
|
||||||
screenWidth = $state(0);
|
screenWidth = $state(0);
|
||||||
@@ -61,6 +64,13 @@ export class KioskController {
|
|||||||
isNight = $state(false);
|
isNight = $state(false);
|
||||||
connectionError = $state<string | null>(null);
|
connectionError = $state<string | null>(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 config: KioskConfig | null = null;
|
||||||
private connection: NativeRoom | null = null;
|
private connection: NativeRoom | null = null;
|
||||||
private timers: ReturnType<typeof setInterval>[] = [];
|
private timers: ReturnType<typeof setInterval>[] = [];
|
||||||
@@ -281,12 +291,15 @@ export class KioskController {
|
|||||||
const event = envelope.payload;
|
const event = envelope.payload;
|
||||||
if (event.type === "whiteboard.patch") {
|
if (event.type === "whiteboard.patch") {
|
||||||
this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, event.elements);
|
this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, event.elements);
|
||||||
|
this.whiteboardFiles = mergeWhiteboardFiles(this.whiteboardFiles, event.files ?? []);
|
||||||
}
|
}
|
||||||
if (event.type === "whiteboard.snapshot") {
|
if (event.type === "whiteboard.snapshot") {
|
||||||
this.whiteboardElements = event.elements;
|
this.whiteboardElements = event.elements;
|
||||||
|
this.whiteboardFiles = mergeWhiteboardFiles(this.whiteboardFiles, event.files ?? []);
|
||||||
}
|
}
|
||||||
if (event.type === "whiteboard.clear") {
|
if (event.type === "whiteboard.clear") {
|
||||||
this.whiteboardElements = [];
|
this.whiteboardElements = [];
|
||||||
|
this.whiteboardFiles = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if kiosk.mode === "whiteboard"}
|
{:else if kiosk.mode === "whiteboard"}
|
||||||
<WhiteboardCanvas elements={kiosk.whiteboardElements} readOnly={true} />
|
<WhiteboardCanvas
|
||||||
|
elements={kiosk.whiteboardElements}
|
||||||
|
files={kiosk.resolvedWhiteboardFiles}
|
||||||
|
readOnly={true}
|
||||||
|
/>
|
||||||
{:else if isPresenting}
|
{:else if isPresenting}
|
||||||
<AnnotationOverlay annotations={kiosk.annotations} labels={participantLabels} />
|
<AnnotationOverlay annotations={kiosk.annotations} labels={participantLabels} />
|
||||||
{:else if kiosk.layout && kiosk.isNight}
|
{:else if kiosk.layout && kiosk.isNight}
|
||||||
|
|||||||
@@ -73,6 +73,32 @@ export function refreshSession(sessionId: string): Promise<SessionRefreshRespons
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function uploadWhiteboardImage(
|
||||||
|
sessionId: string,
|
||||||
|
blob: Blob
|
||||||
|
): Promise<{ imageUrl: string }> {
|
||||||
|
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<AdminLoginResponse> {
|
export function adminLogin(email: string, password: string): Promise<AdminLoginResponse> {
|
||||||
return request<AdminLoginResponse>("/api/admin/login", {
|
return request<AdminLoginResponse>("/api/admin/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ import type {
|
|||||||
StrokeStyle,
|
StrokeStyle,
|
||||||
TopicPayloadMap,
|
TopicPayloadMap,
|
||||||
WhiteboardElement,
|
WhiteboardElement,
|
||||||
WhiteboardEvent
|
WhiteboardEvent,
|
||||||
|
WhiteboardFile
|
||||||
|
} from "@pistation/shared-types";
|
||||||
|
import {
|
||||||
|
DEFAULT_STROKE_STYLE,
|
||||||
|
isTopic,
|
||||||
|
mergeWhiteboardElements,
|
||||||
|
mergeWhiteboardFiles
|
||||||
} from "@pistation/shared-types";
|
} from "@pistation/shared-types";
|
||||||
import { DEFAULT_STROKE_STYLE, isTopic, mergeWhiteboardElements } from "@pistation/shared-types";
|
|
||||||
import type { AnnotationState, ConnectionStatus } from "@pistation/client-core";
|
import type { AnnotationState, ConnectionStatus } from "@pistation/client-core";
|
||||||
import {
|
import {
|
||||||
applyAnnotationEvent,
|
applyAnnotationEvent,
|
||||||
@@ -30,7 +36,7 @@ import {
|
|||||||
Track
|
Track
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
|
|
||||||
import { refreshSession } from "./api";
|
import { apiBaseUrl, refreshSession, uploadWhiteboardImage } from "./api";
|
||||||
import type { StoredSession } from "./session";
|
import type { StoredSession } from "./session";
|
||||||
|
|
||||||
export type AnnotationTool = "pen" | "highlighter" | "arrow" | "rectangle" | "ellipse" | "laser";
|
export type AnnotationTool = "pen" | "highlighter" | "arrow" | "rectangle" | "ellipse" | "laser";
|
||||||
@@ -54,6 +60,8 @@ export class RoomController {
|
|||||||
mode = $state<RoomMode>("idle");
|
mode = $state<RoomMode>("idle");
|
||||||
annotations = $state<AnnotationState>(createAnnotationState());
|
annotations = $state<AnnotationState>(createAnnotationState());
|
||||||
whiteboardElements = $state<WhiteboardElement[]>([]);
|
whiteboardElements = $state<WhiteboardElement[]>([]);
|
||||||
|
whiteboardFiles = $state<WhiteboardFile[]>([]);
|
||||||
|
whiteboardOwnerId = $state<string | null>(null);
|
||||||
screenTrack = $state<RemoteTrack | null>(null);
|
screenTrack = $state<RemoteTrack | null>(null);
|
||||||
localScreenTrack = $state<LocalVideoTrack | null>(null);
|
localScreenTrack = $state<LocalVideoTrack | null>(null);
|
||||||
localCameraTrack = $state<LocalVideoTrack | null>(null);
|
localCameraTrack = $state<LocalVideoTrack | null>(null);
|
||||||
@@ -229,6 +237,17 @@ export class RoomController {
|
|||||||
|
|
||||||
this.participants = [self, ...others];
|
this.participants = [self, ...others];
|
||||||
this.sharingParticipantName = this.broadcaster?.displayName ?? null;
|
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
|
/// 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 {
|
setMode(mode: RoomMode): void {
|
||||||
if (!this.canPresent) return;
|
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.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 {
|
beginStroke(point: NormalizedPoint): void {
|
||||||
@@ -429,6 +467,39 @@ export class RoomController {
|
|||||||
this.send("whiteboard", { type: "whiteboard.patch", elements });
|
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<void> {
|
||||||
|
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 {
|
private eraseAt(point: NormalizedPoint): void {
|
||||||
const strokeIds = eraseAtPoint(this.annotations, point, 0.02);
|
const strokeIds = eraseAtPoint(this.annotations, point, 0.02);
|
||||||
if (strokeIds.length === 0) return;
|
if (strokeIds.length === 0) return;
|
||||||
@@ -485,7 +556,7 @@ export class RoomController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isTopic(envelope, "control")) {
|
if (isTopic(envelope, "control")) {
|
||||||
this.handleControlEvent(envelope.payload);
|
this.handleControlEvent(envelope.payload, envelope.senderId);
|
||||||
return;
|
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") {
|
if (event.type === "mode.set") {
|
||||||
this.mode = event.mode;
|
this.mode = event.mode;
|
||||||
|
this.whiteboardOwnerId =
|
||||||
|
event.mode === "whiteboard" ? (event.ownerId ?? senderId) : null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "room.state") {
|
if (event.type === "room.state") {
|
||||||
this.mode = event.state.mode;
|
this.mode = event.state.mode;
|
||||||
|
this.whiteboardOwnerId =
|
||||||
|
event.state.mode === "whiteboard" ? (event.state.whiteboardOwnerId ?? null) : null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,6 +587,7 @@ export class RoomController {
|
|||||||
roomName: this.session.roomName,
|
roomName: this.session.roomName,
|
||||||
mode: this.mode,
|
mode: this.mode,
|
||||||
presenterId: this.session.participantId,
|
presenterId: this.session.participantId,
|
||||||
|
whiteboardOwnerId: this.whiteboardOwnerId,
|
||||||
annotationsLocked: false,
|
annotationsLocked: false,
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
}
|
}
|
||||||
@@ -522,16 +598,19 @@ export class RoomController {
|
|||||||
private handleWhiteboardEvent(event: WhiteboardEvent): void {
|
private handleWhiteboardEvent(event: WhiteboardEvent): void {
|
||||||
if (event.type === "whiteboard.patch") {
|
if (event.type === "whiteboard.patch") {
|
||||||
this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, event.elements);
|
this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, event.elements);
|
||||||
|
this.whiteboardFiles = mergeWhiteboardFiles(this.whiteboardFiles, event.files ?? []);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "whiteboard.snapshot") {
|
if (event.type === "whiteboard.snapshot") {
|
||||||
this.whiteboardElements = event.elements;
|
this.whiteboardElements = event.elements;
|
||||||
|
this.whiteboardFiles = mergeWhiteboardFiles(this.whiteboardFiles, event.files ?? []);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "whiteboard.clear") {
|
if (event.type === "whiteboard.clear") {
|
||||||
this.whiteboardElements = [];
|
this.whiteboardElements = [];
|
||||||
|
this.whiteboardFiles = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,6 +618,7 @@ export class RoomController {
|
|||||||
this.send("whiteboard", {
|
this.send("whiteboard", {
|
||||||
type: "whiteboard.snapshot",
|
type: "whiteboard.snapshot",
|
||||||
elements: this.whiteboardElements,
|
elements: this.whiteboardElements,
|
||||||
|
files: this.whiteboardFiles,
|
||||||
backgroundColor: "#0b0d10"
|
backgroundColor: "#0b0d10"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,11 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={toggleWhiteboard}
|
onclick={toggleWhiteboard}
|
||||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors"
|
disabled={current.mode === "whiteboard" && !current.canStopWhiteboard}
|
||||||
|
title={current.mode === "whiteboard" && !current.canStopWhiteboard
|
||||||
|
? `${current.whiteboardOwnerName ?? "Someone else"} opened this whiteboard and can close it`
|
||||||
|
: undefined}
|
||||||
|
class="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
class:bg-accent={current.mode === "whiteboard"}
|
class:bg-accent={current.mode === "whiteboard"}
|
||||||
class:text-white={current.mode === "whiteboard"}
|
class:text-white={current.mode === "whiteboard"}
|
||||||
class:bg-surface-2={current.mode !== "whiteboard"}
|
class:bg-surface-2={current.mode !== "whiteboard"}
|
||||||
@@ -214,7 +218,10 @@
|
|||||||
{#if current.mode === "whiteboard"}
|
{#if current.mode === "whiteboard"}
|
||||||
<WhiteboardCanvas
|
<WhiteboardCanvas
|
||||||
elements={current.whiteboardElements}
|
elements={current.whiteboardElements}
|
||||||
|
files={current.resolvedWhiteboardFiles}
|
||||||
onLocalChange={(changed) => current.pushWhiteboardElements(changed)}
|
onLocalChange={(changed) => current.pushWhiteboardElements(changed)}
|
||||||
|
onLocalFile={(file) => void current.pushWhiteboardImage(file)}
|
||||||
|
onLocalClear={() => current.clearWhiteboard()}
|
||||||
/>
|
/>
|
||||||
{:else if current.localScreenTrack}
|
{:else if current.localScreenTrack}
|
||||||
<TrackVideo track={current.localScreenTrack} />
|
<TrackVideo track={current.localScreenTrack} />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RoomMode, RoomState } from "./room.js";
|
|||||||
export interface ModeSetEvent {
|
export interface ModeSetEvent {
|
||||||
type: "mode.set";
|
type: "mode.set";
|
||||||
mode: RoomMode;
|
mode: RoomMode;
|
||||||
|
ownerId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PresenterSetEvent {
|
export interface PresenterSetEvent {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface RoomState {
|
|||||||
roomName: string;
|
roomName: string;
|
||||||
mode: RoomMode;
|
mode: RoomMode;
|
||||||
presenterId: string | null;
|
presenterId: string | null;
|
||||||
|
whiteboardOwnerId?: string | null;
|
||||||
annotationsLocked: boolean;
|
annotationsLocked: boolean;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,22 @@ export interface WhiteboardElement {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WhiteboardFile {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WhiteboardPatchEvent {
|
export interface WhiteboardPatchEvent {
|
||||||
type: "whiteboard.patch";
|
type: "whiteboard.patch";
|
||||||
elements: WhiteboardElement[];
|
elements: WhiteboardElement[];
|
||||||
|
files?: WhiteboardFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WhiteboardSnapshotEvent {
|
export interface WhiteboardSnapshotEvent {
|
||||||
type: "whiteboard.snapshot";
|
type: "whiteboard.snapshot";
|
||||||
elements: WhiteboardElement[];
|
elements: WhiteboardElement[];
|
||||||
|
files?: WhiteboardFile[];
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +56,22 @@ export function mergeWhiteboardElements(
|
|||||||
return [...byId.values()];
|
return [...byId.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mergeWhiteboardFiles(
|
||||||
|
current: WhiteboardFile[],
|
||||||
|
incoming: WhiteboardFile[]
|
||||||
|
): WhiteboardFile[] {
|
||||||
|
const byId = new Map<string, WhiteboardFile>();
|
||||||
|
for (const file of current) {
|
||||||
|
byId.set(file.id, file);
|
||||||
|
}
|
||||||
|
for (const file of incoming) {
|
||||||
|
if (!byId.has(file.id)) {
|
||||||
|
byId.set(file.id, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
}
|
||||||
|
|
||||||
function isNewerElement(candidate: WhiteboardElement, existing: WhiteboardElement): boolean {
|
function isNewerElement(candidate: WhiteboardElement, existing: WhiteboardElement): boolean {
|
||||||
if (candidate.version !== existing.version) {
|
if (candidate.version !== existing.version) {
|
||||||
return candidate.version > existing.version;
|
return candidate.version > existing.version;
|
||||||
|
|||||||
@@ -1,19 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { WhiteboardElement } from "@pistation/shared-types";
|
import type { WhiteboardElement, WhiteboardFile } from "@pistation/shared-types";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
elements,
|
elements,
|
||||||
|
files = [],
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
onLocalChange
|
onLocalChange,
|
||||||
|
onLocalFile,
|
||||||
|
onLocalClear
|
||||||
}: {
|
}: {
|
||||||
elements: WhiteboardElement[];
|
elements: WhiteboardElement[];
|
||||||
|
files?: WhiteboardFile[];
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
onLocalChange?: (changed: WhiteboardElement[]) => void;
|
onLocalChange?: (changed: WhiteboardElement[]) => void;
|
||||||
|
onLocalFile?: (file: { id: string; dataUrl: string; mimeType: string }) => void;
|
||||||
|
onLocalClear?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
interface BinaryFile {
|
||||||
|
id: string;
|
||||||
|
dataURL: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ExcalidrawApi {
|
interface ExcalidrawApi {
|
||||||
updateScene: (scene: { elements: readonly WhiteboardElement[] }) => void;
|
updateScene: (scene: { elements: readonly WhiteboardElement[] }) => void;
|
||||||
|
addFiles: (files: BinaryFile[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let container = $state<HTMLDivElement | null>(null);
|
let container = $state<HTMLDivElement | null>(null);
|
||||||
@@ -21,6 +34,7 @@
|
|||||||
let isDrawing = $state(false);
|
let isDrawing = $state(false);
|
||||||
|
|
||||||
const knownVersions = new Map<string, number>();
|
const knownVersions = new Map<string, number>();
|
||||||
|
const knownFileIds = new Set<string>();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
let unmount: (() => void) | null = null;
|
let unmount: (() => void) | null = null;
|
||||||
@@ -37,15 +51,24 @@
|
|||||||
const root = clientModule.createRoot(container);
|
const root = clientModule.createRoot(container);
|
||||||
root.render(
|
root.render(
|
||||||
reactModule.createElement(excalidrawModule.Excalidraw, {
|
reactModule.createElement(excalidrawModule.Excalidraw, {
|
||||||
theme: "dark",
|
// Excalidraw's dark theme inverts the whole canvas in CSS and then cancels that
|
||||||
|
// out per image by assigning ctx.filter. When the image lands in its cache after
|
||||||
|
// the element is first drawn, the cancellation is missed and photos come out
|
||||||
|
// inverted. A light board has no filter to cancel and cannot drift out of step.
|
||||||
|
theme: "light",
|
||||||
viewModeEnabled: readOnly,
|
viewModeEnabled: readOnly,
|
||||||
// Taken as unknown and narrowed here, so this file does not have to depend on
|
// Taken as unknown and narrowed here, so this file does not have to depend on
|
||||||
// Excalidraw's exported API type just to hold a reference to it.
|
// Excalidraw's exported API type just to hold a reference to it.
|
||||||
excalidrawAPI: (api: unknown) => {
|
excalidrawAPI: (api: unknown) => {
|
||||||
excalidrawApi = api as ExcalidrawApi;
|
excalidrawApi = api as ExcalidrawApi;
|
||||||
},
|
},
|
||||||
onChange: (sceneElements: readonly WhiteboardElement[]) => {
|
onChange: (
|
||||||
|
sceneElements: readonly WhiteboardElement[],
|
||||||
|
_appState: unknown,
|
||||||
|
sceneFiles: Record<string, BinaryFile>
|
||||||
|
) => {
|
||||||
handleSceneChange(sceneElements);
|
handleSceneChange(sceneElements);
|
||||||
|
handleSceneFiles(sceneFiles);
|
||||||
},
|
},
|
||||||
UIOptions: {
|
UIOptions: {
|
||||||
canvasActions: {
|
canvasActions: {
|
||||||
@@ -64,6 +87,19 @@
|
|||||||
return () => unmount?.();
|
return () => unmount?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!excalidrawApi) return;
|
||||||
|
|
||||||
|
const arrived = files.filter((file) => !knownFileIds.has(file.id));
|
||||||
|
if (arrived.length === 0) return;
|
||||||
|
|
||||||
|
for (const file of arrived) {
|
||||||
|
knownFileIds.add(file.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyRemoteFiles(arrived);
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!excalidrawApi) return;
|
if (!excalidrawApi) return;
|
||||||
|
|
||||||
@@ -72,6 +108,16 @@
|
|||||||
// down; this effect runs again on release because isDrawing is reactive.
|
// down; this effect runs again on release because isDrawing is reactive.
|
||||||
if (isDrawing) return;
|
if (isDrawing) return;
|
||||||
|
|
||||||
|
// An emptied board has no versions to compare against, so it would never look like a
|
||||||
|
// change and the old scene would stay on screen.
|
||||||
|
if (elements.length === 0) {
|
||||||
|
if (knownVersions.size === 0) return;
|
||||||
|
knownVersions.clear();
|
||||||
|
knownFileIds.clear();
|
||||||
|
excalidrawApi.updateScene({ elements: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Only push the scene back when something genuinely arrived from someone else.
|
// Only push the scene back when something genuinely arrived from someone else.
|
||||||
// Feeding our own edits back in has the same truncating effect.
|
// Feeding our own edits back in has the same truncating effect.
|
||||||
const hasRemoteChange = elements.some((element) => {
|
const hasRemoteChange = elements.some((element) => {
|
||||||
@@ -88,12 +134,69 @@
|
|||||||
excalidrawApi.updateScene({ elements });
|
excalidrawApi.updateScene({ elements });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Excalidraw only cancels out its dark theme canvas filter for images it has fully
|
||||||
|
/// resolved in its own cache, so remote images are fetched and handed over as data URLs
|
||||||
|
/// rather than as a link it has to load itself.
|
||||||
|
async function applyRemoteFiles(arrived: WhiteboardFile[]): Promise<void> {
|
||||||
|
const loaded = await Promise.all(arrived.map((file) => toBinaryFile(file)));
|
||||||
|
const usable = loaded.filter((file): file is BinaryFile => file !== null);
|
||||||
|
if (usable.length > 0) excalidrawApi?.addFiles(usable);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toBinaryFile(file: WhiteboardFile): Promise<BinaryFile | null> {
|
||||||
|
if (file.url.startsWith("data:")) {
|
||||||
|
return { id: file.id, dataURL: file.url, mimeType: file.mimeType };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(file.url);
|
||||||
|
if (!response.ok) throw new Error(`image request failed: ${response.status}`);
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result));
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { id: file.id, dataURL: dataUrl, mimeType: blob.type || file.mimeType };
|
||||||
|
} catch {
|
||||||
|
knownFileIds.delete(file.id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSceneFiles(sceneFiles: Record<string, BinaryFile>): void {
|
||||||
|
if (readOnly || !onLocalFile) return;
|
||||||
|
|
||||||
|
for (const file of Object.values(sceneFiles ?? {})) {
|
||||||
|
if (!file || knownFileIds.has(file.id)) continue;
|
||||||
|
if (!file.dataURL.startsWith("data:")) continue;
|
||||||
|
|
||||||
|
knownFileIds.add(file.id);
|
||||||
|
onLocalFile({ id: file.id, dataUrl: file.dataURL, mimeType: file.mimeType });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pendingChanges = new Map<string, WhiteboardElement>();
|
const pendingChanges = new Map<string, WhiteboardElement>();
|
||||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
function handleSceneChange(sceneElements: readonly WhiteboardElement[]) {
|
function handleSceneChange(sceneElements: readonly WhiteboardElement[]) {
|
||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
|
|
||||||
|
// Deleting one element leaves it in the scene marked isDeleted, which syncs like any
|
||||||
|
// other edit. Resetting the canvas empties the array instead, so there is nothing left
|
||||||
|
// to diff against and the other end has to be told outright.
|
||||||
|
if (sceneElements.length === 0) {
|
||||||
|
if (knownVersions.size === 0) return;
|
||||||
|
knownVersions.clear();
|
||||||
|
knownFileIds.clear();
|
||||||
|
pendingChanges.clear();
|
||||||
|
onLocalClear?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const element of sceneElements) {
|
for (const element of sceneElements) {
|
||||||
const known = knownVersions.get(element.id);
|
const known = knownVersions.get(element.id);
|
||||||
if (known === undefined || element.version > known) {
|
if (known === undefined || element.version > known) {
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ pub struct KioskIdentity {
|
|||||||
pub room_name: String,
|
pub room_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct SessionIdentity {
|
||||||
|
pub session_id: String,
|
||||||
|
pub kiosk_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl FromRequestParts<AppState> for AdminIdentity {
|
impl FromRequestParts<AppState> for AdminIdentity {
|
||||||
type Rejection = AppError;
|
type Rejection = AppError;
|
||||||
|
|
||||||
@@ -126,6 +131,36 @@ impl FromRequestParts<AppState> for KioskIdentity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FromRequestParts<AppState> for SessionIdentity {
|
||||||
|
type Rejection = AppError;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let session_id = bearer_token(parts)?;
|
||||||
|
|
||||||
|
let row: Option<(String, i64)> = sqlx::query_as(
|
||||||
|
"SELECT kiosk_id, expires_at FROM sessions WHERE id = ? AND revoked = 0",
|
||||||
|
)
|
||||||
|
.bind(&session_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (kiosk_id, expires_at) =
|
||||||
|
row.ok_or_else(|| AppError::Unauthorized("unknown session".into()))?;
|
||||||
|
|
||||||
|
if expires_at <= crate::clock::now_ms() {
|
||||||
|
return Err(AppError::Unauthorized("session expired".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(SessionIdentity {
|
||||||
|
session_id,
|
||||||
|
kiosk_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn bearer_token(parts: &Parts) -> AppResult<String> {
|
fn bearer_token(parts: &Parts) -> AppResult<String> {
|
||||||
let header = parts
|
let header = parts
|
||||||
.headers
|
.headers
|
||||||
|
|||||||
+12
-1
@@ -69,12 +69,23 @@ async fn main() {
|
|||||||
.allow_headers(Any)
|
.allow_headers(Any)
|
||||||
.allow_origin(Any);
|
.allow_origin(Any);
|
||||||
|
|
||||||
|
// Uploaded images are public and are read with fetch by kiosks and browsers alike, which
|
||||||
|
// is blocked without these headers even though the same file loads fine in an img tag.
|
||||||
|
let media_cors = CorsLayer::new()
|
||||||
|
.allow_methods([Method::GET, Method::HEAD])
|
||||||
|
.allow_origin(Any);
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", axum::routing::get(routes::service_index))
|
.route("/", axum::routing::get(routes::service_index))
|
||||||
.route("/install.sh", axum::routing::get(routes::install_script))
|
.route("/install.sh", axum::routing::get(routes::install_script))
|
||||||
.nest("/api/kiosk", routes::kiosk_router().layer(kiosk_cors))
|
.nest("/api/kiosk", routes::kiosk_router().layer(kiosk_cors))
|
||||||
.nest("/api", routes::api_router().layer(cors))
|
.nest("/api", routes::api_router().layer(cors))
|
||||||
.nest_service("/media", ServeDir::new(state.config.media_dir.clone()))
|
.nest_service(
|
||||||
|
"/media",
|
||||||
|
Router::new()
|
||||||
|
.fallback_service(ServeDir::new(state.config.media_dir.clone()))
|
||||||
|
.layer(media_cors),
|
||||||
|
)
|
||||||
.nest_service(
|
.nest_service(
|
||||||
"/downloads",
|
"/downloads",
|
||||||
ServeDir::new(state.config.package_dir.clone()),
|
ServeDir::new(state.config.package_dir.clone()),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod join;
|
|||||||
pub mod kiosk;
|
pub mod kiosk;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod organization;
|
pub mod organization;
|
||||||
|
pub mod whiteboard;
|
||||||
|
|
||||||
pub use install::install_script;
|
pub use install::install_script;
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ pub fn api_router() -> Router<AppState> {
|
|||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
.merge(join::router())
|
.merge(join::router())
|
||||||
.merge(organization::public_router())
|
.merge(organization::public_router())
|
||||||
|
.merge(whiteboard::router())
|
||||||
.nest(
|
.nest(
|
||||||
"/admin",
|
"/admin",
|
||||||
admin::router()
|
admin::router()
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
use axum::body::Bytes;
|
||||||
|
use axum::extract::{DefaultBodyLimit, State};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use axum::routing::put;
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::auth::SessionIdentity;
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use crate::routes::media::{store_image, MAX_UPLOAD_BYTES};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/whiteboard/image", put(upload_image))
|
||||||
|
.layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct UploadResponse {
|
||||||
|
image_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload_image(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
session: SessionIdentity,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> AppResult<Json<UploadResponse>> {
|
||||||
|
let prefix = format!("wb-{}", session.kiosk_id);
|
||||||
|
let image_url = store_image(&state, &headers, &body, &prefix).await?;
|
||||||
|
Ok(Json(UploadResponse { image_url }))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user