Add web client

Join screen, room with screen and camera sharing, live annotation,
whiteboard, and the admin panel for kiosks, widgets and branding.

Claude-Session: https://claude.ai/code/session_01SS9F92jb51bMCRCKem6QtD
This commit is contained in:
2026-08-09 17:09:16 -04:00
parent fc808b81c7
commit 31d55fce86
37 changed files with 5074 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import { themeStyle } from "@pistation/shared-types";
import { onMount } from "svelte";
import { page } from "$app/stores";
import { branding, ensureBranding } from "$lib/branding.svelte";
import { SITE_DESCRIPTION, SITE_NAME, THEME_COLOR } from "$lib/meta";
import "../app.css";
let { children } = $props();
onMount(() => {
void ensureBranding();
});
const canonicalUrl = $derived(`${$page.url.origin}${$page.url.pathname}`);
const imageUrl = $derived(`${$page.url.origin}/og-image.svg`);
</script>
<svelte:head>
<meta name="theme-color" content={THEME_COLOR} />
<meta name="color-scheme" content="dark" />
<link rel="canonical" href={canonicalUrl} />
<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:type" content="website" />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:image" content={imageUrl} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={SITE_NAME} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={imageUrl} />
<meta name="description" content={SITE_DESCRIPTION} />
<meta property="og:description" content={SITE_DESCRIPTION} />
<meta name="twitter:description" content={SITE_DESCRIPTION} />
</svelte:head>
<div class="min-h-full" style={themeStyle(branding.value)}>
{@render children()}
</div>
+262
View File
@@ -0,0 +1,262 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { Logo } from "@pistation/ui";
import { resolveMediaUrl } from "@pistation/shared-types";
import { apiBaseUrl } from "$lib/api";
import { branding as brandingStore } from "$lib/branding.svelte";
import JoinCard from "$lib/components/JoinCard.svelte";
import { SITE_DESCRIPTION, SITE_KEYWORDS } from "$lib/meta";
const branding = $derived(brandingStore.value);
const isJoinOnly = $derived(branding.landingMode === "join");
const logoUrl = $derived(branding.logoUrl ? resolveMediaUrl(apiBaseUrl, branding.logoUrl) : "");
const title = $derived(`${branding.name} · ${branding.headline}`);
const features = [
{
icon: "ph:broadcast-bold",
title: "Share your screen",
body: "Publish a tab, a window or your whole desktop to the big screen. Nothing to install, no cable to hunt for, and anyone in the room can take a turn."
},
{
icon: "ph:pencil-simple-bold",
title: "Annotate live",
body: "Draw over whatever is on screen with an adjustable pen, arrows, shapes and a highlighter. Every stroke lands in the same place on the TV as on your phone."
},
{
icon: "ph:video-camera-bold",
title: "Share a camera",
body: "Point a phone at a whiteboard, a workbench or the room itself. Front and rear cameras both work, and a screen share always takes priority."
},
{
icon: "ph:hand-pointing-bold",
title: "Point things out",
body: "Pointer mode shows the room where you are gesturing without leaving a mark, and everyone's cursor carries their name."
},
{
icon: "ph:scribble-loop-bold",
title: "Shared whiteboard",
body: "Flip the room into a full Excalidraw canvas that everyone can edit at once, mirrored live onto the screen."
},
{
icon: "ph:squares-four-bold",
title: "Idle dashboard",
body: "When nobody is presenting the screen becomes a dashboard. Clock, weather, agenda, rotating wallpapers, or a widget you build yourself."
},
{
icon: "ph:moon-bold",
title: "Night mode",
body: "Outside working hours the display drops to just the time and the join code, dimmed to whatever level suits the room."
}
];
const steps = [
{
title: "Plug in the Pi",
body: "A Raspberry Pi Zero 2 W, 4 or 5 connects to any TV or monitor over HDMI and boots straight into the kiosk. One command sets it up over SSH."
},
{
title: "Read the code",
body: "The screen shows a six digit code that rotates every minute, so an old photo of it is worthless to anyone."
},
{
title: "Take the screen",
body: "Type the code here and give your name, then share a screen or a camera. You keep the screen until you leave, even as the code carries on rotating."
}
];
</script>
<svelte:head>
<title>{title}</title>
<meta name="keywords" content={SITE_KEYWORDS} />
<meta property="og:title" content={title} />
<meta name="twitter:title" content={title} />
<meta name="description" content={SITE_DESCRIPTION} />
</svelte:head>
{#if isJoinOnly}
<main class="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
<div class="flex flex-col items-center gap-4 text-center">
{#if logoUrl}
<img src={logoUrl} alt="" class="h-16 w-16 object-contain" />
{:else}
<Logo size={64} />
{/if}
<h1 class="text-3xl font-semibold tracking-tight">{branding.name}</h1>
{#if branding.description}
<p class="max-w-md text-ink-2">{branding.description}</p>
{/if}
</div>
<JoinCard label={branding.joinLabel} />
</main>
{:else}
<div class="flex min-h-screen flex-col">
<header class="sticky top-0 z-10 bg-surface-0/90 backdrop-blur">
<nav class="mx-auto flex w-full max-w-6xl items-center gap-4 px-6 py-4">
<a href="/" class="flex items-center gap-3">
{#if logoUrl}
<img src={logoUrl} alt="" class="h-9 w-9 object-contain" />
{:else}
<Logo size={36} />
{/if}
<span class="text-lg font-semibold tracking-tight">{branding.name}</span>
</a>
<div class="ml-auto flex items-center gap-1">
<a
href="#how-it-works"
class="hidden px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-1 sm:block"
>
How it works
</a>
<a
href="#features"
class="hidden px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-1 sm:block"
>
Features
</a>
<a
href="#self-host"
class="hidden px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-1 sm:block"
>
Self host
</a>
<a
href="/admin"
class="flex items-center gap-2 bg-surface-2 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3"
>
<Icon icon="ph:shield-check-bold" width="16" />
Admin
</a>
</div>
</nav>
</header>
<main class="flex-1">
<section class="mx-auto w-full max-w-6xl px-6 py-16 sm:py-24">
<div class="grid items-center gap-12 lg:grid-cols-[1.1fr_1fr]">
<div>
<h1 class="text-4xl leading-[1.05] font-semibold tracking-tight sm:text-6xl">
{branding.headline}
</h1>
<p class="mt-6 max-w-xl text-lg text-ink-1">{branding.description}</p>
</div>
<JoinCard label={branding.joinLabel} />
</div>
</section>
<section id="how-it-works" class="bg-surface-1">
<div class="mx-auto w-full max-w-6xl px-6 py-16 sm:py-20">
<h2 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">How it works</h2>
<p class="mt-3 max-w-2xl text-2xl font-semibold tracking-tight sm:text-3xl">
Three steps, no setup for the people joining.
</p>
<div class="mt-10 grid gap-px sm:grid-cols-3">
{#each steps as step, index}
<div class="flex flex-col gap-3 bg-surface-0 p-8">
<span class="font-mono text-4xl font-semibold text-accent">
{String(index + 1).padStart(2, "0")}
</span>
<h3 class="text-lg font-medium">{step.title}</h3>
<p class="text-ink-2">{step.body}</p>
</div>
{/each}
</div>
</div>
</section>
<section id="features" class="mx-auto w-full max-w-6xl px-6 py-16 sm:py-20">
<h2 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">Features</h2>
<p class="mt-3 max-w-2xl text-2xl font-semibold tracking-tight sm:text-3xl">
Everything the room needs on one screen.
</p>
<div class="mt-10 grid gap-px sm:grid-cols-2 lg:grid-cols-3">
{#each features as feature}
<div class="flex flex-col gap-3 bg-surface-1 p-8">
<Icon icon={feature.icon} width="26" class="text-accent" />
<h3 class="text-lg font-medium">{feature.title}</h3>
<p class="text-ink-2">{feature.body}</p>
</div>
{/each}
</div>
</section>
<section id="self-host" class="bg-surface-1">
<div class="mx-auto grid w-full max-w-6xl gap-10 px-6 py-16 sm:py-20 lg:grid-cols-2">
<div>
<h2 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">Self host</h2>
<p class="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl">
Your screens, your server, your data.
</p>
<p class="mt-4 text-ink-1">
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.
</p>
<div class="mt-6 flex flex-col gap-3">
{#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}
<p class="flex items-start gap-3 text-ink-2">
<Icon icon="ph:check-bold" width="18" class="mt-1 shrink-0 text-success" />
{line}
</p>
{/each}
</div>
</div>
<div class="flex flex-col justify-center bg-surface-0 p-6 sm:p-8">
<p class="mb-3 text-xs tracking-wide text-ink-2 uppercase">Get started</p>
<pre class="overflow-x-auto font-mono text-sm text-ink-1"><code
>cp infra/.env.example infra/.env
docker compose -f infra/docker-compose.yml up -d</code
></pre>
<p class="mt-4 text-sm text-ink-2">
Then sign in to the admin panel, add a kiosk, and copy its enrollment token onto the Pi.
</p>
</div>
</div>
</section>
</main>
<footer class="mx-auto flex w-full max-w-6xl flex-wrap items-center gap-4 px-6 py-8">
<p class="text-sm text-ink-2">
{branding.name}{branding.footerNote ? ` · ${branding.footerNote}` : ""}
</p>
<div class="ml-auto flex flex-wrap items-center gap-2">
{#each branding.links as link (link.linkId)}
{#if link.label && link.url}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
class="px-4 py-2 text-sm text-ink-2 transition-colors hover:text-ink-0"
>
{link.label}
</a>
{/if}
{/each}
{#if branding.showSourceLink}
<a
href="https://github.com/SirBlobby/pistation"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 bg-surface-1 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-2 hover:text-ink-0"
>
<Icon icon="ph:github-logo-bold" width="16" />
Source on GitHub
</a>
{/if}
</div>
</footer>
</div>
{/if}
@@ -0,0 +1,435 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { Kiosk } from "@pistation/shared-types";
import { onMount } from "svelte";
import {
adminLogin,
ApiError,
createKiosk,
deleteKiosk,
listKiosks,
updateKiosk,
uploadKioskPackage
} from "$lib/api";
import { Logo } from "@pistation/ui";
import InstallCommand from "$lib/components/admin/InstallCommand.svelte";
import KioskStats from "$lib/components/admin/KioskStats.svelte";
import { clearAdmin, loadAdmin, saveAdmin, type StoredAdmin } from "$lib/session";
let admin = $state<StoredAdmin | null>(null);
let kiosks = $state<Kiosk[]>([]);
let errorMessage = $state<string | null>(null);
let isBusy = $state(false);
let email = $state("");
let password = $state("");
let newName = $state("");
let newLocation = $state("");
let issuedToken = $state<{ name: string; token: string } | null>(null);
let editingId = $state<string | null>(null);
let editName = $state("");
let editLocation = $state("");
function startRename(kiosk: Kiosk) {
editingId = kiosk.kioskId;
editName = kiosk.name;
editLocation = kiosk.location;
}
function cancelRename() {
editingId = null;
}
async function saveRename(event: SubmitEvent) {
event.preventDefault();
if (!admin || !editingId || !editName.trim()) return;
isBusy = true;
errorMessage = null;
try {
await updateKiosk(admin.accessToken, editingId, editName, editLocation);
editingId = null;
await refresh();
} catch (error) {
errorMessage = error instanceof ApiError ? error.message : "Could not rename the kiosk.";
} finally {
isBusy = false;
}
}
let packageInput = $state<HTMLInputElement | null>(null);
let isUploadingPackage = $state(false);
let packageMessage = $state<string | null>(null);
async function handlePackageUpload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file || !admin) return;
isUploadingPackage = true;
packageMessage = null;
errorMessage = null;
try {
const result = await uploadKioskPackage(admin.accessToken, file);
const megabytes = (result.sizeBytes / 1024 / 1024).toFixed(1);
packageMessage = `Uploaded ${file.name}, ${megabytes} MB. Kiosks will install this build.`;
} catch (error) {
errorMessage = error instanceof ApiError ? error.message : "Could not upload the package.";
} finally {
isUploadingPackage = false;
}
}
onMount(() => {
admin = loadAdmin();
if (admin) void refresh();
const interval = setInterval(() => {
if (admin && !editingId) void refresh();
}, 15000);
return () => clearInterval(interval);
});
async function signIn(event: SubmitEvent) {
event.preventDefault();
isBusy = true;
errorMessage = null;
try {
const response = await adminLogin(email, password);
const stored = {
accessToken: response.accessToken,
email: response.email,
expiresAt: response.expiresAt
};
saveAdmin(stored);
admin = stored;
password = "";
await refresh();
} catch (error) {
errorMessage = error instanceof ApiError ? error.message : "Sign in failed.";
} finally {
isBusy = false;
}
}
function signOut() {
clearAdmin();
admin = null;
kiosks = [];
}
async function refresh() {
if (!admin) return;
try {
const response = await listKiosks(admin.accessToken);
kiosks = response.kiosks;
} catch (error) {
if (error instanceof ApiError && error.status === 401) signOut();
else errorMessage = "Could not load kiosks.";
}
}
async function addKiosk(event: SubmitEvent) {
event.preventDefault();
if (!admin || !newName.trim()) return;
isBusy = true;
try {
const response = await createKiosk(admin.accessToken, newName, newLocation);
issuedToken = { name: response.kiosk.name, token: response.enrollmentToken };
newName = "";
newLocation = "";
await refresh();
} catch (error) {
errorMessage = error instanceof ApiError ? error.message : "Could not create kiosk.";
} finally {
isBusy = false;
}
}
async function removeKiosk(kiosk: Kiosk) {
if (!admin) return;
if (!confirm(`Delete ${kiosk.name}? This cannot be undone.`)) return;
await deleteKiosk(admin.accessToken, kiosk.kioskId);
await refresh();
}
function formatLastSeen(timestamp: number | null) {
if (!timestamp) return "never";
return new Date(timestamp).toLocaleString();
}
</script>
<svelte:head>
<title>Admin · PiStation</title>
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
{#if !admin}
<main class="flex min-h-screen flex-col items-center justify-center px-6 py-12">
<div class="w-full max-w-sm">
<a
href="/"
class="mb-8 inline-flex items-center gap-2 text-sm text-ink-2 transition-colors hover:text-ink-0"
>
<Icon icon="ph:arrow-left-bold" width="16" />
Back to PiStation
</a>
<div class="mb-8 flex flex-col items-center text-center">
<Logo size={56} class="mb-4" />
<h1 class="text-2xl font-semibold tracking-tight">PiStation admin</h1>
<p class="mt-1 text-sm text-ink-2">Sign in to manage kiosks</p>
</div>
{#if errorMessage}
<p class="mb-6 flex items-center gap-2 bg-danger/15 px-4 py-3 text-sm text-danger">
<Icon icon="ph:warning-circle-bold" width="18" class="shrink-0" />
{errorMessage}
</p>
{/if}
<form onsubmit={signIn} class="bg-surface-1 p-6">
<label class="mb-4 block">
<span class="mb-2 block text-sm font-medium text-ink-1">Email</span>
<input
bind:value={email}
type="email"
required
class="w-full bg-surface-2 px-4 py-3 text-ink-0"
/>
</label>
<label class="mb-6 block">
<span class="mb-2 block text-sm font-medium text-ink-1">Password</span>
<input
bind:value={password}
type="password"
required
class="w-full bg-surface-2 px-4 py-3 text-ink-0"
/>
</label>
<button
type="submit"
disabled={isBusy}
class="w-full bg-accent px-6 py-3 font-semibold text-white transition-colors hover:bg-accent-strong disabled:bg-surface-3"
>
Sign in
</button>
</form>
</div>
</main>
{:else}
<main class="mx-auto w-full max-w-5xl px-6 py-12">
<header class="mb-10 flex flex-wrap items-center gap-3">
<a
href="/"
aria-label="Back to PiStation"
class="flex h-10 w-10 items-center justify-center bg-surface-2 text-ink-1 transition-colors hover:bg-surface-3"
>
<Icon icon="ph:arrow-left-bold" width="18" />
</a>
<Logo size={40} />
<div class="flex-1">
<h1 class="text-xl font-semibold tracking-tight">PiStation admin</h1>
<p class="text-sm text-ink-2">{admin.email}</p>
</div>
<a
href="/admin/organization"
class="flex items-center gap-2 bg-surface-2 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3"
>
<Icon icon="ph:buildings-bold" width="16" />
Organisation
</a>
<button
onclick={signOut}
class="bg-surface-2 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3"
>
Sign out
</button>
</header>
{#if errorMessage}
<p class="mb-6 flex items-center gap-2 bg-danger/15 px-4 py-3 text-sm text-danger">
<Icon icon="ph:warning-circle-bold" width="18" class="shrink-0" />
{errorMessage}
</p>
{/if}
{#if issuedToken}
<div class="mb-8 flex flex-col gap-3">
<InstallCommand enrollmentToken={issuedToken.token} kioskName={issuedToken.name} />
<button
onclick={() => (issuedToken = null)}
class="self-start bg-surface-2 px-4 py-2 text-sm text-ink-1 hover:bg-surface-3"
>
Done
</button>
</div>
{/if}
<section class="mb-10 flex flex-col gap-3 bg-surface-1 p-5">
<div class="flex flex-wrap items-center gap-3">
<Icon icon="ph:package-bold" width="18" class="text-accent" />
<h2 class="flex-1 text-sm font-semibold tracking-wide uppercase">Kiosk build</h2>
<input
bind:this={packageInput}
type="file"
accept=".deb"
onchange={handlePackageUpload}
class="hidden"
/>
<button
onclick={() => packageInput?.click()}
disabled={isUploadingPackage}
class="flex items-center gap-2 bg-surface-2 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3 disabled:text-ink-2"
>
{#if isUploadingPackage}
<Icon icon="ph:circle-notch-bold" width="16" class="animate-spin" />
Uploading
{:else}
<Icon icon="ph:upload-simple-bold" width="16" />
Upload .deb
{/if}
</button>
</div>
<p class="text-sm text-ink-2">
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.
</p>
{#if packageMessage}
<p class="bg-success/15 px-4 py-2 text-sm text-success">{packageMessage}</p>
{/if}
</section>
<section class="mb-10">
<h2 class="mb-4 text-sm font-semibold tracking-wide text-ink-2 uppercase">Kiosks</h2>
{#if kiosks.length === 0}
<p class="bg-surface-1 px-6 py-8 text-center text-ink-2">
No kiosks yet. Add one below to get an enrollment token.
</p>
{/if}
<div class="flex flex-col gap-px">
{#each kiosks as kiosk (kiosk.kioskId)}
<div class="flex flex-wrap items-center gap-4 bg-surface-1 px-5 py-4">
<span
class="h-2 w-2 shrink-0"
class:bg-success={kiosk.status === "online"}
class:bg-ink-2={kiosk.status !== "online"}
></span>
{#if editingId === kiosk.kioskId}
<form onsubmit={saveRename} class="flex min-w-60 flex-1 flex-wrap items-center gap-2">
<input
bind:value={editName}
placeholder="Name"
required
class="min-w-32 flex-1 bg-surface-2 px-3 py-2 text-sm text-ink-0"
/>
<input
bind:value={editLocation}
placeholder="Location"
class="min-w-32 flex-1 bg-surface-2 px-3 py-2 text-sm text-ink-0"
/>
<button
type="submit"
disabled={isBusy || !editName.trim()}
class="flex items-center gap-2 bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-strong disabled:bg-surface-3 disabled:text-ink-2"
>
<Icon icon="ph:check-bold" width="16" />
Save
</button>
<button
type="button"
onclick={cancelRename}
class="bg-surface-2 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3"
>
Cancel
</button>
</form>
{:else}
<div class="min-w-40 flex-1">
<p class="font-medium">{kiosk.name}</p>
<p class="text-sm text-ink-2">
{kiosk.location || "No location"} · last seen {formatLastSeen(kiosk.lastSeenAt)}
</p>
{#if kiosk.status === "online" && kiosk.metrics}
<div class="mt-1.5">
<KioskStats metrics={kiosk.metrics} metricsAt={kiosk.metricsAt} compact />
</div>
{/if}
</div>
<button
onclick={() => startRename(kiosk)}
aria-label={`Rename ${kiosk.name}`}
class="flex h-9 w-9 items-center justify-center bg-surface-2 text-ink-1 transition-colors hover:bg-surface-3"
>
<Icon icon="ph:pencil-simple-bold" width="16" />
</button>
<a
href={`/admin/kiosks/${kiosk.kioskId}`}
class="flex items-center gap-2 bg-surface-2 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3"
>
<Icon icon="ph:squares-four-bold" width="16" />
Widgets
</a>
<button
onclick={() => removeKiosk(kiosk)}
aria-label={`Delete ${kiosk.name}`}
class="flex h-9 w-9 items-center justify-center bg-surface-2 text-danger transition-colors hover:bg-surface-3"
>
<Icon icon="ph:trash-bold" width="16" />
</button>
{/if}
</div>
{/each}
</div>
</section>
<section class="bg-surface-1 p-6">
<h2 class="mb-4 text-sm font-semibold tracking-wide text-ink-2 uppercase">Add a kiosk</h2>
<form onsubmit={addKiosk} class="flex flex-wrap gap-3">
<input
bind:value={newName}
placeholder="Name"
required
class="min-w-40 flex-1 bg-surface-2 px-4 py-3 text-ink-0 placeholder:text-ink-2"
/>
<input
bind:value={newLocation}
placeholder="Location"
class="min-w-40 flex-1 bg-surface-2 px-4 py-3 text-ink-0 placeholder:text-ink-2"
/>
<button
type="submit"
disabled={isBusy}
class="flex items-center gap-2 bg-accent px-6 py-3 font-semibold text-white transition-colors hover:bg-accent-strong disabled:bg-surface-3"
>
<Icon icon="ph:plus-bold" width="18" />
Create
</button>
</form>
</section>
</main>
{/if}
@@ -0,0 +1,391 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { CustomWidgetDefinition, Kiosk, KioskLayout, Widget, WidgetKind } from "@pistation/shared-types";
import {
BUILTIN_WIDGET_KINDS,
createId,
DEFAULT_WIDGET_SETTINGS,
DEFAULT_WIDGET_STYLES,
ensureUniqueIds,
findFreePlacement
} from "@pistation/shared-types";
import { NightSurface } from "@pistation/ui";
import { onMount } from "svelte";
import { page } from "$app/stores";
import {
ApiError,
apiBaseUrl,
getKiosk,
rotateEnrollment,
saveKioskLayout,
uploadWallpaper
} from "$lib/api";
import AppearancePanel from "$lib/components/admin/AppearancePanel.svelte";
import InstallCommand from "$lib/components/admin/InstallCommand.svelte";
import KioskStats from "$lib/components/admin/KioskStats.svelte";
import NightModePanel from "$lib/components/admin/NightModePanel.svelte";
import WidgetBuilder from "$lib/components/admin/WidgetBuilder.svelte";
import WidgetGridEditor from "$lib/components/admin/WidgetGridEditor.svelte";
import WidgetList from "$lib/components/admin/WidgetList.svelte";
import WidgetSettingsPanel from "$lib/components/admin/WidgetSettingsPanel.svelte";
import { loadAdmin } from "$lib/session";
const kioskId = $derived($page.params.kioskId ?? "");
let kiosk = $state<Kiosk | null>(null);
let layout = $state<KioskLayout | null>(null);
let currentPin = $state<string | null>(null);
let selectedWidgetId = $state<string | null>(null);
let statusMessage = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
let newEnrollmentToken = $state<string | null>(null);
let isPreviewingNight = $state(false);
let isUploading = $state(false);
let uploadError = $state<string | null>(null);
const admin = loadAdmin();
const selectedWidget = $derived(
layout?.widgets.find((widget) => widget.widgetId === selectedWidgetId) ?? null
);
const joinUrl = $derived(
typeof window === "undefined" ? "" : window.location.host
);
onMount(() => {
void load();
// Only the device stats are refreshed on a timer. Re-reading the layout would throw
// away whatever the admin is part way through editing.
const interval = setInterval(() => void refreshStats(), 15000);
return () => clearInterval(interval);
});
async function refreshStats() {
if (!admin) return;
try {
const detail = await getKiosk(admin.accessToken, kioskId);
kiosk = detail.kiosk;
currentPin = detail.currentPin;
} catch {
return;
}
}
async function load() {
if (!admin) return;
try {
const detail = await getKiosk(admin.accessToken, kioskId);
kiosk = detail.kiosk;
currentPin = detail.currentPin;
layout = {
...detail.layout,
widgets: ensureUniqueIds(
detail.layout.widgets,
(widget) => widget.widgetId,
(widget, widgetId) => ({ ...widget, widgetId }),
"w"
)
};
} catch (error) {
errorMessage = error instanceof ApiError ? error.message : "Could not load this kiosk.";
}
}
const PREFERRED_SPANS: Partial<Record<WidgetKind, [number, number]>> = {
pin: [12, 4],
clock: [5, 2],
weather: [5, 2],
image: [6, 4],
agenda: [4, 4]
};
function addWidget(kind: WidgetKind) {
if (!layout) return;
const [columnSpan, rowSpan] = PREFERRED_SPANS[kind] ?? [4, 2];
const placement = findFreePlacement(layout.widgets, columnSpan, rowSpan);
if (!placement) {
errorMessage = "There is no free space left on the grid. Remove or shrink a widget first.";
return;
}
const widget: Widget = {
widgetId: createId("w"),
kind,
placement,
settings: structuredClone(DEFAULT_WIDGET_SETTINGS[kind]),
style: structuredClone(DEFAULT_WIDGET_STYLES[kind]),
enabled: true
};
errorMessage = null;
layout = { ...layout, widgets: [...layout.widgets, widget] };
selectedWidgetId = widget.widgetId;
}
function replaceWidgets(widgets: Widget[]) {
if (!layout) return;
layout = { ...layout, widgets };
if (selectedWidgetId && !widgets.some((widget) => widget.widgetId === selectedWidgetId)) {
selectedWidgetId = null;
}
}
function updateWidget(updated: Widget) {
if (!layout) return;
layout = {
...layout,
widgets: layout.widgets.map((widget) =>
widget.widgetId === updated.widgetId ? updated : widget
)
};
}
function removeWidget(widgetId: string) {
if (!layout) return;
layout = {
...layout,
widgets: layout.widgets.filter((widget) => widget.widgetId !== widgetId)
};
if (selectedWidgetId === widgetId) selectedWidgetId = null;
}
function updateDefinitions(definitions: CustomWidgetDefinition[]) {
if (!layout) return;
layout = { ...layout, customDefinitions: definitions };
}
async function save() {
if (!admin || !layout) return;
statusMessage = null;
errorMessage = null;
try {
await saveKioskLayout(admin.accessToken, kioskId, layout);
statusMessage = "Layout saved. The kiosk will pick it up within a minute.";
} catch (error) {
errorMessage = error instanceof ApiError ? error.message : "Could not save the layout.";
}
}
async function handleWallpaperUpload(files: File[]) {
if (!admin || !layout) return;
isUploading = true;
uploadError = null;
const uploaded: string[] = [];
try {
for (const file of files) {
const result = await uploadWallpaper(admin.accessToken, kioskId, file);
uploaded.push(result.imageUrl);
}
} catch (error) {
uploadError = error instanceof ApiError ? error.message : "Upload failed.";
}
if (uploaded.length > 0) {
layout = {
...layout,
background: {
...layout.background,
images: [...(layout.background.images ?? []), ...uploaded]
}
};
statusMessage = `Added ${uploaded.length} image${uploaded.length === 1 ? "" : "s"}. Save the layout to send it to the kiosk.`;
}
isUploading = false;
}
async function regenerateEnrollment() {
if (!admin) return;
if (!confirm("Rotating the enrollment token disconnects the current Pi until it re-enrolls.")) {
return;
}
const response = await rotateEnrollment(admin.accessToken, kioskId);
newEnrollmentToken = response.enrollmentToken;
}
</script>
<svelte:head>
<title>{kiosk ? `${kiosk.name} · Admin · PiStation` : "Kiosk · Admin · PiStation"}</title>
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
<main class="mx-auto w-full max-w-6xl px-6 py-12">
<header class="mb-8 flex flex-wrap items-center gap-4">
<a
href="/admin"
aria-label="Back to kiosks"
class="flex h-10 w-10 items-center justify-center bg-surface-2 text-ink-1 hover:bg-surface-3"
>
<Icon icon="ph:arrow-left-bold" width="18" />
</a>
<div class="flex-1">
<h1 class="text-xl font-semibold tracking-tight">{kiosk?.name ?? "Kiosk"}</h1>
<p class="text-sm text-ink-2">{kiosk?.location || "No location"}</p>
</div>
{#if currentPin}
<div class="bg-surface-1 px-4 py-2 text-center">
<p class="text-xs tracking-wide text-ink-2 uppercase">Current PIN</p>
<p class="font-mono text-lg font-semibold">{currentPin}</p>
</div>
{/if}
<button
onclick={save}
class="flex items-center gap-2 bg-accent px-5 py-3 font-semibold text-white transition-colors hover:bg-accent-strong"
>
<Icon icon="ph:floppy-disk-bold" width="18" />
Save layout
</button>
</header>
{#if statusMessage}
<p class="mb-6 flex items-center gap-2 bg-success/15 px-4 py-3 text-sm text-success">
<Icon icon="ph:check-circle-bold" width="18" />
{statusMessage}
</p>
{/if}
{#if errorMessage}
<p class="mb-6 flex items-center gap-2 bg-danger/15 px-4 py-3 text-sm text-danger">
<Icon icon="ph:warning-circle-bold" width="18" />
{errorMessage}
</p>
{/if}
{#if layout}
<div class="grid gap-6 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<section class="flex min-w-0 flex-col gap-8">
<div class="flex flex-col gap-3">
<div class="flex items-center gap-3">
<h2 class="flex-1 text-sm font-semibold tracking-wide text-ink-2 uppercase">
Preview
</h2>
<button
onclick={() => (isPreviewingNight = !isPreviewingNight)}
class="flex items-center gap-2 px-3 py-2 text-sm transition-colors"
class:bg-accent={isPreviewingNight}
class:text-white={isPreviewingNight}
class:bg-surface-2={!isPreviewingNight}
class:text-ink-1={!isPreviewingNight}
>
<Icon icon="ph:moon-bold" width="16" />
Night mode
</button>
</div>
{#if isPreviewingNight}
<div class="aspect-video w-full overflow-hidden bg-surface-1">
<NightSurface {layout} pin={currentPin} />
</div>
{:else}
<WidgetGridEditor
{layout}
pin={currentPin}
{joinUrl}
mediaBaseUrl={apiBaseUrl}
{selectedWidgetId}
onSelect={(widgetId) => (selectedWidgetId = widgetId)}
onWidgetsChange={replaceWidgets}
/>
<p class="text-xs text-ink-2">
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.
</p>
{/if}
</div>
<div class="flex flex-col gap-3">
<h2 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">Add a widget</h2>
<div class="flex flex-wrap gap-2">
{#each BUILTIN_WIDGET_KINDS as kind}
<button
onclick={() => addWidget(kind)}
class="bg-surface-2 px-4 py-3 text-sm text-ink-1 capitalize transition-colors hover:bg-surface-3"
>
+ {kind}
</button>
{/each}
</div>
</div>
<WidgetBuilder definitions={layout.customDefinitions} onChange={updateDefinitions} />
</section>
<aside class="flex min-w-0 flex-col gap-6">
<WidgetList
widgets={layout.widgets}
{selectedWidgetId}
onSelect={(widgetId) => (selectedWidgetId = widgetId)}
onChange={replaceWidgets}
/>
{#if selectedWidget}
<WidgetSettingsPanel
widget={selectedWidget}
definitions={layout.customDefinitions}
layoutOpacity={layout.widgetOpacity}
onChange={updateWidget}
onRemove={() => removeWidget(selectedWidget.widgetId)}
/>
{:else}
<p class="bg-surface-1 px-5 py-8 text-center text-sm text-ink-2">
Select a widget to edit its settings.
</p>
{/if}
<div class="flex flex-col gap-4 bg-surface-1 p-5">
<div class="flex items-center gap-2">
<h3 class="flex-1 text-sm font-semibold tracking-wide text-ink-2 uppercase">
Device stats
</h3>
<span
class="h-2 w-2 shrink-0"
class:bg-success={kiosk?.status === "online"}
class:bg-ink-2={kiosk?.status !== "online"}
></span>
</div>
<KioskStats metrics={kiosk?.metrics ?? null} metricsAt={kiosk?.metricsAt ?? null} />
</div>
<AppearancePanel
{layout}
onChange={(updated) => (layout = updated)}
onUpload={handleWallpaperUpload}
{isUploading}
{uploadError}
/>
<NightModePanel {layout} onChange={(updated) => (layout = updated)} />
{#if newEnrollmentToken}
<InstallCommand enrollmentToken={newEnrollmentToken} kioskName={kiosk?.name ?? ""} />
{/if}
<div class="flex flex-col gap-3 bg-surface-1 p-5">
<h3 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">Enrollment</h3>
<p class="text-sm text-ink-2">
Rotating issues a fresh token and a new install command for re-imaging this Pi.
</p>
<button
onclick={regenerateEnrollment}
class="bg-surface-2 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3"
>
Rotate enrollment token
</button>
</div>
</aside>
</div>
{/if}
</main>
@@ -0,0 +1,391 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { BrandLink, OrganizationBranding } from "@pistation/shared-types";
import {
createId,
DEFAULT_THEME,
resolveMediaUrl,
withBrandingDefaults
} from "@pistation/shared-types";
import { onMount } from "svelte";
import { ApiError, apiBaseUrl, getBranding, saveBranding, uploadLogo } from "$lib/api";
import { applyBranding } from "$lib/branding.svelte";
import { loadAdmin } from "$lib/session";
const admin = loadAdmin();
const LANDING_OPTIONS = [
{
value: "full" as const,
label: "Full homepage",
description: "Marketing sections, features and footer"
},
{
value: "join" as const,
label: "Join only",
description: "Just the logo and the code entry"
}
];
const THEME_FIELDS = [
{ key: "surface0" as const, label: "Page background" },
{ key: "surface1" as const, label: "Panels" },
{ key: "surface2" as const, label: "Inputs" },
{ key: "surface3" as const, label: "Borders and hovers" },
{ key: "ink0" as const, label: "Primary text" },
{ key: "ink1" as const, label: "Secondary text" },
{ key: "ink2" as const, label: "Muted text" }
];
let branding = $state<OrganizationBranding | null>(null);
let statusMessage = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
let isSaving = $state(false);
let isUploading = $state(false);
let logoInput = $state<HTMLInputElement | null>(null);
const logoPreview = $derived(
branding?.logoUrl ? resolveMediaUrl(apiBaseUrl, branding.logoUrl) : ""
);
onMount(() => {
void load();
});
async function load() {
try {
branding = withBrandingDefaults(await getBranding());
} catch {
branding = withBrandingDefaults(null);
errorMessage = "Could not load the current branding, showing defaults.";
}
}
function patch(update: Partial<OrganizationBranding>) {
if (!branding) return;
branding = { ...branding, ...update };
}
async function handleLogo(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file || !admin) return;
isUploading = true;
errorMessage = null;
try {
const result = await uploadLogo(admin.accessToken, file);
patch({ logoUrl: result.imageUrl });
statusMessage = "Logo uploaded. Save to publish it.";
} catch (error) {
errorMessage = error instanceof ApiError ? error.message : "Could not upload the logo.";
} finally {
isUploading = false;
}
}
function addLink() {
if (!branding) return;
const link: BrandLink = { linkId: createId("link"), label: "", url: "" };
patch({ links: [...branding.links, link] });
}
function updateLink(linkId: string, update: Partial<BrandLink>) {
if (!branding) return;
patch({
links: branding.links.map((link) => (link.linkId === linkId ? { ...link, ...update } : link))
});
}
function removeLink(linkId: string) {
if (!branding) return;
patch({ links: branding.links.filter((link) => link.linkId !== linkId) });
}
async function save() {
if (!admin || !branding) return;
isSaving = true;
statusMessage = null;
errorMessage = null;
try {
branding = withBrandingDefaults(await saveBranding(admin.accessToken, branding));
// Push it into the shared store so the theme changes under you straight away.
applyBranding(branding);
statusMessage = "Branding saved and applied.";
} catch (error) {
errorMessage = error instanceof ApiError ? error.message : "Could not save the branding.";
} finally {
isSaving = false;
}
}
</script>
<svelte:head>
<title>Organisation · Admin · PiStation</title>
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
<main class="mx-auto w-full max-w-4xl px-6 py-12">
<header class="mb-8 flex flex-wrap items-center gap-4">
<a
href="/admin"
aria-label="Back to kiosks"
class="flex h-10 w-10 items-center justify-center bg-surface-2 text-ink-1 hover:bg-surface-3"
>
<Icon icon="ph:arrow-left-bold" width="18" />
</a>
<div class="flex-1">
<h1 class="text-xl font-semibold tracking-tight">Organisation</h1>
<p class="text-sm text-ink-2">Branding shown on the public homepage</p>
</div>
<button
onclick={save}
disabled={isSaving || !branding}
class="flex items-center gap-2 bg-accent px-5 py-3 font-semibold text-white transition-colors hover:bg-accent-strong disabled:bg-surface-3 disabled:text-ink-2"
>
<Icon icon="ph:floppy-disk-bold" width="18" />
Save
</button>
</header>
{#if statusMessage}
<p class="mb-6 flex items-center gap-2 bg-success/15 px-4 py-3 text-sm text-success">
<Icon icon="ph:check-circle-bold" width="18" class="shrink-0" />
{statusMessage}
</p>
{/if}
{#if errorMessage}
<p class="mb-6 flex items-center gap-2 bg-danger/15 px-4 py-3 text-sm text-danger">
<Icon icon="ph:warning-circle-bold" width="18" class="shrink-0" />
{errorMessage}
</p>
{/if}
{#if branding}
{@const current = branding}
<div class="flex flex-col gap-6">
<section class="flex flex-col gap-4 bg-surface-1 p-6">
<h2 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">Identity</h2>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Organisation name</span>
<input
value={current.name}
oninput={(event) => patch({ name: (event.target as HTMLInputElement).value })}
class="w-full bg-surface-2 px-4 py-3 text-ink-0"
/>
</label>
<div class="flex flex-wrap items-end gap-4">
<div class="flex items-center gap-3">
{#if logoPreview}
<img src={logoPreview} alt="Logo" class="h-14 w-14 object-contain" />
{:else}
<div class="flex h-14 w-14 items-center justify-center bg-surface-2 text-ink-2">
<Icon icon="ph:image-bold" width="22" />
</div>
{/if}
<input
bind:this={logoInput}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
onchange={handleLogo}
class="hidden"
/>
<button
onclick={() => logoInput?.click()}
disabled={isUploading}
class="flex items-center gap-2 bg-surface-2 px-4 py-2 text-sm text-ink-1 hover:bg-surface-3 disabled:text-ink-2"
>
{#if isUploading}
<Icon icon="ph:circle-notch-bold" width="16" class="animate-spin" />
Uploading
{:else}
<Icon icon="ph:upload-simple-bold" width="16" />
Upload logo
{/if}
</button>
{#if current.logoUrl}
<button
onclick={() => patch({ logoUrl: "" })}
class="px-3 py-2 text-sm text-danger hover:bg-surface-2"
>
Reset
</button>
{/if}
</div>
<label class="ml-auto flex items-center gap-3 text-sm text-ink-1">
Accent colour
<input
type="color"
value={current.accentColor}
oninput={(event) =>
patch({ accentColor: (event.target as HTMLInputElement).value })}
class="h-10 w-16 bg-surface-2"
/>
</label>
</div>
</section>
<section class="flex flex-col gap-4 bg-surface-1 p-6">
<h2 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">Homepage</h2>
<div class="flex flex-col gap-2">
<span class="text-xs text-ink-2">What visitors see at the root address</span>
<div class="flex gap-px">
{#each LANDING_OPTIONS as option}
<button
onclick={() => patch({ landingMode: option.value })}
class="flex-1 px-4 py-3 text-left transition-colors"
class:bg-accent={current.landingMode === option.value}
class:text-white={current.landingMode === option.value}
class:bg-surface-2={current.landingMode !== option.value}
class:text-ink-1={current.landingMode !== option.value}
>
<span class="block text-sm font-medium">{option.label}</span>
<span class="block text-xs opacity-70">{option.description}</span>
</button>
{/each}
</div>
</div>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Headline</span>
<input
value={current.headline}
oninput={(event) => patch({ headline: (event.target as HTMLInputElement).value })}
class="w-full bg-surface-2 px-4 py-3 text-ink-0"
/>
</label>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Description</span>
<textarea
value={current.description}
rows="3"
oninput={(event) =>
patch({ description: (event.target as HTMLTextAreaElement).value })}
class="w-full bg-surface-2 px-4 py-3 text-ink-0"
></textarea>
</label>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Label above the join code</span>
<input
value={current.joinLabel}
oninput={(event) => patch({ joinLabel: (event.target as HTMLInputElement).value })}
class="w-full bg-surface-2 px-4 py-3 text-ink-0"
/>
</label>
</section>
<section class="flex flex-col gap-4 bg-surface-1 p-6">
<div class="flex items-center gap-3">
<h2 class="flex-1 text-sm font-semibold tracking-wide text-ink-2 uppercase">Theme</h2>
<button
onclick={() => patch({ theme: { ...DEFAULT_THEME } })}
class="bg-surface-2 px-3 py-2 text-sm text-ink-1 hover:bg-surface-3"
>
Reset
</button>
</div>
<p class="text-sm text-ink-2">
These colours apply across the whole website, including the join screen and the admin
panel.
</p>
<div class="grid gap-3 sm:grid-cols-2">
{#each THEME_FIELDS as field}
<label class="flex items-center justify-between gap-3 bg-surface-2 px-4 py-3">
<span class="text-sm text-ink-1">{field.label}</span>
<input
type="color"
value={current.theme[field.key]}
oninput={(event) =>
patch({
theme: {
...current.theme,
[field.key]: (event.target as HTMLInputElement).value
}
})}
class="h-9 w-16 bg-surface-1"
/>
</label>
{/each}
</div>
</section>
<section class="flex flex-col gap-4 bg-surface-1 p-6">
<div class="flex items-center gap-3">
<h2 class="flex-1 text-sm font-semibold tracking-wide text-ink-2 uppercase">Footer</h2>
<button
onclick={addLink}
class="flex items-center gap-2 bg-surface-2 px-3 py-2 text-sm text-ink-1 hover:bg-surface-3"
>
<Icon icon="ph:plus-bold" width="16" />
Add link
</button>
</div>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Footer note</span>
<input
value={current.footerNote}
placeholder="Internal use only, contact IT for help"
oninput={(event) => patch({ footerNote: (event.target as HTMLInputElement).value })}
class="w-full bg-surface-2 px-4 py-3 text-ink-0 placeholder:text-ink-2"
/>
</label>
{#each current.links as link (link.linkId)}
<div class="flex flex-wrap items-center gap-2">
<input
value={link.label}
placeholder="Label"
oninput={(event) =>
updateLink(link.linkId, { label: (event.target as HTMLInputElement).value })}
class="min-w-32 flex-1 bg-surface-2 px-3 py-2 text-sm"
/>
<input
value={link.url}
placeholder="https://"
oninput={(event) =>
updateLink(link.linkId, { url: (event.target as HTMLInputElement).value })}
class="min-w-48 flex-2 bg-surface-2 px-3 py-2 text-sm"
/>
<button
onclick={() => removeLink(link.linkId)}
aria-label="Remove link"
class="flex h-9 w-9 items-center justify-center bg-surface-2 text-danger hover:bg-surface-3"
>
<Icon icon="ph:trash-bold" width="16" />
</button>
</div>
{/each}
<label class="flex items-center gap-3 text-sm text-ink-1">
<input
type="checkbox"
checked={current.showSourceLink}
onchange={(event) =>
patch({ showSourceLink: (event.target as HTMLInputElement).checked })}
/>
Show the source link in the footer
</label>
</section>
</div>
{/if}
</main>
@@ -0,0 +1,298 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onDestroy, onMount } from "svelte";
import { goto } from "$app/navigation";
import { activeAuthors } from "@pistation/client-core/annotations";
import { AnnotationOverlay, Logo, TrackVideo, WhiteboardCanvas } from "@pistation/ui";
import AnnotationToolbar from "$lib/components/AnnotationToolbar.svelte";
import ConnectionBadge from "$lib/components/ConnectionBadge.svelte";
import ParticipantMenu from "$lib/components/ParticipantMenu.svelte";
import { RoomController } from "$lib/room.svelte";
import { clearSession, loadSession } from "$lib/session";
let controller = $state<RoomController | null>(null);
let isAnnotating = $state(false);
const participantLabels = $derived(
new Map((controller?.participants ?? []).map((p) => [p.participantId, p.displayName]))
);
const screenBlocked = $derived(
Boolean(controller && !controller.isSharing && !controller.canStartScreenShare)
);
const cameraBlocked = $derived(
Boolean(controller && !controller.isCameraOn && !controller.canStartCamera)
);
const cameraTiles = $derived.by(() => {
if (!controller) return [];
const remote = controller.cameraFeeds.map((feed) => ({
key: feed.participantId,
label: feed.displayName,
track: feed.track
}));
if (!controller.localCameraTrack) return remote;
return [
{ key: "self", label: "You", track: controller.localCameraTrack },
...remote
];
});
const drawingNow = $derived.by(() => {
if (!controller) return [];
return activeAuthors(controller.annotations)
.filter((authorId) => authorId !== controller?.participantId)
.map((authorId) => participantLabels.get(authorId) ?? "Someone");
});
onMount(() => {
const session = loadSession();
if (!session) {
void goto("/");
return;
}
const instance = new RoomController(session);
controller = instance;
void instance.connect();
});
onDestroy(() => {
void controller?.disconnect();
});
async function leave() {
await controller?.disconnect();
clearSession();
await goto("/");
}
function toggleShare() {
if (!controller) return;
if (controller.isSharing) {
void controller.stopScreenShare();
} else {
void controller.startScreenShare();
}
}
function toggleCamera() {
if (!controller) return;
if (controller.isCameraOn) {
void controller.stopCamera();
} else {
void controller.startCamera();
}
}
function toggleWhiteboard() {
if (!controller) return;
controller.setMode(controller.mode === "whiteboard" ? "idle" : "whiteboard");
}
</script>
<svelte:head>
<title>{controller ? `${controller.kioskName} · PiStation` : "Room · PiStation"}</title>
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
{#if controller}
{@const current = controller}
<div class="flex h-screen flex-col">
<header class="flex shrink-0 flex-wrap items-center gap-3 bg-surface-1 px-4 py-3">
<div class="flex items-center gap-3">
<Logo size={32} />
<div>
<p class="text-sm font-semibold">{current.kioskName}</p>
<p class="text-xs text-ink-2">
{current.role === "presenter" ? "Presenting" : "Viewing"}
</p>
</div>
</div>
<ConnectionBadge status={current.status} />
<ParticipantMenu participants={current.participants} />
<div class="ml-auto flex flex-wrap items-center gap-2">
<button
onclick={toggleShare}
disabled={screenBlocked}
title={screenBlocked ? (current.blockedReason ?? "Turn your camera off first") : undefined}
class="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors disabled:bg-surface-2 disabled:text-ink-2"
class:bg-danger={current.isSharing}
class:text-white={current.isSharing}
class:bg-accent={!current.isSharing && !screenBlocked}
class:hover:bg-accent-strong={!current.isSharing && !screenBlocked}
>
<Icon icon={current.isSharing ? "ph:stop-circle-bold" : "ph:broadcast-bold"} width="18" />
{current.isSharing ? "Stop sharing" : "Share screen"}
</button>
<button
onclick={toggleCamera}
disabled={cameraBlocked}
title={cameraBlocked
? (current.blockedReason ?? "Stop sharing your screen first")
: undefined}
class="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors disabled:bg-surface-2 disabled:text-ink-2"
class:bg-danger={current.isCameraOn}
class:text-white={current.isCameraOn}
class:bg-surface-2={!current.isCameraOn && cameraBlocked}
class:bg-accent={!current.isCameraOn && !cameraBlocked}
class:hover:bg-accent-strong={!current.isCameraOn && !cameraBlocked}
>
<Icon
icon={current.isCameraOn ? "ph:video-camera-slash-bold" : "ph:video-camera-bold"}
width="18"
/>
{current.isCameraOn ? "Stop camera" : "Camera"}
</button>
{#if current.isCameraOn}
<button
onclick={() => current.flipCamera()}
aria-label="Switch camera"
title="Switch camera"
class="flex h-9 w-9 items-center justify-center bg-surface-2 text-ink-1 transition-colors hover:bg-surface-3"
>
<Icon icon="ph:arrows-clockwise-bold" width="16" />
</button>
{/if}
<button
onclick={toggleWhiteboard}
class="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors"
class:bg-accent={current.mode === "whiteboard"}
class:text-white={current.mode === "whiteboard"}
class:bg-surface-2={current.mode !== "whiteboard"}
class:text-ink-1={current.mode !== "whiteboard"}
class:hover:bg-surface-3={current.mode !== "whiteboard"}
>
<Icon icon="ph:scribble-loop-bold" width="18" />
Whiteboard
</button>
<button
onclick={() => (isAnnotating = !isAnnotating)}
disabled={current.mode === "whiteboard"}
class="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors disabled:bg-surface-2 disabled:text-ink-2"
class:bg-accent={isAnnotating && current.mode !== "whiteboard"}
class:text-white={isAnnotating && current.mode !== "whiteboard"}
class:bg-surface-2={!isAnnotating}
class:text-ink-1={!isAnnotating}
>
<Icon icon="ph:pencil-simple-bold" width="18" />
Annotate
</button>
<button
onclick={leave}
class="flex items-center gap-2 bg-surface-2 px-4 py-2 text-sm font-medium text-danger transition-colors hover:bg-surface-3"
>
<Icon icon="ph:sign-out-bold" width="18" />
Leave
</button>
</div>
</header>
{#if current.errorMessage}
<p class="flex items-center gap-2 bg-danger/15 px-4 py-2 text-sm text-danger">
<Icon icon="ph:warning-circle-bold" width="16" />
{current.errorMessage}
</p>
{/if}
<div class="flex min-h-0 flex-1">
<main class="flex min-w-0 flex-1 flex-col">
<div class="relative min-h-0 flex-1 bg-surface-0">
{#if current.mode === "whiteboard"}
<WhiteboardCanvas
elements={current.whiteboardElements}
onLocalChange={(changed) => current.pushWhiteboardElements(changed)}
/>
{:else if current.localScreenTrack}
<TrackVideo track={current.localScreenTrack} />
<span
class="absolute top-4 left-4 flex items-center gap-2 bg-surface-0/80 px-3 py-1.5 text-sm text-ink-1"
>
<Icon icon="ph:broadcast-bold" width="14" class="text-accent" />
You are sharing this screen
</span>
<AnnotationOverlay
annotations={current.annotations}
labels={participantLabels}
interactive={isAnnotating}
onStrokeStart={(point) => current.beginStroke(point)}
onStrokeExtend={(point) => current.extendStroke(point)}
onStrokeEnd={() => current.endStroke()}
onPointerMove={(point) => current.movePointer(point)}
/>
{:else if current.screenTrack}
<TrackVideo track={current.screenTrack} />
<AnnotationOverlay
annotations={current.annotations}
labels={participantLabels}
interactive={isAnnotating}
onStrokeStart={(point) => current.beginStroke(point)}
onStrokeExtend={(point) => current.extendStroke(point)}
onStrokeEnd={() => current.endStroke()}
onPointerMove={(point) => current.movePointer(point)}
/>
{:else if cameraTiles.length > 0}
<div
class="grid h-full gap-px p-px"
class:grid-cols-1={cameraTiles.length === 1}
class:grid-cols-2={cameraTiles.length > 1}
>
{#each cameraTiles as tile (tile.key)}
<div class="relative min-h-0 bg-black">
<TrackVideo track={tile.track} />
<span
class="absolute bottom-3 left-3 flex items-center gap-2 bg-surface-0/80 px-3 py-1.5 text-sm text-ink-1"
>
<Icon icon="ph:video-camera-bold" width="14" class="text-accent" />
{tile.label}
</span>
</div>
{/each}
</div>
{:else}
<div class="flex h-full flex-col items-center justify-center gap-4 text-center">
<Icon icon="ph:monitor-bold" width="48" class="text-ink-2" />
<div>
<p class="text-lg font-medium">Nothing on screen yet</p>
<p class="text-sm text-ink-2">
Share your screen, turn on a camera, or open the whiteboard to begin.
</p>
</div>
</div>
{/if}
</div>
{#if drawingNow.length > 0}
<div
class="pointer-events-none absolute bottom-20 left-4 flex items-center gap-2 bg-surface-0/85 px-3 py-2 text-sm text-ink-1"
>
<Icon icon="ph:pencil-simple-bold" width="14" class="text-accent" />
{drawingNow.join(", ")}
{drawingNow.length === 1 ? "is" : "are"} drawing
</div>
{/if}
{#if isAnnotating && current.mode !== "whiteboard"}
<AnnotationToolbar controller={current} />
{/if}
</main>
</div>
</div>
{:else}
<div class="flex h-screen items-center justify-center">
<Icon icon="ph:circle-notch-bold" width="32" class="animate-spin text-ink-2" />
</div>
{/if}