Add web client

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

Claude-Session: https://claude.ai/code/session_01SS9F92jb51bMCRCKem6QtD
This commit is contained in:
2026-08-09 17:09:16 -04:00
parent fc808b81c7
commit 31d55fce86
37 changed files with 5074 additions and 0 deletions
@@ -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>