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>
@@ -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>