Add shared UI package and logo

Widget surface, annotation overlay, whiteboard and video components used by
both the website and the kiosk.
This commit is contained in:
SirBlob committed 2026-08-09 17:09:06 -04:00
1 parent 6606e283bf
commit a4b033b78e
17 files changed
+993

No files matched your search

@@ -0,0 +1,31 @@
<script lang="ts">
import type { AgendaSettings } from "@pistation/shared-types";
let { settings }: { settings: AgendaSettings } = $props();
</script>
<div
class="flex h-full min-w-0 flex-col gap-[3cqmin] overflow-hidden"
style="justify-content: var(--widget-valign, center);"
>
{#if settings.heading}
<h3
class="truncate font-semibold tracking-wide uppercase opacity-60"
style="font-size: clamp(0.65rem, 9cqmin, 1.75rem);"
>
{settings.heading}
</h3>
{/if}
<ul class="flex min-h-0 flex-col gap-[2cqmin] overflow-hidden">
{#each settings.items as item}
<li
class="flex items-start gap-[2.5cqmin]"
style="font-size: clamp(0.65rem, 8cqmin, 1.5rem); justify-content: var(--widget-align, flex-start);"
>
<span class="mt-[0.55em] h-[0.3em] w-[0.3em] shrink-0 bg-current opacity-50"></span>
<span class="min-w-0">{item}</span>
</li>
{/each}
</ul>
</div>
@@ -0,0 +1,52 @@
<script lang="ts">
import type { ClockSettings } from "@pistation/shared-types";
import { safeTimeZone } from "@pistation/shared-types";
import { onMount } from "svelte";
let { settings }: { settings: ClockSettings } = $props();
let now = $state(new Date());
onMount(() => {
const interval = setInterval(() => (now = new Date()), settings.showSeconds ? 1000 : 15000);
return () => clearInterval(interval);
});
const timeZone = $derived(safeTimeZone(settings.timeZone));
const timeLabel = $derived(
now.toLocaleTimeString([], {
timeZone,
hour: "2-digit",
minute: "2-digit",
second: settings.showSeconds ? "2-digit" : undefined,
hour12: settings.hour12
})
);
const dateLabel = $derived(
now.toLocaleDateString([], {
timeZone,
weekday: "long",
month: "long",
day: "numeric"
})
);
</script>
<div
class="flex h-full min-w-0 flex-col overflow-hidden"
style="justify-content: var(--widget-valign, center);"
>
<p
class="truncate font-mono leading-none font-semibold tracking-tight"
style="font-size: clamp(0.9rem, 30cqmin, 11rem);"
>
{timeLabel}
</p>
{#if settings.showDate}
<p class="mt-[2cqmin] truncate opacity-60" style="font-size: clamp(0.6rem, 8cqmin, 2rem);">
{dateLabel}
</p>
{/if}
</div>
@@ -0,0 +1,92 @@
<script lang="ts">
import type { CustomWidgetDefinition } from "@pistation/shared-types";
import { renderTemplate, resolvePath } from "@pistation/shared-types";
import { onMount } from "svelte";
let { definition }: { definition: CustomWidgetDefinition | null } = $props();
let data = $state<unknown>(null);
async function load(source: NonNullable<CustomWidgetDefinition["dataSource"]>) {
try {
const response = await fetch(source.url);
if (!response.ok) return;
const payload = await response.json();
data = source.rootPath ? resolvePath(payload, source.rootPath) : payload;
} catch {
data = null;
}
}
onMount(() => {
const source = definition?.dataSource;
if (!source?.url) return;
void load(source);
const seconds = Math.max(10, source.refreshSeconds || 300);
const interval = setInterval(() => void load(source), seconds * 1000);
return () => clearInterval(interval);
});
function listItems(sourcePath: string, maxItems: number): unknown[] {
const value = resolvePath(data, sourcePath);
if (!Array.isArray(value)) return [];
return value.slice(0, Math.max(1, maxItems));
}
</script>
{#if !definition}
<div class="flex h-full items-center justify-center text-sm opacity-40">
Widget definition missing
</div>
{:else}
<div
class="flex h-full min-w-0 flex-col gap-[3cqmin] overflow-hidden"
style="justify-content: var(--widget-valign, center);"
>
{#each definition.blocks as block (block.blockId)}
{#if block.kind === "heading"}
<h3 class="truncate font-semibold" style="font-size: clamp(0.75rem, 12cqmin, 2.5rem);">
{renderTemplate(block.template, data)}
</h3>
{:else if block.kind === "text"}
<p class="opacity-70" style="font-size: clamp(0.65rem, 8cqmin, 1.5rem);">
{renderTemplate(block.template, data)}
</p>
{:else if block.kind === "metric"}
<div class="flex min-w-0 flex-col">
<span
class="truncate tracking-wide uppercase opacity-50"
style="font-size: clamp(0.55rem, 6cqmin, 1.25rem);"
>
{renderTemplate(block.labelTemplate, data)}
</span>
<span
class="truncate leading-none font-semibold"
style={`color: ${definition.accentColor}; font-size: clamp(1rem, 22cqmin, 5rem);`}
>
{renderTemplate(block.valueTemplate, data)}{block.unit}
</span>
</div>
{:else if block.kind === "list"}
<ul class="flex min-h-0 flex-col gap-[1.5cqmin] overflow-hidden">
{#each listItems(block.sourcePath, block.maxItems) as item}
<li class="truncate opacity-80" style="font-size: clamp(0.6rem, 7cqmin, 1.4rem);">
{renderTemplate(block.itemTemplate, item)}
</li>
{/each}
</ul>
{:else if block.kind === "image"}
<img
src={renderTemplate(block.urlTemplate, data)}
alt=""
class="max-h-full w-full"
class:object-cover={block.fit === "cover"}
class:object-contain={block.fit === "contain"}
/>
{:else if block.kind === "divider"}
<div class="h-px w-full bg-current opacity-20"></div>
{/if}
{/each}
</div>
{/if}
@@ -0,0 +1,17 @@
<script lang="ts">
import type { ImageSettings } from "@pistation/shared-types";
let { settings }: { settings: ImageSettings } = $props();
</script>
{#if settings.imageUrl}
<img
src={settings.imageUrl}
alt=""
class="h-full w-full"
class:object-cover={settings.fit === "cover"}
class:object-contain={settings.fit === "contain"}
/>
{:else}
<div class="flex h-full items-center justify-center text-sm opacity-40">No image set</div>
{/if}
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import type { PinSettings } from "@pistation/shared-types";
import { formatPin } from "@pistation/shared-types";
let {
settings,
pin,
joinUrl
}: {
settings: PinSettings;
pin: string | null;
joinUrl: string;
} = $props();
</script>
<div
class="flex h-full min-w-0 flex-col overflow-hidden"
style="justify-content: var(--widget-valign, center);"
>
<p
class="w-full truncate tracking-wide uppercase opacity-60"
style="font-size: clamp(0.6rem, 6cqmin, 1.75rem);"
>
{settings.label}
</p>
{#if settings.showJoinUrl}
<p
class="mt-[1cqmin] w-full truncate font-medium opacity-90"
style="font-size: clamp(0.7rem, 8cqmin, 2.5rem);"
>
{joinUrl}
</p>
{/if}
{#if pin}
<p
class="mt-[3cqmin] w-full truncate font-mono leading-none font-bold tracking-[0.08em]"
style="font-size: clamp(1.5rem, 34cqmin, 15rem);"
>
{formatPin(pin)}
</p>
{:else}
<p class="mt-[3cqmin] w-full opacity-40" style="font-size: clamp(1rem, 18cqmin, 5rem);">
------
</p>
{/if}
</div>
+24
View File
@@ -0,0 +1,24 @@
<script lang="ts">
import type { TextSettings } from "@pistation/shared-types";
let { settings }: { settings: TextSettings } = $props();
</script>
<div
class="flex h-full min-w-0 flex-col overflow-hidden"
style="justify-content: var(--widget-valign, center);"
>
{#if settings.heading}
<h3 class="font-semibold" style="font-size: clamp(0.8rem, 14cqmin, 3rem);">
{settings.heading}
</h3>
{/if}
{#if settings.body}
<p
class="mt-[2cqmin] whitespace-pre-line opacity-70"
style="font-size: clamp(0.65rem, 9cqmin, 1.75rem);"
>
{settings.body}
</p>
{/if}
</div>
@@ -0,0 +1,94 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import type { WeatherSettings } from "@pistation/shared-types";
import { onMount } from "svelte";
let { settings }: { settings: WeatherSettings } = $props();
interface Reading {
temperature: number;
weatherCode: number;
}
let reading = $state<Reading | null>(null);
let hasFailed = $state(false);
const WEATHER_ICONS: Record<number, string> = {
0: "ph:sun-bold",
1: "ph:sun-dim-bold",
2: "ph:cloud-sun-bold",
3: "ph:cloud-bold",
45: "ph:cloud-fog-bold",
48: "ph:cloud-fog-bold",
51: "ph:cloud-rain-bold",
61: "ph:cloud-rain-bold",
63: "ph:cloud-rain-bold",
65: "ph:cloud-rain-bold",
71: "ph:cloud-snow-bold",
73: "ph:cloud-snow-bold",
75: "ph:cloud-snow-bold",
80: "ph:cloud-rain-bold",
95: "ph:cloud-lightning-bold",
96: "ph:cloud-lightning-bold"
};
const icon = $derived(reading ? (WEATHER_ICONS[reading.weatherCode] ?? "ph:cloud-bold") : "ph:cloud-bold");
const unitLabel = $derived(settings.units === "imperial" ? "F" : "C");
async function load() {
const temperatureUnit = settings.units === "imperial" ? "fahrenheit" : "celsius";
const url =
`https://api.open-meteo.com/v1/forecast?latitude=${settings.latitude}` +
`&longitude=${settings.longitude}&current=temperature_2m,weather_code` +
`&temperature_unit=${temperatureUnit}`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error("weather request failed");
const payload = (await response.json()) as {
current?: { temperature_2m?: number; weather_code?: number };
};
if (payload.current?.temperature_2m === undefined) throw new Error("no reading");
reading = {
temperature: Math.round(payload.current.temperature_2m),
weatherCode: payload.current.weather_code ?? 0
};
hasFailed = false;
} catch {
hasFailed = true;
}
}
onMount(() => {
void load();
const interval = setInterval(() => void load(), 15 * 60 * 1000);
return () => clearInterval(interval);
});
</script>
<div
class="flex h-full min-w-0 gap-[4cqmin] overflow-hidden"
style="justify-content: var(--widget-align, flex-start); align-items: var(--widget-valign, center);"
>
<span class="shrink-0 opacity-80" style="font-size: clamp(1rem, 26cqmin, 7rem);">
<Icon {icon} width="1em" height="1em" />
</span>
<div class="min-w-0">
{#if reading}
<p class="truncate leading-none font-semibold" style="font-size: clamp(1rem, 26cqmin, 7rem);">
{reading.temperature}&deg;{unitLabel}
</p>
{:else}
<p class="truncate opacity-60" style="font-size: clamp(0.7rem, 10cqmin, 2rem);">
{hasFailed ? "Weather unavailable" : "Loading"}
</p>
{/if}
<p class="truncate opacity-60" style="font-size: clamp(0.6rem, 8cqmin, 1.75rem);">
{settings.locationLabel}
</p>
</div>
</div>