Add shared UI package and logo

Widget surface, annotation overlay, whiteboard and video components used by
both the website and the kiosk.

Claude-Session: https://claude.ai/code/session_01SS9F92jb51bMCRCKem6QtD
This commit is contained in:
2026-08-09 17:09:06 -04:00
parent 2bca6f0e90
commit fc808b81c7
17 changed files with 993 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@pistation/ui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"svelte": "./src/index.ts",
"exports": {
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"dependencies": {
"@pistation/client-core": "workspace:*",
"@pistation/shared-types": "workspace:*"
},
"peerDependencies": {
"@excalidraw/excalidraw": "^0.17.6",
"@iconify/svelte": "^4.0.2",
"livekit-client": "^2.7.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"svelte": "^5.0.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1"
}
}
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
import type { AnnotationState } from "@pistation/client-core/annotations";
import { renderAnnotations } from "@pistation/client-core/annotations";
import type { NormalizedPoint } from "@pistation/shared-types";
let {
annotations,
labels,
interactive = false,
onStrokeStart,
onStrokeExtend,
onStrokeEnd,
onPointerMove
}: {
annotations: AnnotationState;
labels?: Map<string, string>;
interactive?: boolean;
onStrokeStart?: (point: NormalizedPoint) => void;
onStrokeExtend?: (point: NormalizedPoint) => void;
onStrokeEnd?: () => void;
onPointerMove?: (point: NormalizedPoint | null) => void;
} = $props();
let canvas = $state<HTMLCanvasElement | null>(null);
let isDrawing = $state(false);
$effect(() => {
if (!canvas) return;
const element = canvas;
const observer = new ResizeObserver(() => resizeCanvas(element));
observer.observe(element);
resizeCanvas(element);
return () => observer.disconnect();
});
$effect(() => {
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
renderAnnotations({ context, width: canvas.width, height: canvas.height }, annotations, {
labels
});
});
function resizeCanvas(element: HTMLCanvasElement) {
const rect = element.getBoundingClientRect();
const ratio = window.devicePixelRatio || 1;
element.width = Math.max(1, Math.round(rect.width * ratio));
element.height = Math.max(1, Math.round(rect.height * ratio));
const context = element.getContext("2d");
if (context) {
renderAnnotations({ context, width: element.width, height: element.height }, annotations, {
labels
});
}
}
function toNormalized(event: PointerEvent): NormalizedPoint | null {
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
return {
x: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)),
y: Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height))
};
}
function handlePointerDown(event: PointerEvent) {
if (!interactive) return;
const point = toNormalized(event);
if (!point) return;
canvas?.setPointerCapture(event.pointerId);
isDrawing = true;
onStrokeStart?.(point);
}
function handlePointerMove(event: PointerEvent) {
if (!interactive) return;
const point = toNormalized(event);
if (!point) return;
if (isDrawing) {
onStrokeExtend?.(point);
} else {
onPointerMove?.(point);
}
}
function handlePointerUp(event: PointerEvent) {
if (!interactive || !isDrawing) return;
canvas?.releasePointerCapture(event.pointerId);
isDrawing = false;
onStrokeEnd?.();
}
function handlePointerLeave() {
if (!interactive) return;
onPointerMove?.(null);
}
</script>
<canvas
bind:this={canvas}
class="absolute inset-0 h-full w-full"
class:pointer-events-none={!interactive}
class:cursor-crosshair={interactive}
style="touch-action: none;"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={handlePointerUp}
onpointerleave={handlePointerLeave}
></canvas>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
let {
size = 32,
class: className = ""
}: {
size?: number;
class?: string;
} = $props();
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 32 32"
shape-rendering="crispEdges"
class={className}
role="img"
aria-label="PiStation"
>
<title>PiStation</title>
<rect x="2" y="4" width="28" height="19" fill="#4f7cff" />
<rect x="4" y="6" width="24" height="14" fill="#14181d" />
<rect x="8" y="8" width="4" height="4" fill="#f4f6f8" />
<rect x="14" y="8" width="4" height="4" fill="#f4f6f8" />
<rect x="20" y="8" width="4" height="4" fill="#f4f6f8" />
<rect x="8" y="14" width="4" height="4" fill="#f4f6f8" />
<rect x="14" y="14" width="4" height="4" fill="#f4f6f8" />
<rect x="20" y="14" width="4" height="4" fill="#4f7cff" />
<rect x="14" y="23" width="4" height="3" fill="#4f7cff" />
<rect x="9" y="26" width="14" height="2" fill="#4f7cff" />
</svg>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import type { KioskLayout } from "@pistation/shared-types";
import { formatPin, safeTimeZone } from "@pistation/shared-types";
import { onMount } from "svelte";
let {
layout,
pin = null
}: {
layout: KioskLayout;
pin?: string | null;
} = $props();
let now = $state(new Date());
onMount(() => {
const interval = setInterval(() => (now = new Date()), 20000);
return () => clearInterval(interval);
});
const clockSettings = $derived(
layout.widgets.find((widget) => widget.kind === "clock")?.settings as
| { timeZone?: string; hour12?: boolean }
| undefined
);
const timeLabel = $derived(
now.toLocaleTimeString([], {
timeZone: safeTimeZone(clockSettings?.timeZone ?? ""),
hour: "2-digit",
minute: "2-digit",
hour12: clockSettings?.hour12 ?? true
})
);
const brightness = $derived(Math.min(1, Math.max(0.05, layout.nightMode.brightness)));
</script>
<div
class="flex h-full w-full flex-col items-center justify-center overflow-hidden bg-black"
style={`container-type: size; opacity: ${brightness}; color: ${layout.foregroundColor};`}
>
<p
class="truncate font-mono leading-none font-semibold tracking-tight"
style="font-size: clamp(2rem, 22cqmin, 20rem);"
>
{timeLabel}
</p>
{#if layout.nightMode.showPin && pin}
<p
class="mt-[4cqmin] truncate font-mono leading-none tracking-[0.12em] opacity-70"
style="font-size: clamp(1rem, 9cqmin, 8rem);"
>
{formatPin(pin)}
</p>
{/if}
</div>
+26
View File
@@ -0,0 +1,26 @@
<script lang="ts">
import type { LocalVideoTrack, RemoteTrack } from "livekit-client";
let { track }: { track: RemoteTrack | LocalVideoTrack | null } = $props();
let element = $state<HTMLVideoElement | null>(null);
$effect(() => {
const video = element;
const active = track;
if (!video || !active) return;
active.attach(video);
return () => {
active.detach(video);
};
});
</script>
<video
bind:this={element}
class="h-full w-full bg-black object-contain"
autoplay
playsinline
muted
></video>
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
import type { WhiteboardElement } from "@pistation/shared-types";
import { onMount } from "svelte";
let {
elements,
readOnly = false,
onLocalChange
}: {
elements: WhiteboardElement[];
readOnly?: boolean;
onLocalChange?: (changed: WhiteboardElement[]) => void;
} = $props();
interface ExcalidrawApi {
updateScene: (scene: { elements: readonly WhiteboardElement[] }) => void;
}
let container = $state<HTMLDivElement | null>(null);
let excalidrawApi = $state<ExcalidrawApi | null>(null);
let isDrawing = $state(false);
const knownVersions = new Map<string, number>();
onMount(() => {
let unmount: (() => void) | null = null;
void (async () => {
const [reactModule, clientModule, excalidrawModule] = await Promise.all([
import("react"),
import("react-dom/client"),
import("@excalidraw/excalidraw")
]);
if (!container) return;
const root = clientModule.createRoot(container);
root.render(
reactModule.createElement(excalidrawModule.Excalidraw, {
theme: "dark",
viewModeEnabled: readOnly,
// Taken as unknown and narrowed here, so this file does not have to depend on
// Excalidraw's exported API type just to hold a reference to it.
excalidrawAPI: (api: unknown) => {
excalidrawApi = api as ExcalidrawApi;
},
onChange: (sceneElements: readonly WhiteboardElement[]) => {
handleSceneChange(sceneElements);
},
UIOptions: {
canvasActions: {
loadScene: false,
saveToActiveFile: false,
export: false,
toggleTheme: false
}
}
})
);
unmount = () => root.unmount();
})();
return () => unmount?.();
});
$effect(() => {
if (!excalidrawApi) return;
// updateScene replaces the whole element set, so calling it part way through a stroke
// throws away the points gathered so far. Nothing is applied while the pointer is
// down; this effect runs again on release because isDrawing is reactive.
if (isDrawing) return;
// Only push the scene back when something genuinely arrived from someone else.
// Feeding our own edits back in has the same truncating effect.
const hasRemoteChange = elements.some((element) => {
const known = knownVersions.get(element.id);
return known === undefined || element.version > known;
});
if (!hasRemoteChange) return;
for (const element of elements) {
knownVersions.set(element.id, element.version);
}
excalidrawApi.updateScene({ elements });
});
const pendingChanges = new Map<string, WhiteboardElement>();
let flushTimer: ReturnType<typeof setTimeout> | null = null;
function handleSceneChange(sceneElements: readonly WhiteboardElement[]) {
if (readOnly) return;
for (const element of sceneElements) {
const known = knownVersions.get(element.id);
if (known === undefined || element.version > known) {
knownVersions.set(element.id, element.version);
pendingChanges.set(element.id, element);
}
}
if (pendingChanges.size === 0 || flushTimer) return;
// Excalidraw reports a change on every pointer move. Batching keeps the data channel
// usable on a Pi without making strokes feel laggy.
flushTimer = setTimeout(() => {
flushTimer = null;
const changed = [...pendingChanges.values()];
pendingChanges.clear();
if (changed.length > 0) onLocalChange?.(changed);
}, 80);
}
</script>
<div
bind:this={container}
class="h-full w-full bg-surface-1"
onpointerdowncapture={() => (isDrawing = true)}
onpointerupcapture={() => (isDrawing = false)}
onpointercancelcapture={() => (isDrawing = false)}
></div>
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
import type {
AgendaSettings,
ClockSettings,
CustomSettings,
CustomWidgetDefinition,
ImageSettings,
PinSettings,
TextSettings,
WeatherSettings,
Widget
} from "@pistation/shared-types";
import AgendaWidget from "./widgets/AgendaWidget.svelte";
import ClockWidget from "./widgets/ClockWidget.svelte";
import CustomWidget from "./widgets/CustomWidget.svelte";
import ImageWidget from "./widgets/ImageWidget.svelte";
import PinWidget from "./widgets/PinWidget.svelte";
import TextWidget from "./widgets/TextWidget.svelte";
import WeatherWidget from "./widgets/WeatherWidget.svelte";
let {
widget,
pin = null,
joinUrl = "",
definitions = []
}: {
widget: Widget;
pin?: string | null;
joinUrl?: string;
definitions?: CustomWidgetDefinition[];
} = $props();
const definition = $derived(
widget.kind === "custom"
? (definitions.find(
(candidate) => candidate.definitionId === (widget.settings as CustomSettings).definitionId
) ?? null)
: null
);
</script>
{#if widget.kind === "clock"}
<ClockWidget settings={widget.settings as ClockSettings} />
{:else if widget.kind === "weather"}
<WeatherWidget settings={widget.settings as WeatherSettings} />
{:else if widget.kind === "pin"}
<PinWidget settings={widget.settings as PinSettings} {pin} {joinUrl} />
{:else if widget.kind === "text"}
<TextWidget settings={widget.settings as TextSettings} />
{:else if widget.kind === "image"}
<ImageWidget settings={widget.settings as ImageSettings} />
{:else if widget.kind === "agenda"}
<AgendaWidget settings={widget.settings as AgendaSettings} />
{:else if widget.kind === "custom"}
<CustomWidget {definition} />
{/if}
+146
View File
@@ -0,0 +1,146 @@
<script lang="ts">
import type { KioskLayout, Widget, WidgetStyle } from "@pistation/shared-types";
import {
DEFAULT_WIDGET_OPACITY,
DEFAULT_WIDGET_STYLE,
MIN_WALLPAPER_ROTATION_SECONDS,
resolveMediaUrl,
toCssColor,
toFlexAlign,
toTextAlign,
WIDGET_GRID_COLUMNS,
WIDGET_GRID_ROWS
} from "@pistation/shared-types";
import WidgetRenderer from "./WidgetRenderer.svelte";
let {
layout,
pin = null,
joinUrl = "",
mediaBaseUrl = "",
selectedWidgetId = null,
onSelect
}: {
layout: KioskLayout;
pin?: string | null;
joinUrl?: string;
mediaBaseUrl?: string;
selectedWidgetId?: string | null;
onSelect?: (widgetId: string) => void;
} = $props();
const visibleWidgets = $derived(layout.widgets.filter((widget) => widget.enabled));
const dim = $derived(Math.min(1, Math.max(0, layout.background.dim)));
const wallpapers = $derived(
(layout.background.images ?? []).map((image) => resolveMediaUrl(mediaBaseUrl, image))
);
const hasWallpaper = $derived(wallpapers.length > 0);
let activeWallpaper = $state(0);
// Every wallpaper stays mounted so the browser has it decoded before its turn, and the
// change is a crossfade rather than a blink.
$effect(() => {
const total = wallpapers.length;
if (total <= 1) {
activeWallpaper = 0;
return;
}
const seconds = Math.max(
MIN_WALLPAPER_ROTATION_SECONDS,
layout.background.rotationSeconds ?? 60
);
const interval = setInterval(() => {
activeWallpaper = (activeWallpaper + 1) % total;
}, seconds * 1000);
return () => clearInterval(interval);
});
const widgetOpacity = $derived(
Math.min(1, Math.max(0, layout.widgetOpacity ?? DEFAULT_WIDGET_OPACITY))
);
function styleOf(widget: Widget): WidgetStyle {
return { ...DEFAULT_WIDGET_STYLE, ...(widget.style ?? {}) };
}
function cellBackground(style: WidgetStyle): string {
const opacity = Math.min(1, Math.max(0, style.opacity ?? widgetOpacity));
if (style.backgroundColor) return toCssColor(style.backgroundColor, opacity);
if (hasWallpaper) return `rgb(0 0 0 / ${opacity})`;
return `rgb(255 255 255 / ${(opacity * 0.16).toFixed(3)})`;
}
</script>
<div
class="relative h-full w-full overflow-hidden"
style={`background-color: ${layout.backgroundColor}; color: ${layout.foregroundColor};`}
>
{#if hasWallpaper}
{#each wallpapers as wallpaper, index (wallpaper)}
<img
src={wallpaper}
alt=""
class="absolute inset-0 h-full w-full transition-opacity duration-1000"
class:object-cover={layout.background.fit === "cover"}
class:object-contain={layout.background.fit === "contain"}
style={`opacity: ${index === activeWallpaper ? 1 : 0};`}
/>
{/each}
<div class="absolute inset-0 bg-black" style={`opacity: ${dim};`}></div>
{/if}
<div
class="relative grid h-full w-full"
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%;
`}
>
{#each visibleWidgets as widget (widget.widgetId)}
{@const style = styleOf(widget)}
{@const interaction = onSelect
? {
role: "button",
tabindex: 0,
onclick: () => onSelect(widget.widgetId),
onkeydown: (event: KeyboardEvent) => {
if (event.key === "Enter") onSelect(widget.widgetId);
}
}
: {}}
<div
{...interaction}
class="relative min-h-0 min-w-0 overflow-hidden"
class:cursor-pointer={Boolean(onSelect)}
class:outline={selectedWidgetId === widget.widgetId}
class:outline-2={selectedWidgetId === widget.widgetId}
style={`
grid-column: ${widget.placement.column} / span ${widget.placement.columnSpan};
grid-row: ${widget.placement.row} / span ${widget.placement.rowSpan};
background-color: ${cellBackground(style)};
container-type: size;
`}
>
<div
class="h-full w-full overflow-hidden"
style={`
padding: ${style.padding}cqmin;
text-align: ${toTextAlign(style.align)};
--widget-align: ${toFlexAlign(style.align)};
--widget-valign: ${toFlexAlign(style.verticalAlign)};
`}
>
<WidgetRenderer {widget} {pin} {joinUrl} definitions={layout.customDefinitions} />
</div>
</div>
{/each}
</div>
</div>
+14
View File
@@ -0,0 +1,14 @@
export { default as AnnotationOverlay } from "./AnnotationOverlay.svelte";
export { default as Logo } from "./Logo.svelte";
export { default as NightSurface } from "./NightSurface.svelte";
export { default as TrackVideo } from "./TrackVideo.svelte";
export { default as WhiteboardCanvas } from "./WhiteboardCanvas.svelte";
export { default as WidgetSurface } from "./WidgetSurface.svelte";
export { default as WidgetRenderer } from "./WidgetRenderer.svelte";
export { default as ClockWidget } from "./widgets/ClockWidget.svelte";
export { default as WeatherWidget } from "./widgets/WeatherWidget.svelte";
export { default as PinWidget } from "./widgets/PinWidget.svelte";
export { default as TextWidget } from "./widgets/TextWidget.svelte";
export { default as ImageWidget } from "./widgets/ImageWidget.svelte";
export { default as AgendaWidget } from "./widgets/AgendaWidget.svelte";
export { default as CustomWidget } from "./widgets/CustomWidget.svelte";
@@ -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>