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:
@@ -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)}°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)}°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>
|
||||
Reference in New Issue
Block a user