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:
@@ -0,0 +1,24 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 32 32"
|
||||
shape-rendering="crispEdges"
|
||||
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>
|
||||
|
After Width: | Height: | Size: 804 B |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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