From 18384c0cbcf7ff4e6e5023f13a606a1e0fc70e9d Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Sun, 9 Aug 2026 17:09:16 -0400 Subject: [PATCH] Add web client Join screen, room with screen and camera sharing, live annotation, whiteboard, and the admin panel for kiosks, widgets and branding. --- apps/web-client/.env.example | 1 + apps/web-client/Dockerfile | 25 + apps/web-client/package.json | 35 ++ apps/web-client/src/app.css | 64 ++ apps/web-client/src/app.html | 12 + apps/web-client/src/hooks.server.ts | 10 + apps/web-client/src/lib/api.ts | 234 ++++++++ apps/web-client/src/lib/branding.svelte.ts | 28 + .../lib/components/AnnotationToolbar.svelte | 144 +++++ .../src/lib/components/ConnectionBadge.svelte | 27 + .../src/lib/components/JoinCard.svelte | 81 +++ .../src/lib/components/ParticipantMenu.svelte | 62 ++ .../src/lib/components/PinInput.svelte | 73 +++ .../components/admin/AppearancePanel.svelte | 197 +++++++ .../components/admin/InstallCommand.svelte | 63 ++ .../lib/components/admin/KioskStats.svelte | 200 +++++++ .../components/admin/NightModePanel.svelte | 98 ++++ .../lib/components/admin/TimeZoneField.svelte | 77 +++ .../lib/components/admin/WidgetBuilder.svelte | 322 +++++++++++ .../components/admin/WidgetGridEditor.svelte | 233 ++++++++ .../lib/components/admin/WidgetList.svelte | 101 ++++ .../admin/WidgetSettingsPanel.svelte | 409 +++++++++++++ apps/web-client/src/lib/config.ts | 3 + apps/web-client/src/lib/meta.ts | 17 + apps/web-client/src/lib/room.svelte.ts | 546 ++++++++++++++++++ apps/web-client/src/lib/session.ts | 60 ++ apps/web-client/src/routes/+layout.svelte | 43 ++ apps/web-client/src/routes/+page.svelte | 262 +++++++++ apps/web-client/src/routes/admin/+page.svelte | 435 ++++++++++++++ .../admin/kiosks/[kioskId]/+page.svelte | 391 +++++++++++++ .../routes/admin/organization/+page.svelte | 391 +++++++++++++ apps/web-client/src/routes/room/+page.svelte | 298 ++++++++++ apps/web-client/static/logo.svg | 24 + apps/web-client/static/og-image.svg | 60 ++ apps/web-client/svelte.config.js | 12 + apps/web-client/tsconfig.json | 14 + apps/web-client/vite.config.ts | 22 + 37 files changed, 5074 insertions(+) create mode 100644 apps/web-client/.env.example create mode 100644 apps/web-client/Dockerfile create mode 100644 apps/web-client/package.json create mode 100644 apps/web-client/src/app.css create mode 100644 apps/web-client/src/app.html create mode 100644 apps/web-client/src/hooks.server.ts create mode 100644 apps/web-client/src/lib/api.ts create mode 100644 apps/web-client/src/lib/branding.svelte.ts create mode 100644 apps/web-client/src/lib/components/AnnotationToolbar.svelte create mode 100644 apps/web-client/src/lib/components/ConnectionBadge.svelte create mode 100644 apps/web-client/src/lib/components/JoinCard.svelte create mode 100644 apps/web-client/src/lib/components/ParticipantMenu.svelte create mode 100644 apps/web-client/src/lib/components/PinInput.svelte create mode 100644 apps/web-client/src/lib/components/admin/AppearancePanel.svelte create mode 100644 apps/web-client/src/lib/components/admin/InstallCommand.svelte create mode 100644 apps/web-client/src/lib/components/admin/KioskStats.svelte create mode 100644 apps/web-client/src/lib/components/admin/NightModePanel.svelte create mode 100644 apps/web-client/src/lib/components/admin/TimeZoneField.svelte create mode 100644 apps/web-client/src/lib/components/admin/WidgetBuilder.svelte create mode 100644 apps/web-client/src/lib/components/admin/WidgetGridEditor.svelte create mode 100644 apps/web-client/src/lib/components/admin/WidgetList.svelte create mode 100644 apps/web-client/src/lib/components/admin/WidgetSettingsPanel.svelte create mode 100644 apps/web-client/src/lib/config.ts create mode 100644 apps/web-client/src/lib/meta.ts create mode 100644 apps/web-client/src/lib/room.svelte.ts create mode 100644 apps/web-client/src/lib/session.ts create mode 100644 apps/web-client/src/routes/+layout.svelte create mode 100644 apps/web-client/src/routes/+page.svelte create mode 100644 apps/web-client/src/routes/admin/+page.svelte create mode 100644 apps/web-client/src/routes/admin/kiosks/[kioskId]/+page.svelte create mode 100644 apps/web-client/src/routes/admin/organization/+page.svelte create mode 100644 apps/web-client/src/routes/room/+page.svelte create mode 100644 apps/web-client/static/logo.svg create mode 100644 apps/web-client/static/og-image.svg create mode 100644 apps/web-client/svelte.config.js create mode 100644 apps/web-client/tsconfig.json create mode 100644 apps/web-client/vite.config.ts diff --git a/apps/web-client/.env.example b/apps/web-client/.env.example new file mode 100644 index 0000000..f22cddd --- /dev/null +++ b/apps/web-client/.env.example @@ -0,0 +1 @@ +PUBLIC_API_URL=http://localhost:8080 diff --git a/apps/web-client/Dockerfile b/apps/web-client/Dockerfile new file mode 100644 index 0000000..e453752 --- /dev/null +++ b/apps/web-client/Dockerfile @@ -0,0 +1,25 @@ +FROM oven/bun:1 AS builder +WORKDIR /app +ENV NODE_ENV=development + +COPY package.json ./ +COPY packages packages +COPY apps/web-client apps/web-client + +RUN bun install + +WORKDIR /app/apps/web-client +RUN bun run build + +FROM oven/bun:1-slim +WORKDIR /app +ENV NODE_ENV=production + +COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/packages ./packages +COPY --from=builder /app/apps/web-client ./apps/web-client + +WORKDIR /app/apps/web-client +EXPOSE 3000 +CMD ["bun", "./build/index.js"] diff --git a/apps/web-client/package.json b/apps/web-client/package.json new file mode 100644 index 0000000..97a8587 --- /dev/null +++ b/apps/web-client/package.json @@ -0,0 +1,35 @@ +{ + "name": "@pistation/web-client", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev --port 5173", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" + }, + "dependencies": { + "@excalidraw/excalidraw": "^0.17.6", + "@pistation/client-core": "workspace:*", + "@pistation/shared-types": "workspace:*", + "@pistation/ui": "workspace:*", + "livekit-client": "^2.7.2", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@iconify/svelte": "^4.0.2", + "@sveltejs/adapter-node": "^5.2.9", + "@sveltejs/kit": "^2.9.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "~5.6.2", + "vite": "^6.0.3" + } +} diff --git a/apps/web-client/src/app.css b/apps/web-client/src/app.css new file mode 100644 index 0000000..0229ce4 --- /dev/null +++ b/apps/web-client/src/app.css @@ -0,0 +1,64 @@ +@import "tailwindcss"; + +@source "../../../packages/ui/src"; + +@theme { + --color-surface-0: #0b0d10; + --color-surface-1: #14181d; + --color-surface-2: #1c2229; + --color-surface-3: #262e37; + --color-ink-0: #f4f6f8; + --color-ink-1: #a8b3c0; + --color-ink-2: #6b7885; + --color-accent: #4f7cff; + --color-accent-strong: #3563e9; + --color-danger: #ff3b52; + --color-success: #21c17a; + --radius-none: 0px; + --font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", "SF Mono", Menlo, monospace; +} + +* { + border-radius: 0 !important; +} + +html, +body { + height: 100%; + background-color: var(--color-surface-0); + color: var(--color-ink-0); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +button { + cursor: pointer; +} + +input, +textarea, +select, +button { + outline: none; + font-family: inherit; +} + +input:focus-visible, +textarea:focus-visible, +button:focus-visible { + box-shadow: inset 0 0 0 2px var(--color-accent); +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: var(--color-surface-1); +} + +::-webkit-scrollbar-thumb { + background: var(--color-surface-3); +} diff --git a/apps/web-client/src/app.html b/apps/web-client/src/app.html new file mode 100644 index 0000000..23c36cf --- /dev/null +++ b/apps/web-client/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/web-client/src/hooks.server.ts b/apps/web-client/src/hooks.server.ts new file mode 100644 index 0000000..4aa391e --- /dev/null +++ b/apps/web-client/src/hooks.server.ts @@ -0,0 +1,10 @@ +import type { HandleServerError } from "@sveltejs/kit"; + +export const handleError: HandleServerError = ({ error, event }) => { + const detail = error instanceof Error ? (error.stack ?? error.message) : String(error); + console.error(`[error] ${event.request.method} ${event.url.pathname}\n${detail}`); + + return { + message: "Something went wrong rendering this page." + }; +}; diff --git a/apps/web-client/src/lib/api.ts b/apps/web-client/src/lib/api.ts new file mode 100644 index 0000000..f726e50 --- /dev/null +++ b/apps/web-client/src/lib/api.ts @@ -0,0 +1,234 @@ +import type { + AdminLoginResponse, + CreateKioskResponse, + JoinResponse, + Kiosk, + KioskDetailResponse, + KioskListResponse, + OrganizationBranding, + SessionRefreshResponse +} from "@pistation/shared-types"; + +import { apiBaseUrl } from "./config"; + +export { apiBaseUrl }; + +export class ApiError extends Error { + readonly status: number; + readonly code: string; + + constructor(status: number, code: string, message: string) { + super(message); + this.status = status; + this.code = code; + } +} + +interface RequestOptions { + method?: string; + body?: unknown; + token?: string | null; +} + +async function request(path: string, options: RequestOptions = {}): Promise { + const headers: Record = {}; + if (options.body !== undefined) headers["content-type"] = "application/json"; + if (options.token) headers["authorization"] = `Bearer ${options.token}`; + + let response: Response; + try { + response = await fetch(`${apiBaseUrl}${path}`, { + method: options.method ?? "GET", + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body) + }); + } catch { + throw new ApiError(0, "network", "Could not reach the PiStation server."); + } + + if (response.status === 204) return undefined as T; + + const payload = await response.json().catch(() => null); + + if (!response.ok) { + const code = (payload as { error?: string })?.error ?? "unknown"; + const message = (payload as { message?: string })?.message ?? "Something went wrong."; + throw new ApiError(response.status, code, message); + } + + return payload as T; +} + +export function joinRoom(pin: string, displayName: string): Promise { + return request("/api/join", { + method: "POST", + body: { pin, displayName } + }); +} + +export function refreshSession(sessionId: string): Promise { + return request("/api/session/refresh", { + method: "POST", + body: { sessionId } + }); +} + +export function adminLogin(email: string, password: string): Promise { + return request("/api/admin/login", { + method: "POST", + body: { email, password } + }); +} + +export function listKiosks(token: string): Promise { + return request("/api/admin/kiosks", { token }); +} + +export function createKiosk( + token: string, + name: string, + location: string +): Promise { + return request("/api/admin/kiosks", { + method: "POST", + body: { name, location }, + token + }); +} + +export function getKiosk(token: string, kioskId: string): Promise { + return request(`/api/admin/kiosks/${kioskId}`, { token }); +} + +export function getBranding(): Promise { + return request("/api/organization"); +} + +export function saveBranding( + token: string, + branding: OrganizationBranding +): Promise { + return request("/api/admin/organization", { + method: "PUT", + body: { branding }, + token + }); +} + +export async function uploadLogo(token: string, file: File): Promise<{ imageUrl: string }> { + let response: Response; + + try { + response = await fetch(`${apiBaseUrl}/api/admin/organization/logo`, { + method: "PUT", + headers: { "content-type": file.type, authorization: `Bearer ${token}` }, + body: file + }); + } 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 updateKiosk( + token: string, + kioskId: string, + name: string, + location: string +): Promise { + return request(`/api/admin/kiosks/${kioskId}`, { + method: "PATCH", + body: { name, location }, + token + }); +} + +export function deleteKiosk(token: string, kioskId: string): Promise { + return request(`/api/admin/kiosks/${kioskId}`, { method: "DELETE", token }); +} + +export function saveKioskLayout( + token: string, + kioskId: string, + layout: unknown +): Promise { + return request(`/api/admin/kiosks/${kioskId}/layout`, { + method: "PUT", + body: { layout }, + token + }); +} + +export async function uploadWallpaper( + token: string, + kioskId: string, + file: File +): Promise<{ imageUrl: string }> { + let response: Response; + + try { + response = await fetch(`${apiBaseUrl}/api/admin/kiosks/${kioskId}/wallpaper`, { + method: "PUT", + headers: { + "content-type": file.type, + authorization: `Bearer ${token}` + }, + body: file + }); + } 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 async function uploadKioskPackage( + token: string, + file: File +): Promise<{ downloadUrl: string; sizeBytes: number }> { + let response: Response; + + try { + response = await fetch(`${apiBaseUrl}/api/admin/packages/kiosk`, { + method: "PUT", + headers: { + "content-type": "application/vnd.debian.binary-package", + authorization: `Bearer ${token}` + }, + body: file + }); + } 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 { downloadUrl: string; sizeBytes: number }; +} + +export function rotateEnrollment( + token: string, + kioskId: string +): Promise<{ enrollmentToken: string }> { + return request(`/api/admin/kiosks/${kioskId}/enrollment`, { method: "POST", token }); +} diff --git a/apps/web-client/src/lib/branding.svelte.ts b/apps/web-client/src/lib/branding.svelte.ts new file mode 100644 index 0000000..ca0b179 --- /dev/null +++ b/apps/web-client/src/lib/branding.svelte.ts @@ -0,0 +1,28 @@ +import type { OrganizationBranding } from "@pistation/shared-types"; +import { withBrandingDefaults } from "@pistation/shared-types"; + +import { getBranding } from "./api"; + +/// Shared so the layout can paint the theme and pages can read the copy, without every +/// page fetching branding for itself. +export const branding = $state<{ value: OrganizationBranding }>({ + value: withBrandingDefaults(null) +}); + +let hasRequested = false; + +export async function ensureBranding(): Promise { + if (hasRequested) return; + hasRequested = true; + + try { + branding.value = withBrandingDefaults(await getBranding()); + } catch { + // Defaults are already in place, so a server that is not up yet just means the + // stock palette and copy. + } +} + +export function applyBranding(loaded: OrganizationBranding): void { + branding.value = withBrandingDefaults(loaded); +} diff --git a/apps/web-client/src/lib/components/AnnotationToolbar.svelte b/apps/web-client/src/lib/components/AnnotationToolbar.svelte new file mode 100644 index 0000000..6828be4 --- /dev/null +++ b/apps/web-client/src/lib/components/AnnotationToolbar.svelte @@ -0,0 +1,144 @@ + + +
+ {#each tools as tool} + + {/each} + + + + + +
+ + + +
+ + {#each colors as color} + + {/each} + +
+ + +
diff --git a/apps/web-client/src/lib/components/ConnectionBadge.svelte b/apps/web-client/src/lib/components/ConnectionBadge.svelte new file mode 100644 index 0000000..4bd102c --- /dev/null +++ b/apps/web-client/src/lib/components/ConnectionBadge.svelte @@ -0,0 +1,27 @@ + + +
+ + {current.label} +
diff --git a/apps/web-client/src/lib/components/JoinCard.svelte b/apps/web-client/src/lib/components/JoinCard.svelte new file mode 100644 index 0000000..ff0137c --- /dev/null +++ b/apps/web-client/src/lib/components/JoinCard.svelte @@ -0,0 +1,81 @@ + + +
+ +

{label}

+

It rotates every minute

+ + + + + + {#if errorMessage} +

+ + {errorMessage} +

+ {/if} + + +
diff --git a/apps/web-client/src/lib/components/ParticipantMenu.svelte b/apps/web-client/src/lib/components/ParticipantMenu.svelte new file mode 100644 index 0000000..cd46255 --- /dev/null +++ b/apps/web-client/src/lib/components/ParticipantMenu.svelte @@ -0,0 +1,62 @@ + + +
+ + + {#if isOpen} +
+

In this room

+ +
    + {#each participants as participant (participant.participantId)} + {@const isBroadcasting = participant.isSharing || participant.isCameraOn} +
  • + + + {participant.displayName} + + {#if participant.isSelf} + you + {/if} +
  • + {/each} +
+
+ {/if} +
diff --git a/apps/web-client/src/lib/components/PinInput.svelte b/apps/web-client/src/lib/components/PinInput.svelte new file mode 100644 index 0000000..3d69aff --- /dev/null +++ b/apps/web-client/src/lib/components/PinInput.svelte @@ -0,0 +1,73 @@ + + +
+ {#each slots as digit, index} + handleInput(index, event)} + onkeydown={(event) => handleKeydown(index, event)} + onfocus={(event) => (event.target as HTMLInputElement).select()} + /> + {/each} +
diff --git a/apps/web-client/src/lib/components/admin/AppearancePanel.svelte b/apps/web-client/src/lib/components/admin/AppearancePanel.svelte new file mode 100644 index 0000000..dee31c5 --- /dev/null +++ b/apps/web-client/src/lib/components/admin/AppearancePanel.svelte @@ -0,0 +1,197 @@ + + +
+

Appearance

+ + + + + + + +
+
+

Wallpapers

+ {#if images.length > 0} + {images.length} + {/if} +
+ + {#if images.length === 0} +

No wallpaper set

+ {:else} +
+ {#each images as image, index (image)} +
+ {`Wallpaper + +
+ {/each} +
+ {/if} + + {#if uploadError} +

{uploadError}

+ {/if} + + + + + +

PNG, JPEG, WEBP or GIF, up to 8 MB each.

+ + {#if images.length > 1} + + {/if} + + {#if images.length > 0} + + + + {/if} +
+
diff --git a/apps/web-client/src/lib/components/admin/InstallCommand.svelte b/apps/web-client/src/lib/components/admin/InstallCommand.svelte new file mode 100644 index 0000000..ae6c896 --- /dev/null +++ b/apps/web-client/src/lib/components/admin/InstallCommand.svelte @@ -0,0 +1,63 @@ + + +
+
+ +

+ Set up {kioskName || "this kiosk"} +

+
+ +

+ Run this once on the Raspberry Pi over SSH, with sudo as shown. It installs the kiosk, + enlarges swap, tunes video for the board it finds, and reboots straight into the display. + The token works only until the Pi enrolls. Needs the 64 bit Raspberry Pi OS. +

+ +
+ {command} + +
+ +

+ The Pi must be able to reach {apiBaseUrl}. Upload a kiosk build on the admin home page first, + otherwise the script has nothing to install. +

+
diff --git a/apps/web-client/src/lib/components/admin/KioskStats.svelte b/apps/web-client/src/lib/components/admin/KioskStats.svelte new file mode 100644 index 0000000..0633046 --- /dev/null +++ b/apps/web-client/src/lib/components/admin/KioskStats.svelte @@ -0,0 +1,200 @@ + + +{#if !metrics} +

No stats reported yet.

+{:else if compact} +
+ {#if metrics.cpuPercent !== null} + + + {metrics.cpuPercent.toFixed(0)}% + + {/if} + + {#if memoryPercent !== null} + + + {memoryPercent.toFixed(0)}% + + {/if} + + {#if metrics.wifi} + {@const strength = signalLabel(metrics.wifi.signalDbm)} + + + {STRENGTH_WORDS[strength]} + + {/if} + + {#if metrics.temperatureCelsius !== null} + + + {metrics.temperatureCelsius.toFixed(0)}°C + + {/if} +
+{:else} +
+ {#if isStale} +

+ + These figures are more than two minutes old. +

+ {/if} + + {#if metrics.cpuPercent !== null} +
+
+ + + CPU + + {metrics.cpuPercent.toFixed(0)}% +
+
+
+
+
+ {/if} + + {#if memoryPercent !== null} +
+
+ + + Memory + + + {formatBytes(metrics.memoryUsedBytes)} of {formatBytes(metrics.memoryTotalBytes)} + +
+
+
+
+
+ {/if} + + {#if metrics.wifi} + {@const strength = signalLabel(metrics.wifi.signalDbm)} + {@const percent = signalPercent(metrics.wifi.signalDbm)} +
+
+ + + Wi-Fi + + + {STRENGTH_WORDS[strength]} + {percent}% + +
+
+
+
+

+ {signalAdvice(metrics.wifi.signalDbm)} · {metrics.wifi.signalDbm.toFixed(0)} dBm on + {metrics.wifi.interface} +

+
+ {:else} +
+ + + Network + + Wired or no wireless adapter +
+ {/if} + + {#if metrics.temperatureCelsius !== null} +
+ + + Temperature + + = 75 ? "text-danger" : "text-ink-2"}> + {metrics.temperatureCelsius.toFixed(1)}°C + +
+ {/if} + +
+ + + Uptime + + {formatUptime(metrics.uptimeSeconds)} +
+
+{/if} diff --git a/apps/web-client/src/lib/components/admin/NightModePanel.svelte b/apps/web-client/src/lib/components/admin/NightModePanel.svelte new file mode 100644 index 0000000..cd82d69 --- /dev/null +++ b/apps/web-client/src/lib/components/admin/NightModePanel.svelte @@ -0,0 +1,98 @@ + + +
+
+

Night mode

+ {#if nightMode.enabled && isActiveNow} + Active now + {/if} +
+ +

+ Between these times the screen shows only the clock and the PIN. Widgets, wallpaper and + all other graphics are hidden. A live presentation always takes priority. +

+ + + + {#if nightMode.enabled} +
+ + + +
+ + patch({ timeZone })} + /> + + + + + {/if} +
diff --git a/apps/web-client/src/lib/components/admin/TimeZoneField.svelte b/apps/web-client/src/lib/components/admin/TimeZoneField.svelte new file mode 100644 index 0000000..8d68de2 --- /dev/null +++ b/apps/web-client/src/lib/components/admin/TimeZoneField.svelte @@ -0,0 +1,77 @@ + + + diff --git a/apps/web-client/src/lib/components/admin/WidgetBuilder.svelte b/apps/web-client/src/lib/components/admin/WidgetBuilder.svelte new file mode 100644 index 0000000..163c5a6 --- /dev/null +++ b/apps/web-client/src/lib/components/admin/WidgetBuilder.svelte @@ -0,0 +1,322 @@ + + +
+
+

Widget builder

+ +
+ +
+ {#each definitions as definition (definition.definitionId)} + + {/each} +
+ + {#if selected} +
+
+ + + +
+ +
+

Data source

+ + updateDefinition({ + dataSource: { + url: (event.target as HTMLInputElement).value, + refreshSeconds: selected.dataSource?.refreshSeconds ?? 300, + rootPath: selected.dataSource?.rootPath ?? "" + } + })} + class="w-full bg-surface-1 px-3 py-2 text-sm" + /> +
+ + +
+

+ Reference fields in any template with double braces, for example + {"{{temperature}}"}. +

+
+ +
+ {#each WIDGET_BLOCK_KINDS as kind} + + {/each} +
+ +
+ {#each selected.blocks as block, index (block.blockId)} +
+
+ {block.kind} + + + +
+ + {#if block.kind === "heading" || block.kind === "text"} + + updateBlock(block.blockId, { + template: (event.target as HTMLInputElement).value + })} + class="w-full bg-surface-1 px-3 py-2 text-sm" + /> + {:else if block.kind === "metric"} +
+ + updateBlock(block.blockId, { + labelTemplate: (event.target as HTMLInputElement).value + })} + class="bg-surface-1 px-3 py-2 text-sm" + /> + + updateBlock(block.blockId, { + valueTemplate: (event.target as HTMLInputElement).value + })} + class="bg-surface-1 px-3 py-2 text-sm" + /> + + updateBlock(block.blockId, { unit: (event.target as HTMLInputElement).value })} + class="bg-surface-1 px-3 py-2 text-sm" + /> +
+ {:else if block.kind === "list"} +
+ + updateBlock(block.blockId, { + sourcePath: (event.target as HTMLInputElement).value + })} + class="bg-surface-1 px-3 py-2 text-sm" + /> + + updateBlock(block.blockId, { + itemTemplate: (event.target as HTMLInputElement).value + })} + class="bg-surface-1 px-3 py-2 text-sm" + /> + + updateBlock(block.blockId, { + maxItems: Number((event.target as HTMLInputElement).value) + })} + class="bg-surface-1 px-3 py-2 text-sm" + /> +
+ {:else if block.kind === "image"} + + updateBlock(block.blockId, { + urlTemplate: (event.target as HTMLInputElement).value + })} + class="w-full bg-surface-1 px-3 py-2 text-sm" + /> + {/if} +
+ {/each} +
+
+ {/if} +
diff --git a/apps/web-client/src/lib/components/admin/WidgetGridEditor.svelte b/apps/web-client/src/lib/components/admin/WidgetGridEditor.svelte new file mode 100644 index 0000000..2bc5141 --- /dev/null +++ b/apps/web-client/src/lib/components/admin/WidgetGridEditor.svelte @@ -0,0 +1,233 @@ + + +
+ + +
+ {#if drag} + {#each Array(WIDGET_GRID_COLUMNS * WIDGET_GRID_ROWS) as _, index} +
+ {/each} + {/if} + + {#each visibleWidgets as widget (widget.widgetId)} + {@const placement = placementFor(widget)} +
beginDrag(event, widget, "move")} + onpointermove={updateDrag} + onpointerup={endDrag} + onpointercancel={endDrag} + onkeydown={(event) => nudge(event, widget)} + onfocus={() => onSelect(widget.widgetId)} + > + + {widget.kind} + + + +
+ {/each} +
+
diff --git a/apps/web-client/src/lib/components/admin/WidgetList.svelte b/apps/web-client/src/lib/components/admin/WidgetList.svelte new file mode 100644 index 0000000..89786a5 --- /dev/null +++ b/apps/web-client/src/lib/components/admin/WidgetList.svelte @@ -0,0 +1,101 @@ + + +
+

Widgets

+ + {#if widgets.length === 0} +

No widgets yet.

+ {/if} + +
+ {#each widgets as widget, index (widget.widgetId)} +
+ + + + + + + + + +
+ {/each} +
+
diff --git a/apps/web-client/src/lib/components/admin/WidgetSettingsPanel.svelte b/apps/web-client/src/lib/components/admin/WidgetSettingsPanel.svelte new file mode 100644 index 0000000..a13f66f --- /dev/null +++ b/apps/web-client/src/lib/components/admin/WidgetSettingsPanel.svelte @@ -0,0 +1,409 @@ + + +
+
+

{widget.kind}

+ + +
+ +
+ {#each [ + { key: "column", label: "Col", max: WIDGET_GRID_COLUMNS }, + { key: "row", label: "Row", max: WIDGET_GRID_ROWS }, + { key: "columnSpan", label: "Width", max: WIDGET_GRID_COLUMNS }, + { key: "rowSpan", label: "Height", max: WIDGET_GRID_ROWS } + ] as field} + + {/each} +
+ +
+

Style

+ + + +
+
+ Horizontal +
+ {#each ALIGN_OPTIONS as option} + + {/each} +
+
+ +
+ Vertical +
+ {#each ALIGN_OPTIONS as option} + + {/each} +
+
+
+ +
+ Panel colour +
+ {#if style.backgroundColor} + + {/if} + + patchStyle({ backgroundColor: (event.target as HTMLInputElement).value })} + class="h-9 w-14 bg-surface-1" + /> +
+
+ + +
+ + {#if widget.kind === "clock"} + patchSettings({ timeZone })} + /> +
+ + + +
+ {:else if widget.kind === "weather"} +
+ + +
+ + + {:else if widget.kind === "pin"} + + + {:else if widget.kind === "text"} + + + {:else if widget.kind === "image"} + + + {:else if widget.kind === "agenda"} + + + {:else if widget.kind === "custom"} + + {/if} +
diff --git a/apps/web-client/src/lib/config.ts b/apps/web-client/src/lib/config.ts new file mode 100644 index 0000000..91d172a --- /dev/null +++ b/apps/web-client/src/lib/config.ts @@ -0,0 +1,3 @@ +import { env } from "$env/dynamic/public"; + +export const apiBaseUrl = (env.PUBLIC_API_URL ?? "http://localhost:8080").replace(/\/$/, ""); diff --git a/apps/web-client/src/lib/meta.ts b/apps/web-client/src/lib/meta.ts new file mode 100644 index 0000000..8cc316c --- /dev/null +++ b/apps/web-client/src/lib/meta.ts @@ -0,0 +1,17 @@ +export const SITE_NAME = "PiStation"; + +export const SITE_DESCRIPTION = + "Turn any TV or monitor into a screen the whole room can use. Type the code on screen to " + + "present, annotate live, or open a shared whiteboard. Self hosted on hardware you own."; + +export const SITE_KEYWORDS = [ + "screen sharing", + "wireless presentation", + "raspberry pi kiosk", + "digital signage", + "self hosted", + "whiteboard", + "livekit" +].join(", "); + +export const THEME_COLOR = "#0b0d10"; diff --git a/apps/web-client/src/lib/room.svelte.ts b/apps/web-client/src/lib/room.svelte.ts new file mode 100644 index 0000000..2f14116 --- /dev/null +++ b/apps/web-client/src/lib/room.svelte.ts @@ -0,0 +1,546 @@ +import type { + ControlEvent, + DataEnvelope, + DataTopic, + NormalizedPoint, + RoomMode, + StrokeStyle, + TopicPayloadMap, + WhiteboardElement, + WhiteboardEvent +} from "@pistation/shared-types"; +import { DEFAULT_STROKE_STYLE, isTopic, mergeWhiteboardElements } from "@pistation/shared-types"; +import type { AnnotationState, ConnectionStatus } from "@pistation/client-core"; +import { + applyAnnotationEvent, + createAnnotationState, + eraseAtPoint, + makeStrokeId, + prunePointers, + publishEnvelope, + RoomConnection +} from "@pistation/client-core"; +import { + LocalTrackPublication, + LocalVideoTrack, + RemoteParticipant, + RemoteTrack, + RemoteTrackPublication, + RoomEvent, + Track +} from "livekit-client"; + +import { refreshSession } from "./api"; +import type { StoredSession } from "./session"; + +export type AnnotationTool = "pen" | "highlighter" | "arrow" | "rectangle" | "ellipse" | "laser"; + +export interface RoomParticipant { + participantId: string; + displayName: string; + isSelf: boolean; + isSharing: boolean; + isCameraOn: boolean; +} + +export interface CameraFeed { + participantId: string; + displayName: string; + track: RemoteTrack; +} + +export class RoomController { + status = $state("idle"); + mode = $state("idle"); + annotations = $state(createAnnotationState()); + whiteboardElements = $state([]); + screenTrack = $state(null); + localScreenTrack = $state(null); + localCameraTrack = $state(null); + cameraFeeds = $state([]); + isSharing = $state(false); + isCameraOn = $state(false); + facingMode = $state<"user" | "environment">("environment"); + participants = $state([]); + sharingParticipantName = $state(null); + errorMessage = $state(null); + + tool = $state("pen"); + strokeStyle = $state({ ...DEFAULT_STROKE_STYLE }); + isEraser = $state(false); + isPointerMode = $state(false); + + private session: StoredSession; + private connection: RoomConnection | null = null; + private activeStrokeId: string | null = null; + private pendingPoints: NormalizedPoint[] = []; + private flushTimer: ReturnType | null = null; + + constructor(session: StoredSession) { + this.session = session; + } + + get canPresent(): boolean { + return this.session.role !== "viewer"; + } + + get participantId(): string { + return this.session.participantId; + } + + get kioskName(): string { + return this.session.kioskName; + } + + get role(): string { + return this.session.role; + } + + async connect(): Promise { + this.connection = new RoomConnection( + async () => { + const refreshed = await refreshSession(this.session.sessionId); + return { + livekitUrl: this.session.livekitUrl, + accessToken: refreshed.accessToken + }; + }, + { + onStatus: (status) => { + this.status = status; + }, + onEnvelope: (envelope) => this.handleEnvelope(envelope), + onReconnected: () => this.requestRoomState() + } + ); + + this.bindTrackEvents(); + await this.connection.start(); + this.requestRoomState(); + this.startPointerPruning(); + } + + async disconnect(): Promise { + if (this.flushTimer) clearInterval(this.flushTimer); + this.flushTimer = null; + await this.connection?.stop(); + this.connection = null; + } + + private bindTrackEvents(): void { + const room = this.connection?.room; + if (!room) return; + + room + .on( + RoomEvent.TrackSubscribed, + ( + track: RemoteTrack, + publication: RemoteTrackPublication, + participant: RemoteParticipant + ) => { + if (track.kind !== Track.Kind.Video) return; + + if (publication.source === Track.Source.ScreenShare) { + this.screenTrack = track; + } + + if (publication.source === Track.Source.Camera) { + this.cameraFeeds = [ + ...this.cameraFeeds.filter((feed) => feed.track !== track), + { + participantId: participant.identity, + displayName: participant.name || participant.identity, + track + } + ]; + } + + this.refreshParticipants(); + } + ) + .on(RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => { + if (this.screenTrack === track) this.screenTrack = null; + this.cameraFeeds = this.cameraFeeds.filter((feed) => feed.track !== track); + this.refreshParticipants(); + }) + .on(RoomEvent.LocalTrackPublished, (publication: LocalTrackPublication) => { + if (publication.source === Track.Source.ScreenShare) { + this.isSharing = true; + this.localScreenTrack = (publication.track as LocalVideoTrack) ?? null; + } + + if (publication.source === Track.Source.Camera) { + this.isCameraOn = true; + this.localCameraTrack = (publication.track as LocalVideoTrack) ?? null; + } + + this.refreshParticipants(); + }) + .on(RoomEvent.LocalTrackUnpublished, (publication: LocalTrackPublication) => { + if (publication.source === Track.Source.ScreenShare) { + this.isSharing = false; + this.localScreenTrack = null; + } + + if (publication.source === Track.Source.Camera) { + this.isCameraOn = false; + this.localCameraTrack = null; + } + + this.refreshParticipants(); + }) + .on(RoomEvent.ParticipantConnected, () => this.refreshParticipants()) + .on(RoomEvent.ParticipantDisconnected, () => this.refreshParticipants()) + .on(RoomEvent.Connected, () => this.refreshParticipants()); + } + + private refreshParticipants(): void { + const room = this.connection?.room; + if (!room) { + this.participants = []; + this.sharingParticipantName = null; + return; + } + + const publishes = ( + participant: { trackPublications: Map }, + source: Track.Source + ) => + [...participant.trackPublications.values()].some( + (publication) => publication.source === source + ); + + const self: RoomParticipant = { + participantId: this.session.participantId, + displayName: this.session.displayName, + isSelf: true, + isSharing: this.isSharing, + isCameraOn: this.isCameraOn + }; + + const others = [...room.remoteParticipants.values()].map((participant) => ({ + participantId: participant.identity, + displayName: participant.name || participant.identity, + isSelf: false, + isSharing: publishes(participant, Track.Source.ScreenShare), + isCameraOn: publishes(participant, Track.Source.Camera) + })); + + this.participants = [self, ...others]; + this.sharingParticipantName = this.broadcaster?.displayName ?? null; + } + + /// The room has a single broadcast slot. Whoever holds it, with either a screen or a + /// camera, holds it until they stop. + get broadcaster(): RoomParticipant | null { + return ( + this.participants.find((participant) => participant.isSharing || participant.isCameraOn) ?? + null + ); + } + + get isSomeoneElseSharing(): boolean { + const holder = this.broadcaster; + return Boolean(holder && !holder.isSelf); + } + + /// One source at a time, per room and per person, so a phone camera cannot quietly ride + /// alongside a screen share. + get canStartScreenShare(): boolean { + return !this.isSomeoneElseSharing && !this.isCameraOn; + } + + get canStartCamera(): boolean { + return !this.isSomeoneElseSharing && !this.isSharing; + } + + get blockedReason(): string | null { + const holder = this.broadcaster; + if (holder && !holder.isSelf) { + const what = holder.isSharing ? "sharing a screen" : "sharing a camera"; + return `${holder.displayName} is ${what}`; + } + return null; + } + + get participantCount(): number { + return this.participants.length; + } + + async startScreenShare(): Promise { + const room = this.connection?.room; + if (!room) return; + + if (!this.canStartScreenShare) { + this.errorMessage = + this.blockedReason ?? "Turn your camera off before sharing your screen."; + return; + } + + try { + this.errorMessage = null; + await room.localParticipant.setScreenShareEnabled(true, { + audio: false, + contentHint: "detail" + }); + this.setMode("presentation"); + } catch { + this.errorMessage = "Screen sharing was blocked or cancelled."; + } + } + + /// Publishes the device camera. Phones default to the rear lens, which is what people + /// point at a whiteboard or a room, and can be flipped without dropping the track. + async startCamera(): Promise { + const room = this.connection?.room; + if (!room) return; + + if (!this.canStartCamera) { + this.errorMessage = + this.blockedReason ?? "Stop sharing your screen before turning the camera on."; + return; + } + + try { + this.errorMessage = null; + await room.localParticipant.setCameraEnabled(true, { + facingMode: this.facingMode, + resolution: { width: 1280, height: 720 } + }); + } catch { + this.errorMessage = "Could not use the camera. Check the site has permission."; + this.isCameraOn = false; + } + } + + async stopCamera(): Promise { + const room = this.connection?.room; + if (!room) return; + await room.localParticipant.setCameraEnabled(false); + } + + async flipCamera(): Promise { + this.facingMode = this.facingMode === "environment" ? "user" : "environment"; + if (!this.isCameraOn) return; + + const track = this.localCameraTrack; + if (!track) return; + + try { + await track.restartTrack({ facingMode: this.facingMode }); + } catch { + this.errorMessage = "This device only has one camera."; + } + } + + async stopScreenShare(): Promise { + const room = this.connection?.room; + if (!room) return; + await room.localParticipant.setScreenShareEnabled(false); + this.setMode("idle"); + } + + setMode(mode: RoomMode): void { + if (!this.canPresent) return; + this.mode = mode; + this.send("control", { type: "mode.set", mode }); + } + + beginStroke(point: NormalizedPoint): void { + if (this.isPointerMode) { + this.movePointer(point); + return; + } + + if (this.isEraser) { + this.eraseAt(point); + return; + } + + const strokeId = makeStrokeId(); + this.activeStrokeId = strokeId; + this.pendingPoints = []; + + const event = { + type: "stroke.start" as const, + strokeId, + tool: this.tool, + style: { ...this.strokeStyle }, + point + }; + + this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId); + this.send("annotation", event); + } + + extendStroke(point: NormalizedPoint): void { + if (this.isPointerMode) { + this.movePointer(point); + return; + } + + if (this.isEraser) { + this.eraseAt(point); + return; + } + if (!this.activeStrokeId) return; + + const event = { + type: "stroke.append" as const, + strokeId: this.activeStrokeId, + points: [point] + }; + + this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId); + this.pendingPoints.push(point); + this.flushPendingPoints(); + } + + endStroke(): void { + if (this.isPointerMode) return; + if (!this.activeStrokeId) return; + const strokeId = this.activeStrokeId; + this.activeStrokeId = null; + this.flushPendingPoints(true); + + const event = { type: "stroke.end" as const, strokeId }; + this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId); + this.send("annotation", event); + } + + movePointer(point: NormalizedPoint | null): void { + this.send( + "annotation", + { type: "pointer.move", point, color: this.strokeStyle.color }, + false + ); + } + + clearAnnotations(): void { + const event = { type: "canvas.clear" as const }; + this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId); + this.send("annotation", event); + } + + pushWhiteboardElements(elements: WhiteboardElement[]): void { + if (elements.length === 0) return; + this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, elements); + this.send("whiteboard", { type: "whiteboard.patch", elements }); + } + + private eraseAt(point: NormalizedPoint): void { + const strokeIds = eraseAtPoint(this.annotations, point, 0.02); + if (strokeIds.length === 0) return; + + const event = { type: "stroke.erase" as const, strokeIds }; + this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId); + this.send("annotation", event); + } + + private flushPendingPoints(force = false): void { + if (this.pendingPoints.length === 0) return; + if (!force && this.pendingPoints.length < 3) return; + if (!this.activeStrokeId && !force) return; + + const strokeId = this.activeStrokeId; + const points = this.pendingPoints; + this.pendingPoints = []; + if (!strokeId) return; + + this.send("annotation", { type: "stroke.append", strokeId, points }, false); + } + + private requestRoomState(): void { + this.send("control", { type: "room.state.request" }); + this.send("whiteboard", { type: "whiteboard.request" }); + } + + private startPointerPruning(): void { + this.flushTimer = setInterval(() => { + this.annotations = prunePointers(this.annotations, Date.now()); + }, 1000); + } + + private send( + topic: T, + payload: TopicPayloadMap[T], + reliable = true + ): void { + const room = this.connection?.room; + if (!room) return; + publishEnvelope(room, topic, this.session.participantId, payload, reliable); + } + + private handleEnvelope(envelope: DataEnvelope): void { + if (envelope.senderId === this.session.participantId) return; + + if (isTopic(envelope, "annotation")) { + this.annotations = applyAnnotationEvent( + this.annotations, + envelope.payload, + envelope.senderId + ); + return; + } + + if (isTopic(envelope, "control")) { + this.handleControlEvent(envelope.payload); + return; + } + + if (isTopic(envelope, "whiteboard")) { + this.handleWhiteboardEvent(envelope.payload); + } + } + + private handleControlEvent(event: ControlEvent): void { + if (event.type === "mode.set") { + this.mode = event.mode; + return; + } + + if (event.type === "room.state") { + this.mode = event.state.mode; + return; + } + + if (event.type === "room.state.request" && this.isSharing) { + this.send("control", { + type: "room.state", + state: { + roomName: this.session.roomName, + mode: this.mode, + presenterId: this.session.participantId, + annotationsLocked: false, + updatedAt: Date.now() + } + }); + } + } + + private handleWhiteboardEvent(event: WhiteboardEvent): void { + if (event.type === "whiteboard.patch") { + this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, event.elements); + return; + } + + if (event.type === "whiteboard.snapshot") { + this.whiteboardElements = event.elements; + return; + } + + if (event.type === "whiteboard.clear") { + this.whiteboardElements = []; + return; + } + + if (event.type === "whiteboard.request" && this.whiteboardElements.length > 0) { + this.send("whiteboard", { + type: "whiteboard.snapshot", + elements: this.whiteboardElements, + backgroundColor: "#0b0d10" + }); + } + } +} diff --git a/apps/web-client/src/lib/session.ts b/apps/web-client/src/lib/session.ts new file mode 100644 index 0000000..43729aa --- /dev/null +++ b/apps/web-client/src/lib/session.ts @@ -0,0 +1,60 @@ +import type { JoinResponse } from "@pistation/shared-types"; +import { browser } from "$app/environment"; + +const SESSION_KEY = "pistation.session"; +const ADMIN_KEY = "pistation.admin"; + +export type StoredSession = JoinResponse; + +export function saveSession(session: StoredSession): void { + if (!browser) return; + sessionStorage.setItem(SESSION_KEY, JSON.stringify(session)); +} + +export function loadSession(): StoredSession | null { + if (!browser) return null; + const raw = sessionStorage.getItem(SESSION_KEY); + if (!raw) return null; + try { + return JSON.parse(raw) as StoredSession; + } catch { + return null; + } +} + +export function clearSession(): void { + if (!browser) return; + sessionStorage.removeItem(SESSION_KEY); +} + +export interface StoredAdmin { + accessToken: string; + email: string; + expiresAt: number; +} + +export function saveAdmin(admin: StoredAdmin): void { + if (!browser) return; + localStorage.setItem(ADMIN_KEY, JSON.stringify(admin)); +} + +export function loadAdmin(): StoredAdmin | null { + if (!browser) return null; + const raw = localStorage.getItem(ADMIN_KEY); + if (!raw) return null; + try { + const admin = JSON.parse(raw) as StoredAdmin; + if (admin.expiresAt <= Date.now()) { + localStorage.removeItem(ADMIN_KEY); + return null; + } + return admin; + } catch { + return null; + } +} + +export function clearAdmin(): void { + if (!browser) return; + localStorage.removeItem(ADMIN_KEY); +} diff --git a/apps/web-client/src/routes/+layout.svelte b/apps/web-client/src/routes/+layout.svelte new file mode 100644 index 0000000..97da37e --- /dev/null +++ b/apps/web-client/src/routes/+layout.svelte @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ {@render children()} +
diff --git a/apps/web-client/src/routes/+page.svelte b/apps/web-client/src/routes/+page.svelte new file mode 100644 index 0000000..420ba79 --- /dev/null +++ b/apps/web-client/src/routes/+page.svelte @@ -0,0 +1,262 @@ + + + + {title} + + + + + + +{#if isJoinOnly} +
+
+ {#if logoUrl} + + {:else} + + {/if} +

{branding.name}

+ {#if branding.description} +

{branding.description}

+ {/if} +
+ + +
+{:else} +
+
+ +
+ +
+
+
+
+

+ {branding.headline} +

+ +

{branding.description}

+
+ + +
+
+ +
+
+

How it works

+

+ Three steps, no setup for the people joining. +

+ +
+ {#each steps as step, index} +
+ + {String(index + 1).padStart(2, "0")} + +

{step.title}

+

{step.body}

+
+ {/each} +
+
+
+ +
+

Features

+

+ Everything the room needs on one screen. +

+ +
+ {#each features as feature} +
+ +

{feature.title}

+

{feature.body}

+
+ {/each} +
+
+ +
+
+
+

Self host

+

+ Your screens, your server, your data. +

+

+ PiStation ships as a Docker Compose stack: a LiveKit media server, a Rust backend and + this site. Media travels directly between devices on your own network, so a + presentation never touches anyone else's infrastructure. +

+ +
+ {#each ["One command to bring the whole stack up", "SQLite for storage, no external database", "Add as many kiosks and rooms as you like", "Every kiosk reports its own CPU, memory and signal strength"] as line} +

+ + {line} +

+ {/each} +
+
+ +
+

Get started

+
cp infra/.env.example infra/.env
+docker compose -f infra/docker-compose.yml up -d
+

+ Then sign in to the admin panel, add a kiosk, and copy its enrollment token onto the Pi. +

+
+
+
+
+ +
+

+ {branding.name}{branding.footerNote ? ` · ${branding.footerNote}` : ""} +

+ +
+ {#each branding.links as link (link.linkId)} + {#if link.label && link.url} + + {link.label} + + {/if} + {/each} + + {#if branding.showSourceLink} + + + Source on GitHub + + {/if} +
+
+
+{/if} diff --git a/apps/web-client/src/routes/admin/+page.svelte b/apps/web-client/src/routes/admin/+page.svelte new file mode 100644 index 0000000..1eff841 --- /dev/null +++ b/apps/web-client/src/routes/admin/+page.svelte @@ -0,0 +1,435 @@ + + + + Admin · PiStation + + + +{#if !admin} +
+
+ + + Back to PiStation + + +
+ +

PiStation admin

+

Sign in to manage kiosks

+
+ + {#if errorMessage} +

+ + {errorMessage} +

+ {/if} + +
+ + + + + +
+
+
+{:else} +
+
+ + + + + + +
+

PiStation admin

+

{admin.email}

+
+ + + + Organisation + + + +
+ + {#if errorMessage} +

+ + {errorMessage} +

+ {/if} + + {#if issuedToken} +
+ + +
+ {/if} + +
+
+ +

Kiosk build

+ + + + +
+ +

+ The installer script downloads this package onto each Pi. Upload the arm64 build produced + by the release workflow, then every kiosk is one command to set up. +

+ + {#if packageMessage} +

{packageMessage}

+ {/if} +
+ +
+

Kiosks

+ + {#if kiosks.length === 0} +

+ No kiosks yet. Add one below to get an enrollment token. +

+ {/if} + +
+ {#each kiosks as kiosk (kiosk.kioskId)} +
+ + + {#if editingId === kiosk.kioskId} +
+ + + + +
+ {:else} +
+

{kiosk.name}

+

+ {kiosk.location || "No location"} · last seen {formatLastSeen(kiosk.lastSeenAt)} +

+ {#if kiosk.status === "online" && kiosk.metrics} +
+ +
+ {/if} +
+ + + + + + Widgets + + + + {/if} +
+ {/each} +
+
+ +
+

Add a kiosk

+
+ + + +
+
+
+{/if} diff --git a/apps/web-client/src/routes/admin/kiosks/[kioskId]/+page.svelte b/apps/web-client/src/routes/admin/kiosks/[kioskId]/+page.svelte new file mode 100644 index 0000000..5a3fbb0 --- /dev/null +++ b/apps/web-client/src/routes/admin/kiosks/[kioskId]/+page.svelte @@ -0,0 +1,391 @@ + + + + {kiosk ? `${kiosk.name} · Admin · PiStation` : "Kiosk · Admin · PiStation"} + + + +
+
+ + + + +
+

{kiosk?.name ?? "Kiosk"}

+

{kiosk?.location || "No location"}

+
+ + {#if currentPin} +
+

Current PIN

+

{currentPin}

+
+ {/if} + + +
+ + {#if statusMessage} +

+ + {statusMessage} +

+ {/if} + + {#if errorMessage} +

+ + {errorMessage} +

+ {/if} + + {#if layout} +
+
+
+
+

+ Preview +

+ +
+ + {#if isPreviewingNight} +
+ +
+ {:else} + (selectedWidgetId = widgetId)} + onWidgetsChange={replaceWidgets} + /> +

+ Drag a widget to move it, drag its corner to resize. With one selected, the arrow + keys nudge it and shift with the arrow keys resizes. +

+ {/if} +
+ +
+

Add a widget

+
+ {#each BUILTIN_WIDGET_KINDS as kind} + + {/each} +
+
+ + +
+ + +
+ {/if} +
diff --git a/apps/web-client/src/routes/admin/organization/+page.svelte b/apps/web-client/src/routes/admin/organization/+page.svelte new file mode 100644 index 0000000..a43ed26 --- /dev/null +++ b/apps/web-client/src/routes/admin/organization/+page.svelte @@ -0,0 +1,391 @@ + + + + Organisation · Admin · PiStation + + + +
+
+ + + + +
+

Organisation

+

Branding shown on the public homepage

+
+ + +
+ + {#if statusMessage} +

+ + {statusMessage} +

+ {/if} + + {#if errorMessage} +

+ + {errorMessage} +

+ {/if} + + {#if branding} + {@const current = branding} +
+
+

Identity

+ + + +
+
+ {#if logoPreview} + Logo + {:else} +
+ +
+ {/if} + + + + + + {#if current.logoUrl} + + {/if} +
+ + +
+
+ +
+

Homepage

+ +
+ What visitors see at the root address +
+ {#each LANDING_OPTIONS as option} + + {/each} +
+
+ + + + + + +
+ +
+
+

Theme

+ +
+ +

+ These colours apply across the whole website, including the join screen and the admin + panel. +

+ +
+ {#each THEME_FIELDS as field} + + {/each} +
+
+ +
+
+

Footer

+ +
+ + + + {#each current.links as link (link.linkId)} +
+ + updateLink(link.linkId, { label: (event.target as HTMLInputElement).value })} + class="min-w-32 flex-1 bg-surface-2 px-3 py-2 text-sm" + /> + + updateLink(link.linkId, { url: (event.target as HTMLInputElement).value })} + class="min-w-48 flex-2 bg-surface-2 px-3 py-2 text-sm" + /> + +
+ {/each} + + +
+
+ {/if} +
diff --git a/apps/web-client/src/routes/room/+page.svelte b/apps/web-client/src/routes/room/+page.svelte new file mode 100644 index 0000000..0629b33 --- /dev/null +++ b/apps/web-client/src/routes/room/+page.svelte @@ -0,0 +1,298 @@ + + + + {controller ? `${controller.kioskName} · PiStation` : "Room · PiStation"} + + + +{#if controller} + {@const current = controller} +
+
+
+ +
+

{current.kioskName}

+

+ {current.role === "presenter" ? "Presenting" : "Viewing"} +

+
+
+ + + + + +
+ + + + + {#if current.isCameraOn} + + {/if} + + + + + + +
+
+ + {#if current.errorMessage} +

+ + {current.errorMessage} +

+ {/if} + +
+
+
+ {#if current.mode === "whiteboard"} + current.pushWhiteboardElements(changed)} + /> + {:else if current.localScreenTrack} + + + + You are sharing this screen + + current.beginStroke(point)} + onStrokeExtend={(point) => current.extendStroke(point)} + onStrokeEnd={() => current.endStroke()} + onPointerMove={(point) => current.movePointer(point)} + /> + {:else if current.screenTrack} + + current.beginStroke(point)} + onStrokeExtend={(point) => current.extendStroke(point)} + onStrokeEnd={() => current.endStroke()} + onPointerMove={(point) => current.movePointer(point)} + /> + {:else if cameraTiles.length > 0} +
1} + > + {#each cameraTiles as tile (tile.key)} +
+ + + + {tile.label} + +
+ {/each} +
+ {:else} +
+ +
+

Nothing on screen yet

+

+ Share your screen, turn on a camera, or open the whiteboard to begin. +

+
+
+ {/if} +
+ + {#if drawingNow.length > 0} +
+ + {drawingNow.join(", ")} + {drawingNow.length === 1 ? "is" : "are"} drawing +
+ {/if} + + {#if isAnnotating && current.mode !== "whiteboard"} + + {/if} +
+
+
+{:else} +
+ +
+{/if} diff --git a/apps/web-client/static/logo.svg b/apps/web-client/static/logo.svg new file mode 100644 index 0000000..166d7b2 --- /dev/null +++ b/apps/web-client/static/logo.svg @@ -0,0 +1,24 @@ + + PiStation + + + + + + + + + + + + + + diff --git a/apps/web-client/static/og-image.svg b/apps/web-client/static/og-image.svg new file mode 100644 index 0000000..c1cc280 --- /dev/null +++ b/apps/web-client/static/og-image.svg @@ -0,0 +1,60 @@ + + PiStation + + + + + + + + + + + + + + + + + + + PiStation + + + + Any screen becomes a shared screen. + + + + Present, annotate and whiteboard together. Self hosted. + + diff --git a/apps/web-client/svelte.config.js b/apps/web-client/svelte.config.js new file mode 100644 index 0000000..820ad0a --- /dev/null +++ b/apps/web-client/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from "@sveltejs/adapter-node"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter() + } +}; + +export default config; diff --git a/apps/web-client/tsconfig.json b/apps/web-client/tsconfig.json new file mode 100644 index 0000000..4344710 --- /dev/null +++ b/apps/web-client/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/apps/web-client/vite.config.ts b/apps/web-client/vite.config.ts new file mode 100644 index 0000000..bdbfe2a --- /dev/null +++ b/apps/web-client/vite.config.ts @@ -0,0 +1,22 @@ +import { sveltekit } from "@sveltejs/kit/vite"; +import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + // The workspace root, so the dev server may serve the shared packages. + fs: { + allow: ["../.."] + } + }, + ssr: { + noExternal: ["@pistation/shared-types", "@pistation/client-core", "@pistation/ui"] + }, + optimizeDeps: { + include: ["react", "react-dom", "react-dom/client"] + }, + define: { + "process.env.IS_PREACT": JSON.stringify("false") + } +});