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