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:
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}
|
||||
@@ -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>
|
||||
@@ -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}¤t=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}°{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>
|
||||
Reference in new issue
Block a user