Add web client

Join screen, room with screen and camera sharing, live annotation,
whiteboard, and the admin panel for kiosks, widgets and branding.
This commit is contained in:
2026-08-09 17:09:16 -04:00
parent a4b033b78e
commit 18384c0cbc
37 changed files with 5074 additions and 0 deletions
+64
View File
@@ -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);
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/logo.svg" type="image/svg+xml" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="h-full">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+10
View File
@@ -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."
};
};
+234
View File
@@ -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<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers: Record<string, string> = {};
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<JoinResponse> {
return request<JoinResponse>("/api/join", {
method: "POST",
body: { pin, displayName }
});
}
export function refreshSession(sessionId: string): Promise<SessionRefreshResponse> {
return request<SessionRefreshResponse>("/api/session/refresh", {
method: "POST",
body: { sessionId }
});
}
export function adminLogin(email: string, password: string): Promise<AdminLoginResponse> {
return request<AdminLoginResponse>("/api/admin/login", {
method: "POST",
body: { email, password }
});
}
export function listKiosks(token: string): Promise<KioskListResponse> {
return request<KioskListResponse>("/api/admin/kiosks", { token });
}
export function createKiosk(
token: string,
name: string,
location: string
): Promise<CreateKioskResponse> {
return request<CreateKioskResponse>("/api/admin/kiosks", {
method: "POST",
body: { name, location },
token
});
}
export function getKiosk(token: string, kioskId: string): Promise<KioskDetailResponse> {
return request<KioskDetailResponse>(`/api/admin/kiosks/${kioskId}`, { token });
}
export function getBranding(): Promise<OrganizationBranding> {
return request<OrganizationBranding>("/api/organization");
}
export function saveBranding(
token: string,
branding: OrganizationBranding
): Promise<OrganizationBranding> {
return request<OrganizationBranding>("/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<Kiosk> {
return request<Kiosk>(`/api/admin/kiosks/${kioskId}`, {
method: "PATCH",
body: { name, location },
token
});
}
export function deleteKiosk(token: string, kioskId: string): Promise<void> {
return request<void>(`/api/admin/kiosks/${kioskId}`, { method: "DELETE", token });
}
export function saveKioskLayout(
token: string,
kioskId: string,
layout: unknown
): Promise<unknown> {
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 });
}
@@ -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<void> {
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);
}
@@ -0,0 +1,144 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { DEFAULT_STROKE_STYLE, HIGHLIGHTER_STYLE } from "@pistation/shared-types";
import type { RoomController } from "$lib/room.svelte";
let { controller }: { controller: RoomController } = $props();
const tools = [
{ id: "pen", icon: "ph:pencil-simple-bold", label: "Pen" },
{ id: "highlighter", icon: "ph:highlighter-bold", label: "Highlighter" },
{ id: "arrow", icon: "ph:arrow-up-right-bold", label: "Arrow" },
{ id: "rectangle", icon: "ph:rectangle-bold", label: "Rectangle" },
{ id: "ellipse", icon: "ph:circle-bold", label: "Ellipse" },
{ id: "laser", icon: "ph:cursor-bold", label: "Laser" }
] as const;
const colors = ["#ff2d55", "#ffd60a", "#21c17a", "#4f7cff", "#f4f6f8"];
function isToolActive(tool: string): boolean {
return controller.tool === tool && !controller.isEraser && !controller.isPointerMode;
}
const thicknessValue = $derived(Math.round(controller.strokeStyle.width * 1000));
const thicknessPreview = $derived(
Math.max(4, Math.min(22, Math.round(controller.strokeStyle.width * 1000) + 3))
);
function selectTool(tool: (typeof tools)[number]["id"]) {
controller.isEraser = false;
controller.isPointerMode = false;
controller.tool = tool;
// Switching tools keeps the colour and the thickness the user picked. Only the
// highlighter overrides them, because a thin opaque highlighter is useless.
controller.strokeStyle =
tool === "highlighter"
? { ...HIGHLIGHTER_STYLE }
: {
...DEFAULT_STROKE_STYLE,
color: controller.strokeStyle.color,
width: controller.strokeStyle.width
};
}
</script>
<div class="flex flex-wrap items-center gap-1 bg-surface-2 p-2">
{#each tools as tool}
<button
onclick={() => selectTool(tool.id)}
title={tool.label}
aria-label={tool.label}
class="flex h-10 w-10 items-center justify-center transition-colors"
class:bg-accent={isToolActive(tool.id)}
class:text-white={isToolActive(tool.id)}
class:text-ink-1={!isToolActive(tool.id)}
class:hover:bg-surface-3={!isToolActive(tool.id)}
>
<Icon icon={tool.icon} width="18" />
</button>
{/each}
<button
onclick={() => {
controller.isPointerMode = !controller.isPointerMode;
if (controller.isPointerMode) controller.isEraser = false;
}}
title="Pointer, others see where you are pointing"
aria-label="Pointer"
class="flex h-10 w-10 items-center justify-center transition-colors"
class:bg-accent={controller.isPointerMode}
class:text-white={controller.isPointerMode}
class:text-ink-1={!controller.isPointerMode}
class:hover:bg-surface-3={!controller.isPointerMode}
>
<Icon icon="ph:hand-pointing-bold" width="18" />
</button>
<button
onclick={() => {
controller.isEraser = !controller.isEraser;
if (controller.isEraser) controller.isPointerMode = false;
}}
title="Eraser"
aria-label="Eraser"
class="flex h-10 w-10 items-center justify-center transition-colors"
class:bg-accent={controller.isEraser}
class:text-white={controller.isEraser}
class:text-ink-1={!controller.isEraser}
class:hover:bg-surface-3={!controller.isEraser}
>
<Icon icon="ph:eraser-bold" width="18" />
</button>
<div class="mx-1 h-8 w-px bg-surface-3"></div>
<label class="flex items-center gap-2 px-2" title="Line thickness">
<span class="text-xs tracking-wide text-ink-2 uppercase">Thickness</span>
<input
type="range"
min="1"
max="30"
value={thicknessValue}
oninput={(event) =>
(controller.strokeStyle = {
...controller.strokeStyle,
width: Number((event.target as HTMLInputElement).value) / 1000
})}
class="w-28"
aria-label="Line thickness"
/>
<span class="w-6 text-right font-mono text-xs text-ink-1">{thicknessValue}</span>
<span
class="shrink-0 rounded-full bg-current"
style={`width: ${thicknessPreview}px; height: ${thicknessPreview}px; color: ${controller.strokeStyle.color};`}
></span>
</label>
<div class="mx-1 h-8 w-px bg-surface-3"></div>
{#each colors as color}
<button
onclick={() => (controller.strokeStyle = { ...controller.strokeStyle, color })}
title={`Colour ${color}`}
aria-label={`Colour ${color}`}
class="h-8 w-8 transition-transform"
class:scale-90={controller.strokeStyle.color !== color}
style={`background-color: ${color}`}
></button>
{/each}
<div class="mx-1 h-8 w-px bg-surface-3"></div>
<button
onclick={() => controller.clearAnnotations()}
title="Clear annotations"
aria-label="Clear annotations"
class="flex h-10 items-center gap-2 px-3 text-sm text-ink-1 transition-colors hover:bg-surface-3"
>
<Icon icon="ph:trash-bold" width="18" />
Clear
</button>
</div>
@@ -0,0 +1,27 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { ConnectionStatus } from "@pistation/client-core";
let { status }: { status: ConnectionStatus } = $props();
const presentation = {
idle: { label: "Idle", icon: "ph:circle-bold", color: "text-ink-2" },
connecting: { label: "Connecting", icon: "ph:circle-notch-bold", color: "text-ink-1" },
connected: { label: "Live", icon: "ph:circle-fill", color: "text-success" },
reconnecting: { label: "Reconnecting", icon: "ph:circle-notch-bold", color: "text-accent" },
disconnected: { label: "Offline", icon: "ph:circle-fill", color: "text-danger" },
failed: { label: "Failed", icon: "ph:warning-circle-bold", color: "text-danger" }
} as const;
const current = $derived(presentation[status]);
const isSpinning = $derived(status === "connecting" || status === "reconnecting");
</script>
<div class="flex items-center gap-2 bg-surface-2 px-3 py-2 text-sm">
<Icon
icon={current.icon}
width="12"
class={`${current.color} ${isSpinning ? "animate-spin" : ""}`}
/>
<span class="text-ink-1">{current.label}</span>
</div>
@@ -0,0 +1,81 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { PIN_LENGTH } from "@pistation/shared-types";
import { Logo } from "@pistation/ui";
import { goto } from "$app/navigation";
import { ApiError, joinRoom } from "$lib/api";
import PinInput from "$lib/components/PinInput.svelte";
import { saveSession } from "$lib/session";
let { label = "Enter the code on screen" }: { label?: string } = $props();
let pin = $state("");
let displayName = $state("");
let isJoining = $state(false);
let errorMessage = $state<string | null>(null);
const isReady = $derived(
pin.replace(/\D/g, "").length === PIN_LENGTH && displayName.trim().length > 0
);
async function join() {
if (!isReady || isJoining) return;
isJoining = true;
errorMessage = null;
try {
const session = await joinRoom(pin, displayName);
saveSession(session);
await goto("/room");
} catch (error) {
errorMessage =
error instanceof ApiError ? error.message : "Could not join. Check the PIN and try again.";
isJoining = false;
}
}
</script>
<div class="mx-auto flex w-full max-w-md flex-col bg-surface-1 p-6 sm:p-8">
<Logo size={40} class="mx-auto mb-4" />
<p class="mb-1 text-center text-sm tracking-wide text-ink-2 uppercase">{label}</p>
<p class="mb-6 text-center text-sm text-ink-2">It rotates every minute</p>
<PinInput bind:value={pin} onComplete={join} />
<label class="mt-6 block">
<span class="mb-2 block text-sm font-medium text-ink-1">
Your name
<span class="text-ink-2">required</span>
</span>
<input
bind:value={displayName}
placeholder="So the room knows who you are"
maxlength="32"
required
class="w-full bg-surface-2 px-4 py-3 text-ink-0 placeholder:text-ink-2"
/>
</label>
{#if errorMessage}
<p class="mt-4 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}
<button
onclick={join}
disabled={!isReady || isJoining}
class="mt-5 flex w-full items-center justify-center gap-2 bg-accent px-6 py-4 text-base font-semibold text-white transition-colors hover:bg-accent-strong disabled:bg-surface-3 disabled:text-ink-2"
>
{#if isJoining}
<Icon icon="ph:circle-notch-bold" width="20" class="animate-spin" />
Connecting
{:else}
<Icon icon="ph:sign-in-bold" width="20" />
Join screen
{/if}
</button>
</div>
@@ -0,0 +1,62 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { RoomParticipant } from "$lib/room.svelte";
let { participants }: { participants: RoomParticipant[] } = $props();
let isOpen = $state(false);
let container = $state<HTMLDivElement | null>(null);
$effect(() => {
if (!isOpen) return;
const close = (event: MouseEvent) => {
if (container && !container.contains(event.target as Node)) isOpen = false;
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
});
</script>
<div bind:this={container} class="relative">
<button
onclick={() => (isOpen = !isOpen)}
aria-expanded={isOpen}
class="flex items-center gap-2 bg-surface-2 px-3 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3"
>
<Icon icon="ph:users-bold" width="16" />
{participants.length}
<Icon icon={isOpen ? "ph:caret-up-bold" : "ph:caret-down-bold"} width="12" />
</button>
{#if isOpen}
<div class="absolute top-full right-0 z-20 mt-px w-64 bg-surface-1 shadow-lg">
<p class="bg-surface-2 px-4 py-2 text-xs tracking-wide text-ink-2 uppercase">In this room</p>
<ul class="max-h-72 overflow-y-auto">
{#each participants as participant (participant.participantId)}
{@const isBroadcasting = participant.isSharing || participant.isCameraOn}
<li class="flex items-center gap-2 px-4 py-2.5 text-sm">
<Icon
icon={participant.isSharing
? "ph:broadcast-bold"
: participant.isCameraOn
? "ph:video-camera-bold"
: "ph:user-bold"}
width="16"
class={isBroadcasting ? "shrink-0 text-accent" : "shrink-0 text-ink-2"}
/>
<span class="min-w-0 flex-1 truncate text-ink-0">
{participant.displayName}
</span>
{#if participant.isSelf}
<span class="shrink-0 text-xs text-ink-2">you</span>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
</div>
@@ -0,0 +1,73 @@
<script lang="ts">
import { PIN_LENGTH } from "@pistation/shared-types";
let { value = $bindable(""), onComplete }: {
value?: string;
onComplete?: (pin: string) => void;
} = $props();
let inputs: HTMLInputElement[] = $state([]);
const slots = $derived(
Array.from({ length: PIN_LENGTH }, (_, index) => value[index] ?? "")
);
function setDigit(index: number, digit: string) {
const characters = value.padEnd(PIN_LENGTH, " ").split("");
characters[index] = digit;
value = characters.join("").trimEnd();
if (digit && index < PIN_LENGTH - 1) {
inputs[index + 1]?.focus();
}
if (value.replace(/\s/g, "").length === PIN_LENGTH) {
onComplete?.(value);
}
}
function handleInput(index: number, event: Event) {
const target = event.target as HTMLInputElement;
const digit = target.value.replace(/\D/g, "").slice(-1);
target.value = digit;
setDigit(index, digit);
}
function handleKeydown(index: number, event: KeyboardEvent) {
if (event.key === "Backspace" && !slots[index] && index > 0) {
inputs[index - 1]?.focus();
setDigit(index - 1, "");
event.preventDefault();
}
if (event.key === "ArrowLeft" && index > 0) inputs[index - 1]?.focus();
if (event.key === "ArrowRight" && index < PIN_LENGTH - 1) inputs[index + 1]?.focus();
}
function handlePaste(event: ClipboardEvent) {
const pasted = event.clipboardData?.getData("text")?.replace(/\D/g, "") ?? "";
if (!pasted) return;
event.preventDefault();
value = pasted.slice(0, PIN_LENGTH);
inputs[Math.min(value.length, PIN_LENGTH - 1)]?.focus();
if (value.length === PIN_LENGTH) onComplete?.(value);
}
</script>
<div class="flex w-full flex-nowrap gap-1.5 sm:gap-2" onpaste={handlePaste}>
{#each slots as digit, index}
<input
bind:this={inputs[index]}
value={digit}
inputmode="numeric"
autocomplete="one-time-code"
maxlength="1"
aria-label={`PIN digit ${index + 1}`}
class="h-16 min-w-0 flex-1 bg-surface-2 px-0 text-center font-mono text-2xl text-ink-0 transition-colors focus:bg-surface-3 sm:h-20 sm:text-4xl"
oninput={(event) => handleInput(index, event)}
onkeydown={(event) => handleKeydown(index, event)}
onfocus={(event) => (event.target as HTMLInputElement).select()}
/>
{/each}
</div>
@@ -0,0 +1,197 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { KioskLayout } from "@pistation/shared-types";
import { MIN_WALLPAPER_ROTATION_SECONDS, resolveMediaUrl } from "@pistation/shared-types";
import { apiBaseUrl } from "$lib/api";
let {
layout,
onChange,
onUpload,
isUploading = false,
uploadError = null
}: {
layout: KioskLayout;
onChange: (layout: KioskLayout) => void;
onUpload: (files: File[]) => void;
isUploading?: boolean;
uploadError?: string | null;
} = $props();
let fileInput = $state<HTMLInputElement | null>(null);
const images = $derived(layout.background.images ?? []);
function patchBackground(patch: Partial<KioskLayout["background"]>) {
onChange({ ...layout, background: { ...layout.background, ...patch } });
}
function removeImage(image: string) {
patchBackground({ images: images.filter((candidate) => candidate !== image) });
}
function handleFile(event: Event) {
const input = event.target as HTMLInputElement;
const files = [...(input.files ?? [])];
if (files.length > 0) onUpload(files);
input.value = "";
}
</script>
<div class="flex flex-col gap-4 bg-surface-1 p-5">
<h3 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">Appearance</h3>
<label class="flex items-center justify-between gap-3 text-sm text-ink-1">
Background colour
<input
type="color"
value={layout.backgroundColor}
oninput={(event) =>
onChange({ ...layout, backgroundColor: (event.target as HTMLInputElement).value })}
class="h-9 w-16 bg-surface-2"
/>
</label>
<label class="flex items-center justify-between gap-3 text-sm text-ink-1">
Text colour
<input
type="color"
value={layout.foregroundColor}
oninput={(event) =>
onChange({ ...layout, foregroundColor: (event.target as HTMLInputElement).value })}
class="h-9 w-16 bg-surface-2"
/>
</label>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">
Widget panel opacity {Math.round((layout.widgetOpacity ?? 0.5) * 100)} percent
</span>
<input
type="range"
min="0"
max="100"
value={(layout.widgetOpacity ?? 0.5) * 100}
oninput={(event) =>
onChange({
...layout,
widgetOpacity: Number((event.target as HTMLInputElement).value) / 100
})}
class="w-full"
/>
</label>
<div class="flex flex-col gap-3 bg-surface-2 p-4">
<div class="flex items-center gap-2">
<p class="flex-1 text-xs tracking-wide text-ink-2 uppercase">Wallpapers</p>
{#if images.length > 0}
<span class="text-xs text-ink-2">{images.length}</span>
{/if}
</div>
{#if images.length === 0}
<p class="bg-surface-1 px-4 py-6 text-center text-sm text-ink-2">No wallpaper set</p>
{:else}
<div class="grid grid-cols-3 gap-2">
{#each images as image, index (image)}
<div class="group relative">
<img
src={resolveMediaUrl(apiBaseUrl, image)}
alt={`Wallpaper ${index + 1}`}
class="h-16 w-full object-cover"
/>
<button
onclick={() => removeImage(image)}
aria-label={`Remove wallpaper ${index + 1}`}
class="absolute top-0 right-0 flex h-6 w-6 items-center justify-center bg-surface-0/80 text-danger opacity-0 transition-opacity group-hover:opacity-100"
>
<Icon icon="ph:x-bold" width="12" />
</button>
</div>
{/each}
</div>
{/if}
{#if uploadError}
<p class="bg-danger/15 px-3 py-2 text-sm text-danger">{uploadError}</p>
{/if}
<input
bind:this={fileInput}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
multiple
onchange={handleFile}
class="hidden"
/>
<button
onclick={() => fileInput?.click()}
disabled={isUploading}
class="flex items-center justify-center gap-2 bg-surface-1 px-4 py-2 text-sm text-ink-1 transition-colors 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" />
Add images
{/if}
</button>
<p class="text-xs text-ink-2">PNG, JPEG, WEBP or GIF, up to 8 MB each.</p>
{#if images.length > 1}
<label class="block">
<span class="mb-1 block text-xs text-ink-2">
Change every {layout.background.rotationSeconds ?? 60} seconds
</span>
<input
type="range"
min={MIN_WALLPAPER_ROTATION_SECONDS}
max="600"
step="5"
value={layout.background.rotationSeconds ?? 60}
oninput={(event) =>
patchBackground({
rotationSeconds: Number((event.target as HTMLInputElement).value)
})}
class="w-full"
/>
</label>
{/if}
{#if images.length > 0}
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Fit</span>
<select
value={layout.background.fit}
onchange={(event) =>
patchBackground({
fit: (event.target as HTMLSelectElement).value as "cover" | "contain"
})}
class="w-full bg-surface-1 px-3 py-2 text-sm"
>
<option value="cover">Cover</option>
<option value="contain">Contain</option>
</select>
</label>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">
Dim {Math.round(layout.background.dim * 100)} percent
</span>
<input
type="range"
min="0"
max="100"
value={layout.background.dim * 100}
oninput={(event) =>
patchBackground({ dim: Number((event.target as HTMLInputElement).value) / 100 })}
class="w-full"
/>
</label>
{/if}
</div>
</div>
@@ -0,0 +1,63 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { apiBaseUrl } from "$lib/api";
let {
enrollmentToken,
kioskName = ""
}: {
enrollmentToken: string;
kioskName?: string;
} = $props();
let hasCopied = $state(false);
const command = $derived(
`curl -fsSL ${apiBaseUrl}/install.sh | sudo bash -s -- --key ${enrollmentToken}`
);
async function copy() {
try {
await navigator.clipboard.writeText(command);
hasCopied = true;
setTimeout(() => (hasCopied = false), 2000);
} catch {
hasCopied = false;
}
}
</script>
<div class="flex min-w-0 flex-col gap-3 overflow-hidden bg-surface-1 p-5">
<div class="flex items-center gap-2">
<Icon icon="ph:terminal-window-bold" width="18" class="text-accent" />
<h3 class="flex-1 text-sm font-semibold tracking-wide uppercase">
Set up {kioskName || "this kiosk"}
</h3>
</div>
<p class="text-sm text-ink-2">
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.
</p>
<div class="flex min-w-0 items-stretch gap-px">
<code
class="min-w-0 flex-1 overflow-x-auto bg-surface-0 px-4 py-3 font-mono text-sm whitespace-pre text-accent"
>{command}</code
>
<button
onclick={copy}
aria-label="Copy install command"
class="flex w-12 shrink-0 items-center justify-center bg-surface-2 text-ink-1 transition-colors hover:bg-surface-3"
>
<Icon icon={hasCopied ? "ph:check-bold" : "ph:copy-bold"} width="18" />
</button>
</div>
<p class="text-xs break-words text-ink-2">
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.
</p>
</div>
@@ -0,0 +1,200 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { KioskMetrics } from "@pistation/shared-types";
import {
formatBytes,
formatUptime,
signalAdvice,
signalLabel,
signalPercent
} from "@pistation/shared-types";
let {
metrics,
metricsAt,
compact = false
}: {
metrics: KioskMetrics | null;
metricsAt: number | null;
compact?: boolean;
} = $props();
const SIGNAL_COLORS = {
excellent: "text-success",
good: "text-success",
weak: "text-accent",
poor: "text-danger"
} as const;
const SIGNAL_BARS = {
excellent: "bg-success",
good: "bg-success",
weak: "bg-accent",
poor: "bg-danger"
} as const;
const WIFI_ICONS = {
excellent: "ph:wifi-high-bold",
good: "ph:wifi-high-bold",
weak: "ph:wifi-medium-bold",
poor: "ph:wifi-low-bold"
} as const;
const STRENGTH_WORDS = {
excellent: "Excellent",
good: "Good",
weak: "Weak",
poor: "Poor"
} as const;
const memoryPercent = $derived(
metrics && metrics.memoryTotalBytes > 0
? (metrics.memoryUsedBytes / metrics.memoryTotalBytes) * 100
: null
);
const isStale = $derived(metricsAt !== null && Date.now() - metricsAt > 120_000);
function barColor(percent: number): string {
if (percent >= 90) return "bg-danger";
if (percent >= 70) return "bg-accent";
return "bg-success";
}
</script>
{#if !metrics}
<p class="text-sm text-ink-2">No stats reported yet.</p>
{:else if compact}
<div class="flex flex-wrap items-center gap-4 text-sm text-ink-2">
{#if metrics.cpuPercent !== null}
<span class="flex items-center gap-1.5">
<Icon icon="ph:cpu-bold" width="14" />
{metrics.cpuPercent.toFixed(0)}%
</span>
{/if}
{#if memoryPercent !== null}
<span class="flex items-center gap-1.5">
<Icon icon="ph:memory-bold" width="14" />
{memoryPercent.toFixed(0)}%
</span>
{/if}
{#if metrics.wifi}
{@const strength = signalLabel(metrics.wifi.signalDbm)}
<span
class={`flex items-center gap-1.5 ${SIGNAL_COLORS[strength]}`}
title={`${metrics.wifi.signalDbm.toFixed(0)} dBm on ${metrics.wifi.interface}`}
>
<Icon icon={WIFI_ICONS[strength]} width="14" />
{STRENGTH_WORDS[strength]}
</span>
{/if}
{#if metrics.temperatureCelsius !== null}
<span class="flex items-center gap-1.5">
<Icon icon="ph:thermometer-simple-bold" width="14" />
{metrics.temperatureCelsius.toFixed(0)}&deg;C
</span>
{/if}
</div>
{:else}
<div class="flex flex-col gap-4">
{#if isStale}
<p class="flex items-center gap-2 bg-surface-2 px-3 py-2 text-xs text-ink-2">
<Icon icon="ph:clock-countdown-bold" width="14" />
These figures are more than two minutes old.
</p>
{/if}
{#if metrics.cpuPercent !== null}
<div>
<div class="mb-1 flex items-baseline justify-between text-sm">
<span class="flex items-center gap-2 text-ink-1">
<Icon icon="ph:cpu-bold" width="16" />
CPU
</span>
<span class="text-ink-2">{metrics.cpuPercent.toFixed(0)}%</span>
</div>
<div class="h-1.5 w-full bg-surface-2">
<div
class={`h-full ${barColor(metrics.cpuPercent)}`}
style={`width: ${Math.min(100, metrics.cpuPercent)}%`}
></div>
</div>
</div>
{/if}
{#if memoryPercent !== null}
<div>
<div class="mb-1 flex items-baseline justify-between text-sm">
<span class="flex items-center gap-2 text-ink-1">
<Icon icon="ph:memory-bold" width="16" />
Memory
</span>
<span class="text-ink-2">
{formatBytes(metrics.memoryUsedBytes)} of {formatBytes(metrics.memoryTotalBytes)}
</span>
</div>
<div class="h-1.5 w-full bg-surface-2">
<div
class={`h-full ${barColor(memoryPercent)}`}
style={`width: ${Math.min(100, memoryPercent)}%`}
></div>
</div>
</div>
{/if}
{#if metrics.wifi}
{@const strength = signalLabel(metrics.wifi.signalDbm)}
{@const percent = signalPercent(metrics.wifi.signalDbm)}
<div>
<div class="mb-1 flex items-baseline justify-between text-sm">
<span class="flex items-center gap-2 text-ink-1">
<Icon icon={WIFI_ICONS[strength]} width="16" class={SIGNAL_COLORS[strength]} />
Wi-Fi
</span>
<span class={SIGNAL_COLORS[strength]}>
{STRENGTH_WORDS[strength]}
<span class="text-ink-2">{percent}%</span>
</span>
</div>
<div class="h-1.5 w-full bg-surface-2">
<div class={`h-full ${SIGNAL_BARS[strength]}`} style={`width: ${percent}%`}></div>
</div>
<p class="mt-1 text-xs text-ink-2">
{signalAdvice(metrics.wifi.signalDbm)} · {metrics.wifi.signalDbm.toFixed(0)} dBm on
{metrics.wifi.interface}
</p>
</div>
{:else}
<div class="flex items-baseline justify-between text-sm">
<span class="flex items-center gap-2 text-ink-1">
<Icon icon="ph:network-bold" width="16" />
Network
</span>
<span class="text-ink-2">Wired or no wireless adapter</span>
</div>
{/if}
{#if metrics.temperatureCelsius !== null}
<div class="flex items-baseline justify-between text-sm">
<span class="flex items-center gap-2 text-ink-1">
<Icon icon="ph:thermometer-simple-bold" width="16" />
Temperature
</span>
<span class={metrics.temperatureCelsius >= 75 ? "text-danger" : "text-ink-2"}>
{metrics.temperatureCelsius.toFixed(1)}&deg;C
</span>
</div>
{/if}
<div class="flex items-baseline justify-between text-sm">
<span class="flex items-center gap-2 text-ink-1">
<Icon icon="ph:timer-bold" width="16" />
Uptime
</span>
<span class="text-ink-2">{formatUptime(metrics.uptimeSeconds)}</span>
</div>
</div>
{/if}
@@ -0,0 +1,98 @@
<script lang="ts">
import type { KioskLayout, NightModeSettings } from "@pistation/shared-types";
import { isNightModeActive } from "@pistation/shared-types";
import TimeZoneField from "./TimeZoneField.svelte";
let {
layout,
onChange
}: {
layout: KioskLayout;
onChange: (layout: KioskLayout) => void;
} = $props();
const nightMode = $derived(layout.nightMode);
const isActiveNow = $derived(isNightModeActive(nightMode));
function patch(update: Partial<NightModeSettings>) {
onChange({ ...layout, nightMode: { ...layout.nightMode, ...update } });
}
</script>
<div class="flex flex-col gap-4 bg-surface-1 p-5">
<div class="flex items-center gap-3">
<h3 class="flex-1 text-sm font-semibold tracking-wide text-ink-2 uppercase">Night mode</h3>
{#if nightMode.enabled && isActiveNow}
<span class="bg-accent px-2 py-1 text-xs font-medium text-white">Active now</span>
{/if}
</div>
<p class="text-sm text-ink-2">
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.
</p>
<label class="flex items-center gap-3 text-sm text-ink-1">
<input
type="checkbox"
checked={nightMode.enabled}
onchange={(event) => patch({ enabled: (event.target as HTMLInputElement).checked })}
/>
Enable night mode
</label>
{#if nightMode.enabled}
<div class="grid grid-cols-2 gap-3">
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Starts</span>
<input
type="time"
value={nightMode.startTime}
oninput={(event) => patch({ startTime: (event.target as HTMLInputElement).value })}
class="w-full bg-surface-2 px-3 py-2 text-sm"
/>
</label>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Ends</span>
<input
type="time"
value={nightMode.endTime}
oninput={(event) => patch({ endTime: (event.target as HTMLInputElement).value })}
class="w-full bg-surface-2 px-3 py-2 text-sm"
/>
</label>
</div>
<TimeZoneField
value={nightMode.timeZone}
emptyLabel="Kiosk clock"
onChange={(timeZone) => patch({ timeZone })}
/>
<label class="flex items-center gap-3 text-sm text-ink-1">
<input
type="checkbox"
checked={nightMode.showPin}
onchange={(event) => patch({ showPin: (event.target as HTMLInputElement).checked })}
/>
Show the PIN at night
</label>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">
Brightness {Math.round(nightMode.brightness * 100)} percent
</span>
<input
type="range"
min="5"
max="100"
value={nightMode.brightness * 100}
oninput={(event) =>
patch({ brightness: Number((event.target as HTMLInputElement).value) / 100 })}
class="w-full"
/>
</label>
{/if}
</div>
@@ -0,0 +1,77 @@
<script lang="ts">
import { isValidTimeZone, listTimeZones, localTimeZone } from "@pistation/shared-types";
let {
value,
label = "Time zone",
emptyLabel = "Device clock",
onChange
}: {
value: string;
label?: string;
emptyLabel?: string;
onChange: (value: string) => void;
} = $props();
const zones = listTimeZones();
const groups = $derived.by(() => {
const byRegion = new Map<string, string[]>();
for (const zone of zones) {
const region = zone.includes("/") ? zone.split("/")[0] : "Other";
const existing = byRegion.get(region);
if (existing) existing.push(zone);
else byRegion.set(region, [zone]);
}
return [...byRegion.entries()].sort((a, b) => a[0].localeCompare(b[0]));
});
const isKnown = $derived(!value || zones.includes(value));
const preview = $derived.by(() => {
if (value && !isValidTimeZone(value)) return null;
try {
return new Date().toLocaleTimeString([], {
timeZone: value || undefined,
hour: "2-digit",
minute: "2-digit"
});
} catch {
return null;
}
});
function labelFor(zone: string): string {
return zone.includes("/") ? zone.split("/").slice(1).join("/").replace(/_/g, " ") : zone;
}
</script>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">{label}</span>
<select
{value}
onchange={(event) => onChange((event.target as HTMLSelectElement).value)}
class="w-full bg-surface-2 px-3 py-2 text-sm text-ink-0"
>
<option value="">{emptyLabel} ({localTimeZone()})</option>
{#if !isKnown}
<option value={value}>{value}</option>
{/if}
{#each groups as [region, regionZones]}
<optgroup label={region}>
{#each regionZones as zone}
<option value={zone}>{labelFor(zone)}</option>
{/each}
</optgroup>
{/each}
</select>
{#if preview}
<span class="mt-1 block text-xs text-ink-2">Currently {preview} there</span>
{/if}
</label>
@@ -0,0 +1,322 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { CustomWidgetDefinition, WidgetBlock } from "@pistation/shared-types";
import { createEmptyDefinition, createId, WIDGET_BLOCK_KINDS } from "@pistation/shared-types";
let {
definitions,
onChange
}: {
definitions: CustomWidgetDefinition[];
onChange: (definitions: CustomWidgetDefinition[]) => void;
} = $props();
let selectedId = $state<string | null>(null);
const selected = $derived(
definitions.find((definition) => definition.definitionId === selectedId) ?? null
);
const makeId = createId;
function addDefinition() {
const definition = createEmptyDefinition(makeId("def"));
onChange([...definitions, definition]);
selectedId = definition.definitionId;
}
function updateDefinition(patch: Partial<CustomWidgetDefinition>) {
if (!selected) return;
onChange(
definitions.map((definition) =>
definition.definitionId === selected.definitionId
? { ...definition, ...patch, updatedAt: Date.now() }
: definition
)
);
}
function removeDefinition(definitionId: string) {
onChange(definitions.filter((definition) => definition.definitionId !== definitionId));
if (selectedId === definitionId) selectedId = null;
}
function addBlock(kind: (typeof WIDGET_BLOCK_KINDS)[number]) {
if (!selected) return;
const blockId = makeId("block");
const block = {
heading: { blockId, kind: "heading", template: "Title" },
text: { blockId, kind: "text", template: "Some text" },
metric: { blockId, kind: "metric", labelTemplate: "Label", valueTemplate: "{{value}}", unit: "" },
list: { blockId, kind: "list", sourcePath: "items", itemTemplate: "{{name}}", maxItems: 5 },
image: { blockId, kind: "image", urlTemplate: "", fit: "contain" },
divider: { blockId, kind: "divider" }
}[kind] as WidgetBlock;
updateDefinition({ blocks: [...selected.blocks, block] });
}
function updateBlock(blockId: string, patch: Record<string, unknown>) {
if (!selected) return;
updateDefinition({
blocks: selected.blocks.map((block) =>
block.blockId === blockId ? ({ ...block, ...patch } as WidgetBlock) : block
)
});
}
function removeBlock(blockId: string) {
if (!selected) return;
updateDefinition({ blocks: selected.blocks.filter((block) => block.blockId !== blockId) });
}
function moveBlock(index: number, offset: number) {
if (!selected) return;
const target = index + offset;
if (target < 0 || target >= selected.blocks.length) return;
const blocks = [...selected.blocks];
const [moved] = blocks.splice(index, 1);
blocks.splice(target, 0, moved);
updateDefinition({ blocks });
}
</script>
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<h3 class="flex-1 text-sm font-semibold tracking-wide text-ink-2 uppercase">Widget builder</h3>
<button
onclick={addDefinition}
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" />
New
</button>
</div>
<div class="flex flex-wrap gap-px">
{#each definitions as definition (definition.definitionId)}
<button
onclick={() => (selectedId = definition.definitionId)}
class="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
class:bg-accent={selectedId === definition.definitionId}
class:text-white={selectedId === definition.definitionId}
class:bg-surface-2={selectedId !== definition.definitionId}
class:text-ink-1={selectedId !== definition.definitionId}
>
{definition.name}
</button>
{/each}
</div>
{#if selected}
<div class="flex flex-col gap-4 bg-surface-1 p-5">
<div class="flex flex-wrap gap-3">
<label class="min-w-40 flex-1">
<span class="mb-1 block text-xs text-ink-2">Name</span>
<input
value={selected.name}
oninput={(event) =>
updateDefinition({ name: (event.target as HTMLInputElement).value })}
class="w-full bg-surface-2 px-3 py-2 text-sm"
/>
</label>
<label>
<span class="mb-1 block text-xs text-ink-2">Accent</span>
<input
type="color"
value={selected.accentColor}
oninput={(event) =>
updateDefinition({ accentColor: (event.target as HTMLInputElement).value })}
class="h-10 w-16 bg-surface-2"
/>
</label>
<button
onclick={() => removeDefinition(selected.definitionId)}
class="self-end bg-surface-2 px-4 py-2 text-sm text-danger hover:bg-surface-3"
>
Delete
</button>
</div>
<div class="flex flex-col gap-3 bg-surface-2 p-4">
<p class="text-xs tracking-wide text-ink-2 uppercase">Data source</p>
<input
value={selected.dataSource?.url ?? ""}
placeholder="https://example.com/api.json"
oninput={(event) =>
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"
/>
<div class="grid grid-cols-2 gap-3">
<label>
<span class="mb-1 block text-xs text-ink-2">Refresh seconds</span>
<input
type="number"
min="10"
value={selected.dataSource?.refreshSeconds ?? 300}
oninput={(event) =>
updateDefinition({
dataSource: {
url: selected.dataSource?.url ?? "",
refreshSeconds: Number((event.target as HTMLInputElement).value),
rootPath: selected.dataSource?.rootPath ?? ""
}
})}
class="w-full bg-surface-1 px-3 py-2 text-sm"
/>
</label>
<label>
<span class="mb-1 block text-xs text-ink-2">Root path</span>
<input
value={selected.dataSource?.rootPath ?? ""}
placeholder="data.current"
oninput={(event) =>
updateDefinition({
dataSource: {
url: selected.dataSource?.url ?? "",
refreshSeconds: selected.dataSource?.refreshSeconds ?? 300,
rootPath: (event.target as HTMLInputElement).value
}
})}
class="w-full bg-surface-1 px-3 py-2 text-sm"
/>
</label>
</div>
<p class="text-xs text-ink-2">
Reference fields in any template with double braces, for example
<code class="text-accent">{"{{temperature}}"}</code>.
</p>
</div>
<div class="flex flex-wrap gap-2">
{#each WIDGET_BLOCK_KINDS as kind}
<button
onclick={() => addBlock(kind)}
class="bg-surface-2 px-3 py-2 text-sm text-ink-1 capitalize hover:bg-surface-3"
>
+ {kind}
</button>
{/each}
</div>
<div class="flex flex-col gap-px">
{#each selected.blocks as block, index (block.blockId)}
<div class="flex flex-col gap-2 bg-surface-2 p-4">
<div class="flex items-center gap-2">
<span class="flex-1 text-xs tracking-wide text-ink-2 uppercase">{block.kind}</span>
<button
onclick={() => moveBlock(index, -1)}
aria-label="Move up"
class="flex h-7 w-7 items-center justify-center bg-surface-1 text-ink-1 hover:bg-surface-3"
>
<Icon icon="ph:arrow-up-bold" width="14" />
</button>
<button
onclick={() => moveBlock(index, 1)}
aria-label="Move down"
class="flex h-7 w-7 items-center justify-center bg-surface-1 text-ink-1 hover:bg-surface-3"
>
<Icon icon="ph:arrow-down-bold" width="14" />
</button>
<button
onclick={() => removeBlock(block.blockId)}
aria-label="Remove block"
class="flex h-7 w-7 items-center justify-center bg-surface-1 text-danger hover:bg-surface-3"
>
<Icon icon="ph:x-bold" width="14" />
</button>
</div>
{#if block.kind === "heading" || block.kind === "text"}
<input
value={block.template}
oninput={(event) =>
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"}
<div class="grid grid-cols-3 gap-2">
<input
value={block.labelTemplate}
placeholder="Label"
oninput={(event) =>
updateBlock(block.blockId, {
labelTemplate: (event.target as HTMLInputElement).value
})}
class="bg-surface-1 px-3 py-2 text-sm"
/>
<input
value={block.valueTemplate}
placeholder={"{{value}}"}
oninput={(event) =>
updateBlock(block.blockId, {
valueTemplate: (event.target as HTMLInputElement).value
})}
class="bg-surface-1 px-3 py-2 text-sm"
/>
<input
value={block.unit}
placeholder="Unit"
oninput={(event) =>
updateBlock(block.blockId, { unit: (event.target as HTMLInputElement).value })}
class="bg-surface-1 px-3 py-2 text-sm"
/>
</div>
{:else if block.kind === "list"}
<div class="grid grid-cols-3 gap-2">
<input
value={block.sourcePath}
placeholder="items"
oninput={(event) =>
updateBlock(block.blockId, {
sourcePath: (event.target as HTMLInputElement).value
})}
class="bg-surface-1 px-3 py-2 text-sm"
/>
<input
value={block.itemTemplate}
placeholder={"{{name}}"}
oninput={(event) =>
updateBlock(block.blockId, {
itemTemplate: (event.target as HTMLInputElement).value
})}
class="bg-surface-1 px-3 py-2 text-sm"
/>
<input
type="number"
min="1"
value={block.maxItems}
oninput={(event) =>
updateBlock(block.blockId, {
maxItems: Number((event.target as HTMLInputElement).value)
})}
class="bg-surface-1 px-3 py-2 text-sm"
/>
</div>
{:else if block.kind === "image"}
<input
value={block.urlTemplate}
placeholder={"https://example.com/{{path}}"}
oninput={(event) =>
updateBlock(block.blockId, {
urlTemplate: (event.target as HTMLInputElement).value
})}
class="w-full bg-surface-1 px-3 py-2 text-sm"
/>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,233 @@
<script lang="ts">
import type { KioskLayout, Widget, WidgetPlacement } from "@pistation/shared-types";
import {
buildOccupancy,
clampPlacement,
isAreaFree,
WIDGET_GRID_COLUMNS,
WIDGET_GRID_ROWS
} from "@pistation/shared-types";
import { WidgetSurface } from "@pistation/ui";
let {
layout,
pin = null,
joinUrl = "",
mediaBaseUrl = "",
selectedWidgetId = null,
onSelect,
onWidgetsChange
}: {
layout: KioskLayout;
pin?: string | null;
joinUrl?: string;
mediaBaseUrl?: string;
selectedWidgetId?: string | null;
onSelect: (widgetId: string) => void;
onWidgetsChange: (widgets: Widget[]) => void;
} = $props();
const GRID_PADDING_RATIO = 0.016;
const GRID_GAP_RATIO = 0.012;
interface DragState {
widgetId: string;
mode: "move" | "resize";
pointerX: number;
pointerY: number;
origin: WidgetPlacement;
}
let surfaceWidth = $state(0);
let surfaceHeight = $state(0);
let drag = $state<DragState | null>(null);
let draft = $state<WidgetPlacement | null>(null);
let isDraftValid = $state(true);
const visibleWidgets = $derived(layout.widgets.filter((widget) => widget.enabled));
const stepX = $derived.by(() => {
const padding = surfaceWidth * GRID_PADDING_RATIO;
const gap = surfaceWidth * GRID_GAP_RATIO;
const content = surfaceWidth - padding * 2;
const cell = (content - gap * (WIDGET_GRID_COLUMNS - 1)) / WIDGET_GRID_COLUMNS;
return cell + gap;
});
const stepY = $derived.by(() => {
const padding = surfaceWidth * GRID_PADDING_RATIO;
const gap = surfaceHeight * GRID_GAP_RATIO;
const content = surfaceHeight - padding * 2;
const cell = (content - gap * (WIDGET_GRID_ROWS - 1)) / WIDGET_GRID_ROWS;
return cell + gap;
});
function placementFor(widget: Widget): WidgetPlacement {
return drag?.widgetId === widget.widgetId && draft ? draft : widget.placement;
}
function beginDrag(event: PointerEvent, widget: Widget, mode: "move" | "resize") {
event.preventDefault();
event.stopPropagation();
onSelect(widget.widgetId);
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
drag = {
widgetId: widget.widgetId,
mode,
pointerX: event.clientX,
pointerY: event.clientY,
origin: { ...widget.placement }
};
draft = { ...widget.placement };
isDraftValid = true;
}
function updateDrag(event: PointerEvent) {
if (!drag || stepX <= 0 || stepY <= 0) return;
const deltaColumns = Math.round((event.clientX - drag.pointerX) / stepX);
const deltaRows = Math.round((event.clientY - drag.pointerY) / stepY);
const candidate =
drag.mode === "move"
? clampPlacement({
...drag.origin,
column: drag.origin.column + deltaColumns,
row: drag.origin.row + deltaRows
})
: clampPlacement({
...drag.origin,
columnSpan: Math.min(
Math.max(1, drag.origin.columnSpan + deltaColumns),
WIDGET_GRID_COLUMNS - drag.origin.column + 1
),
rowSpan: Math.min(
Math.max(1, drag.origin.rowSpan + deltaRows),
WIDGET_GRID_ROWS - drag.origin.row + 1
)
});
draft = candidate;
isDraftValid = isAreaFree(buildOccupancy(layout.widgets, drag.widgetId), candidate);
}
function endDrag() {
if (drag && draft && isDraftValid) {
commitPlacement(drag.widgetId, draft);
}
drag = null;
draft = null;
isDraftValid = true;
}
function commitPlacement(widgetId: string, placement: WidgetPlacement) {
onWidgetsChange(
layout.widgets.map((widget) =>
widget.widgetId === widgetId ? { ...widget, placement } : widget
)
);
}
function nudge(event: KeyboardEvent, widget: Widget) {
const directions: Record<string, [number, number]> = {
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1]
};
const direction = directions[event.key];
if (!direction) return;
event.preventDefault();
const [deltaColumns, deltaRows] = direction;
const candidate = clampPlacement(
event.shiftKey
? {
...widget.placement,
columnSpan: Math.max(1, widget.placement.columnSpan + deltaColumns),
rowSpan: Math.max(1, widget.placement.rowSpan + deltaRows)
}
: {
...widget.placement,
column: widget.placement.column + deltaColumns,
row: widget.placement.row + deltaRows
}
);
if (isAreaFree(buildOccupancy(layout.widgets, widget.widgetId), candidate)) {
commitPlacement(widget.widgetId, candidate);
}
}
</script>
<div
class="relative aspect-video w-full overflow-hidden bg-surface-1 select-none"
bind:clientWidth={surfaceWidth}
bind:clientHeight={surfaceHeight}
>
<WidgetSurface {layout} {pin} {joinUrl} {mediaBaseUrl} />
<div
class="absolute inset-0 grid"
style={`
grid-template-columns: repeat(${WIDGET_GRID_COLUMNS}, minmax(0, 1fr));
grid-template-rows: repeat(${WIDGET_GRID_ROWS}, minmax(0, 1fr));
gap: 1.2%;
padding: 1.6%;
`}
>
{#if drag}
{#each Array(WIDGET_GRID_COLUMNS * WIDGET_GRID_ROWS) as _, index}
<div
class="pointer-events-none bg-white/5"
style={`grid-column: ${(index % WIDGET_GRID_COLUMNS) + 1}; grid-row: ${Math.floor(index / WIDGET_GRID_COLUMNS) + 1};`}
></div>
{/each}
{/if}
{#each visibleWidgets as widget (widget.widgetId)}
{@const placement = placementFor(widget)}
<div
class="group relative cursor-grab touch-none transition-colors hover:bg-white/5"
class:cursor-grabbing={drag?.widgetId === widget.widgetId}
class:outline={selectedWidgetId === widget.widgetId || drag?.widgetId === widget.widgetId}
class:outline-2={selectedWidgetId === widget.widgetId || drag?.widgetId === widget.widgetId}
class:outline-accent={isDraftValid}
class:outline-danger={!isDraftValid && drag?.widgetId === widget.widgetId}
style={`
grid-column: ${placement.column} / span ${placement.columnSpan};
grid-row: ${placement.row} / span ${placement.rowSpan};
`}
role="button"
tabindex="0"
aria-label={`Move ${widget.kind} widget`}
onpointerdown={(event) => beginDrag(event, widget, "move")}
onpointermove={updateDrag}
onpointerup={endDrag}
onpointercancel={endDrag}
onkeydown={(event) => nudge(event, widget)}
onfocus={() => onSelect(widget.widgetId)}
>
<span
class="absolute top-1 left-1 bg-surface-0/80 px-2 py-0.5 text-xs text-ink-1 opacity-0 transition-opacity group-hover:opacity-100"
>
{widget.kind}
</span>
<button
class="absolute right-0 bottom-0 h-5 w-5 cursor-nwse-resize bg-accent opacity-0 transition-opacity group-hover:opacity-100"
class:opacity-100={selectedWidgetId === widget.widgetId}
aria-label={`Resize ${widget.kind} widget`}
onpointerdown={(event) => beginDrag(event, widget, "resize")}
onpointermove={updateDrag}
onpointerup={endDrag}
onpointercancel={endDrag}
></button>
</div>
{/each}
</div>
</div>
@@ -0,0 +1,101 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { Widget } from "@pistation/shared-types";
let {
widgets,
selectedWidgetId,
onSelect,
onChange
}: {
widgets: Widget[];
selectedWidgetId: string | null;
onSelect: (widgetId: string) => void;
onChange: (widgets: Widget[]) => void;
} = $props();
function move(index: number, offset: number) {
const target = index + offset;
if (target < 0 || target >= widgets.length) return;
const reordered = [...widgets];
const [moved] = reordered.splice(index, 1);
reordered.splice(target, 0, moved);
onChange(reordered);
}
function toggle(widgetId: string) {
onChange(
widgets.map((widget) =>
widget.widgetId === widgetId ? { ...widget, enabled: !widget.enabled } : widget
)
);
}
function remove(widgetId: string) {
onChange(widgets.filter((widget) => widget.widgetId !== widgetId));
}
</script>
<div class="flex flex-col gap-3 bg-surface-1 p-5">
<h3 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">Widgets</h3>
{#if widgets.length === 0}
<p class="py-4 text-center text-sm text-ink-2">No widgets yet.</p>
{/if}
<div class="flex flex-col gap-px">
{#each widgets as widget, index (widget.widgetId)}
<div
class="flex items-center gap-2 px-3 py-2 transition-colors"
class:bg-surface-2={selectedWidgetId !== widget.widgetId}
class:bg-accent={selectedWidgetId === widget.widgetId}
>
<button
onclick={() => onSelect(widget.widgetId)}
class="flex-1 text-left text-sm capitalize"
class:text-white={selectedWidgetId === widget.widgetId}
class:text-ink-1={selectedWidgetId !== widget.widgetId}
class:opacity-40={!widget.enabled}
>
{widget.kind}
<span class="ml-2 text-xs opacity-60">
{widget.placement.columnSpan} by {widget.placement.rowSpan}
</span>
</button>
<button
onclick={() => toggle(widget.widgetId)}
aria-label={widget.enabled ? "Hide widget" : "Show widget"}
class="flex h-7 w-7 items-center justify-center text-ink-1 hover:bg-surface-3"
>
<Icon icon={widget.enabled ? "ph:eye-bold" : "ph:eye-slash-bold"} width="14" />
</button>
<button
onclick={() => move(index, -1)}
aria-label="Move earlier"
class="flex h-7 w-7 items-center justify-center text-ink-1 hover:bg-surface-3"
>
<Icon icon="ph:arrow-up-bold" width="14" />
</button>
<button
onclick={() => move(index, 1)}
aria-label="Move later"
class="flex h-7 w-7 items-center justify-center text-ink-1 hover:bg-surface-3"
>
<Icon icon="ph:arrow-down-bold" width="14" />
</button>
<button
onclick={() => remove(widget.widgetId)}
aria-label="Delete widget"
class="flex h-7 w-7 items-center justify-center text-danger hover:bg-surface-3"
>
<Icon icon="ph:trash-bold" width="14" />
</button>
</div>
{/each}
</div>
</div>
@@ -0,0 +1,409 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type {
AgendaSettings,
ClockSettings,
CustomSettings,
CustomWidgetDefinition,
ImageSettings,
PinSettings,
TextSettings,
WeatherSettings,
Widget,
WidgetStyle
} from "@pistation/shared-types";
import {
DEFAULT_WIDGET_OPACITY,
DEFAULT_WIDGET_STYLE,
WIDGET_GRID_COLUMNS,
WIDGET_GRID_ROWS
} from "@pistation/shared-types";
import TimeZoneField from "./TimeZoneField.svelte";
let {
widget,
definitions,
layoutOpacity = DEFAULT_WIDGET_OPACITY,
onChange,
onRemove
}: {
widget: Widget;
definitions: CustomWidgetDefinition[];
layoutOpacity?: number;
onChange: (widget: Widget) => void;
onRemove: () => void;
} = $props();
const ALIGN_OPTIONS = [
{
value: "start" as const,
label: "Start",
horizontalIcon: "ph:align-left-simple-bold",
verticalIcon: "ph:align-top-simple-bold"
},
{
value: "center" as const,
label: "Centre",
horizontalIcon: "ph:align-center-horizontal-simple-bold",
verticalIcon: "ph:align-center-vertical-simple-bold"
},
{
value: "end" as const,
label: "End",
horizontalIcon: "ph:align-right-simple-bold",
verticalIcon: "ph:align-bottom-simple-bold"
}
];
const style = $derived({ ...DEFAULT_WIDGET_STYLE, ...(widget.style ?? {}) });
function patchSettings(patch: Record<string, unknown>) {
onChange({ ...widget, settings: { ...widget.settings, ...patch } as Widget["settings"] });
}
function patchStyle(patch: Partial<WidgetStyle>) {
onChange({ ...widget, style: { ...style, ...patch } });
}
function patchPlacement(patch: Record<string, number>) {
onChange({ ...widget, placement: { ...widget.placement, ...patch } });
}
const clock = $derived(widget.settings as ClockSettings);
const weather = $derived(widget.settings as WeatherSettings);
const pin = $derived(widget.settings as PinSettings);
const text = $derived(widget.settings as TextSettings);
const image = $derived(widget.settings as ImageSettings);
const agenda = $derived(widget.settings as AgendaSettings);
const custom = $derived(widget.settings as CustomSettings);
</script>
<div class="flex flex-col gap-5 bg-surface-1 p-5">
<div class="flex items-center gap-2">
<h3 class="flex-1 text-sm font-semibold tracking-wide uppercase">{widget.kind}</h3>
<label class="flex items-center gap-2 text-sm text-ink-1">
<input
type="checkbox"
checked={widget.enabled}
onchange={(event) =>
onChange({ ...widget, enabled: (event.target as HTMLInputElement).checked })}
/>
Shown
</label>
<button
onclick={onRemove}
aria-label="Remove widget"
class="flex h-8 w-8 items-center justify-center bg-surface-2 text-danger hover:bg-surface-3"
>
<Icon icon="ph:trash-bold" width="16" />
</button>
</div>
<div class="grid grid-cols-4 gap-3">
{#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}
<label class="block">
<span class="mb-1 block text-xs text-ink-2">{field.label}</span>
<input
type="number"
min="1"
max={field.max}
value={widget.placement[field.key as keyof typeof widget.placement]}
oninput={(event) =>
patchPlacement({
[field.key]: Number((event.target as HTMLInputElement).value)
})}
class="w-full bg-surface-2 px-3 py-2 text-sm"
/>
</label>
{/each}
</div>
<div class="flex flex-col gap-4 bg-surface-2 p-4">
<p class="text-xs tracking-wide text-ink-2 uppercase">Style</p>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Padding {style.padding}</span>
<input
type="range"
min="0"
max="20"
step="0.5"
value={style.padding}
oninput={(event) =>
patchStyle({ padding: Number((event.target as HTMLInputElement).value) })}
class="w-full"
/>
</label>
<div class="grid grid-cols-2 gap-3">
<div>
<span class="mb-1 block text-xs text-ink-2">Horizontal</span>
<div class="flex gap-px">
{#each ALIGN_OPTIONS as option}
<button
onclick={() => patchStyle({ align: option.value })}
aria-label={option.label}
title={option.label}
class="flex h-9 flex-1 items-center justify-center transition-colors"
class:bg-accent={style.align === option.value}
class:text-white={style.align === option.value}
class:bg-surface-1={style.align !== option.value}
class:text-ink-1={style.align !== option.value}
>
<Icon icon={option.horizontalIcon} width="16" />
</button>
{/each}
</div>
</div>
<div>
<span class="mb-1 block text-xs text-ink-2">Vertical</span>
<div class="flex gap-px">
{#each ALIGN_OPTIONS as option}
<button
onclick={() => patchStyle({ verticalAlign: option.value })}
aria-label={option.label}
title={option.label}
class="flex h-9 flex-1 items-center justify-center transition-colors"
class:bg-accent={style.verticalAlign === option.value}
class:text-white={style.verticalAlign === option.value}
class:bg-surface-1={style.verticalAlign !== option.value}
class:text-ink-1={style.verticalAlign !== option.value}
>
<Icon icon={option.verticalIcon} width="16" />
</button>
{/each}
</div>
</div>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-xs text-ink-2">Panel colour</span>
<div class="flex items-center gap-2">
{#if style.backgroundColor}
<button
onclick={() => patchStyle({ backgroundColor: "" })}
class="bg-surface-1 px-3 py-1.5 text-xs text-ink-1 hover:bg-surface-3"
>
Reset
</button>
{/if}
<input
type="color"
value={style.backgroundColor || "#ffffff"}
oninput={(event) =>
patchStyle({ backgroundColor: (event.target as HTMLInputElement).value })}
class="h-9 w-14 bg-surface-1"
/>
</div>
</div>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">
Panel opacity
{style.opacity === null ? "follows the layout" : `${Math.round(style.opacity * 100)} percent`}
</span>
<div class="flex items-center gap-2">
<input
type="range"
min="0"
max="100"
value={(style.opacity ?? layoutOpacity) * 100}
oninput={(event) =>
patchStyle({ opacity: Number((event.target as HTMLInputElement).value) / 100 })}
class="w-full"
/>
{#if style.opacity !== null}
<button
onclick={() => patchStyle({ opacity: null })}
class="shrink-0 bg-surface-1 px-3 py-1.5 text-xs text-ink-1 hover:bg-surface-3"
>
Reset
</button>
{/if}
</div>
</label>
</div>
{#if widget.kind === "clock"}
<TimeZoneField
value={clock.timeZone}
onChange={(timeZone) => patchSettings({ timeZone })}
/>
<div class="flex gap-4 text-sm text-ink-1">
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={clock.showSeconds}
onchange={(event) =>
patchSettings({ showSeconds: (event.target as HTMLInputElement).checked })}
/>
Seconds
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={clock.showDate}
onchange={(event) =>
patchSettings({ showDate: (event.target as HTMLInputElement).checked })}
/>
Date
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={clock.hour12}
onchange={(event) =>
patchSettings({ hour12: (event.target as HTMLInputElement).checked })}
/>
12 hour
</label>
</div>
{:else if widget.kind === "weather"}
<div class="grid grid-cols-2 gap-3">
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Latitude</span>
<input
type="number"
step="0.0001"
value={weather.latitude}
oninput={(event) =>
patchSettings({ latitude: Number((event.target as HTMLInputElement).value) })}
class="w-full bg-surface-2 px-3 py-2 text-sm"
/>
</label>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Longitude</span>
<input
type="number"
step="0.0001"
value={weather.longitude}
oninput={(event) =>
patchSettings({ longitude: Number((event.target as HTMLInputElement).value) })}
class="w-full bg-surface-2 px-3 py-2 text-sm"
/>
</label>
</div>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Location label</span>
<input
value={weather.locationLabel}
oninput={(event) =>
patchSettings({ locationLabel: (event.target as HTMLInputElement).value })}
class="w-full bg-surface-2 px-3 py-2 text-sm"
/>
</label>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Units</span>
<select
value={weather.units}
onchange={(event) => patchSettings({ units: (event.target as HTMLSelectElement).value })}
class="w-full bg-surface-2 px-3 py-2 text-sm"
>
<option value="metric">Celsius</option>
<option value="imperial">Fahrenheit</option>
</select>
</label>
{:else if widget.kind === "pin"}
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Label</span>
<input
value={pin.label}
oninput={(event) => patchSettings({ label: (event.target as HTMLInputElement).value })}
class="w-full bg-surface-2 px-3 py-2 text-sm"
/>
</label>
<label class="flex items-center gap-2 text-sm text-ink-1">
<input
type="checkbox"
checked={pin.showJoinUrl}
onchange={(event) =>
patchSettings({ showJoinUrl: (event.target as HTMLInputElement).checked })}
/>
Show join address
</label>
{:else if widget.kind === "text"}
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Heading</span>
<input
value={text.heading}
oninput={(event) => patchSettings({ heading: (event.target as HTMLInputElement).value })}
class="w-full bg-surface-2 px-3 py-2 text-sm"
/>
</label>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Body</span>
<textarea
value={text.body}
rows="4"
oninput={(event) => patchSettings({ body: (event.target as HTMLTextAreaElement).value })}
class="w-full bg-surface-2 px-3 py-2 text-sm"
></textarea>
</label>
{:else if widget.kind === "image"}
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Image URL</span>
<input
value={image.imageUrl}
oninput={(event) => patchSettings({ imageUrl: (event.target as HTMLInputElement).value })}
class="w-full bg-surface-2 px-3 py-2 text-sm"
/>
</label>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Fit</span>
<select
value={image.fit}
onchange={(event) => patchSettings({ fit: (event.target as HTMLSelectElement).value })}
class="w-full bg-surface-2 px-3 py-2 text-sm"
>
<option value="cover">Cover</option>
<option value="contain">Contain</option>
</select>
</label>
{:else if widget.kind === "agenda"}
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Heading</span>
<input
value={agenda.heading}
oninput={(event) => patchSettings({ heading: (event.target as HTMLInputElement).value })}
class="w-full bg-surface-2 px-3 py-2 text-sm"
/>
</label>
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Items, one per line</span>
<textarea
value={agenda.items.join("\n")}
rows="5"
oninput={(event) =>
patchSettings({
items: (event.target as HTMLTextAreaElement).value
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
})}
class="w-full bg-surface-2 px-3 py-2 text-sm"
></textarea>
</label>
{:else if widget.kind === "custom"}
<label class="block">
<span class="mb-1 block text-xs text-ink-2">Definition</span>
<select
value={custom.definitionId}
onchange={(event) =>
patchSettings({ definitionId: (event.target as HTMLSelectElement).value })}
class="w-full bg-surface-2 px-3 py-2 text-sm"
>
<option value="">Choose a custom widget</option>
{#each definitions as definition}
<option value={definition.definitionId}>{definition.name}</option>
{/each}
</select>
</label>
{/if}
</div>
+3
View File
@@ -0,0 +1,3 @@
import { env } from "$env/dynamic/public";
export const apiBaseUrl = (env.PUBLIC_API_URL ?? "http://localhost:8080").replace(/\/$/, "");
+17
View File
@@ -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";
+546
View File
@@ -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<ConnectionStatus>("idle");
mode = $state<RoomMode>("idle");
annotations = $state<AnnotationState>(createAnnotationState());
whiteboardElements = $state<WhiteboardElement[]>([]);
screenTrack = $state<RemoteTrack | null>(null);
localScreenTrack = $state<LocalVideoTrack | null>(null);
localCameraTrack = $state<LocalVideoTrack | null>(null);
cameraFeeds = $state<CameraFeed[]>([]);
isSharing = $state(false);
isCameraOn = $state(false);
facingMode = $state<"user" | "environment">("environment");
participants = $state<RoomParticipant[]>([]);
sharingParticipantName = $state<string | null>(null);
errorMessage = $state<string | null>(null);
tool = $state<AnnotationTool>("pen");
strokeStyle = $state<StrokeStyle>({ ...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<typeof setInterval> | 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<void> {
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<void> {
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<string, { source: Track.Source }> },
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<void> {
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<void> {
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<void> {
const room = this.connection?.room;
if (!room) return;
await room.localParticipant.setCameraEnabled(false);
}
async flipCamera(): Promise<void> {
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<void> {
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<T extends DataTopic>(
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"
});
}
}
}
+60
View File
@@ -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);
}
+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}