Add shared types and client core packages

The wire contract every client codes against, plus the annotation engine,
canvas renderer and connection policy shared by both clients.

Claude-Session: https://claude.ai/code/session_01SS9F92jb51bMCRCKem6QtD
This commit is contained in:
2026-08-09 17:08:55 -04:00
parent f38b4407cb
commit d7733a6765
23 changed files with 1701 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
export const ANNOTATION_TOOLS = [
"pen",
"highlighter",
"arrow",
"rectangle",
"ellipse",
"laser"
] as const;
export type AnnotationTool = (typeof ANNOTATION_TOOLS)[number];
export interface NormalizedPoint {
x: number;
y: number;
}
export interface StrokeStyle {
color: string;
width: number;
opacity: number;
}
export interface StrokeStartEvent {
type: "stroke.start";
strokeId: string;
tool: AnnotationTool;
style: StrokeStyle;
point: NormalizedPoint;
}
export interface StrokeAppendEvent {
type: "stroke.append";
strokeId: string;
points: NormalizedPoint[];
}
export interface StrokeEndEvent {
type: "stroke.end";
strokeId: string;
}
export interface StrokeEraseEvent {
type: "stroke.erase";
strokeIds: string[];
}
export interface CanvasClearEvent {
type: "canvas.clear";
}
export interface PointerMoveEvent {
type: "pointer.move";
point: NormalizedPoint | null;
color: string;
}
export type AnnotationEvent =
| StrokeStartEvent
| StrokeAppendEvent
| StrokeEndEvent
| StrokeEraseEvent
| CanvasClearEvent
| PointerMoveEvent;
export interface CompletedStroke {
strokeId: string;
authorId: string;
tool: AnnotationTool;
style: StrokeStyle;
points: NormalizedPoint[];
}
export const DEFAULT_STROKE_STYLE: StrokeStyle = {
color: "#ff2d55",
width: 0.004,
opacity: 1
};
export const HIGHLIGHTER_STYLE: StrokeStyle = {
color: "#ffd60a",
width: 0.018,
opacity: 0.35
};
export function isPersistentTool(tool: AnnotationTool): boolean {
return tool !== "laser";
}
+99
View File
@@ -0,0 +1,99 @@
import type { Kiosk, KioskLayout } from "./kiosk.js";
import type { ParticipantRole } from "./room.js";
export interface ApiError {
error: string;
message: string;
}
export interface KioskRegisterRequest {
enrollmentToken: string;
hardwareId: string;
}
export interface KioskRegisterResponse {
kioskId: string;
kioskToken: string;
roomName: string;
livekitUrl: string;
rotationSeconds: number;
}
export interface KioskPinResponse {
pin: string;
issuedAt: number;
expiresAt: number;
}
export interface KioskSessionResponse {
roomName: string;
livekitUrl: string;
accessToken: string;
participantId: string;
}
export interface JoinRequest {
pin: string;
displayName: string;
}
export interface JoinResponse {
sessionId: string;
roomName: string;
kioskName: string;
livekitUrl: string;
accessToken: string;
participantId: string;
displayName: string;
role: ParticipantRole;
expiresAt: number;
}
export interface SessionRefreshRequest {
sessionId: string;
}
export interface SessionRefreshResponse {
roomName: string;
livekitUrl: string;
accessToken: string;
participantId: string;
displayName: string;
role: ParticipantRole;
expiresAt: number;
}
export interface AdminLoginRequest {
email: string;
password: string;
}
export interface AdminLoginResponse {
accessToken: string;
email: string;
expiresAt: number;
}
export interface CreateKioskRequest {
name: string;
location: string;
}
export interface CreateKioskResponse {
kiosk: Kiosk;
enrollmentToken: string;
}
export interface KioskDetailResponse {
kiosk: Kiosk;
layout: KioskLayout;
currentPin: string | null;
}
export interface KioskListResponse {
kiosks: Kiosk[];
}
export interface UpdateLayoutRequest {
layout: KioskLayout;
}
+60
View File
@@ -0,0 +1,60 @@
import type { AnnotationEvent } from "./annotation.js";
import type { ControlEvent } from "./control.js";
import type { WhiteboardEvent } from "./whiteboard.js";
export const DATA_TOPICS = ["annotation", "control", "whiteboard"] as const;
export type DataTopic = (typeof DATA_TOPICS)[number];
export const PROTOCOL_VERSION = 1;
export interface TopicPayloadMap {
annotation: AnnotationEvent;
control: ControlEvent;
whiteboard: WhiteboardEvent;
}
export interface DataEnvelope<T extends DataTopic = DataTopic> {
version: number;
topic: T;
senderId: string;
sentAt: number;
payload: TopicPayloadMap[T];
}
export function encodeEnvelope<T extends DataTopic>(
topic: T,
senderId: string,
payload: TopicPayloadMap[T]
): Uint8Array {
const envelope: DataEnvelope<T> = {
version: PROTOCOL_VERSION,
topic,
senderId,
sentAt: Date.now(),
payload
};
return new TextEncoder().encode(JSON.stringify(envelope));
}
export function decodeEnvelope(data: Uint8Array): DataEnvelope | null {
return parseEnvelope(new TextDecoder().decode(data));
}
export function parseEnvelope(json: string): DataEnvelope | null {
try {
const parsed = JSON.parse(json) as DataEnvelope;
if (parsed.version !== PROTOCOL_VERSION) return null;
if (!DATA_TOPICS.includes(parsed.topic)) return null;
return parsed;
} catch {
return null;
}
}
export function isTopic<T extends DataTopic>(
envelope: DataEnvelope,
topic: T
): envelope is DataEnvelope<T> {
return envelope.topic === topic;
}
+32
View File
@@ -0,0 +1,32 @@
import type { RoomMode, RoomState } from "./room.js";
export interface ModeSetEvent {
type: "mode.set";
mode: RoomMode;
}
export interface PresenterSetEvent {
type: "presenter.set";
presenterId: string | null;
}
export interface AnnotationLockEvent {
type: "annotation.lock";
locked: boolean;
}
export interface RoomStateEvent {
type: "room.state";
state: RoomState;
}
export interface RoomStateRequestEvent {
type: "room.state.request";
}
export type ControlEvent =
| ModeSetEvent
| PresenterSetEvent
| AnnotationLockEvent
| RoomStateEvent
| RoomStateRequestEvent;
+28
View File
@@ -0,0 +1,28 @@
let sequence = 0;
export function createId(prefix: string): string {
sequence += 1;
const random = Math.random().toString(36).slice(2, 10);
return `${prefix}-${Date.now().toString(36)}-${sequence.toString(36)}-${random}`;
}
export function ensureUniqueIds<T>(
items: T[],
getId: (item: T) => string,
withId: (item: T, id: string) => T,
prefix: string
): T[] {
const seen = new Set<string>();
return items.map((item) => {
const id = getId(item);
if (id && !seen.has(id)) {
seen.add(id);
return item;
}
const replacement = createId(prefix);
seen.add(replacement);
return withId(item, replacement);
});
}
+14
View File
@@ -0,0 +1,14 @@
export * from "./annotation.js";
export * from "./api.js";
export * from "./channel.js";
export * from "./control.js";
export * from "./ids.js";
export * from "./kiosk.js";
export * from "./layout-grid.js";
export * from "./time-zone.js";
export * from "./night.js";
export * from "./organization.js";
export * from "./room.js";
export * from "./whiteboard.js";
export * from "./widget.js";
export * from "./widget-builder.js";
+137
View File
@@ -0,0 +1,137 @@
import type { NightModeSettings } from "./night.js";
import type { CustomWidgetDefinition } from "./widget-builder.js";
import type { Widget } from "./widget.js";
export const KIOSK_STATUSES = ["online", "offline"] as const;
export type KioskStatus = (typeof KIOSK_STATUSES)[number];
export interface WifiMetrics {
interface: string;
linkQuality: number;
signalDbm: number;
}
export interface KioskMetrics {
cpuPercent: number | null;
memoryUsedBytes: number;
memoryTotalBytes: number;
uptimeSeconds: number;
temperatureCelsius: number | null;
wifi: WifiMetrics | null;
}
export interface Kiosk {
kioskId: string;
name: string;
location: string;
roomName: string;
status: KioskStatus;
lastSeenAt: number | null;
createdAt: number;
metrics: KioskMetrics | null;
metricsAt: number | null;
}
export function signalLabel(signalDbm: number): "excellent" | "good" | "weak" | "poor" {
if (signalDbm >= -55) return "excellent";
if (signalDbm >= -67) return "good";
if (signalDbm >= -75) return "weak";
return "poor";
}
/// Rough strength as a percentage. Wi-Fi signal is logarithmic and roughly spans -100 dBm
/// for unusable to -50 dBm for excellent, which is the range this maps onto.
export function signalPercent(signalDbm: number): number {
return Math.round(Math.min(100, Math.max(0, 2 * (signalDbm + 100))));
}
export function signalAdvice(signalDbm: number): string {
switch (signalLabel(signalDbm)) {
case "excellent":
return "Strong signal";
case "good":
return "Good signal";
case "weak":
return "Weak, video may stutter";
default:
return "Too weak, move the screen or the access point";
}
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
}
export function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
export interface KioskBackground {
/// Retained so layouts saved before rotation existed still resolve. The server folds a
/// non empty value into `images` on load, so clients should read `images`.
imageUrl: string;
images: string[];
rotationSeconds: number;
fit: "cover" | "contain";
dim: number;
}
export const DEFAULT_BACKGROUND: KioskBackground = {
imageUrl: "",
images: [],
rotationSeconds: 60,
fit: "cover",
dim: 0.35
};
export const MIN_WALLPAPER_ROTATION_SECONDS = 5;
export const DEFAULT_WIDGET_OPACITY = 0.5;
export interface KioskLayout {
kioskId: string;
backgroundColor: string;
foregroundColor: string;
widgetOpacity: number;
background: KioskBackground;
nightMode: NightModeSettings;
widgets: Widget[];
customDefinitions: CustomWidgetDefinition[];
updatedAt: number;
}
export function resolveMediaUrl(baseUrl: string, url: string): string {
if (!url) return "";
if (/^(https?:|data:|blob:)/.test(url)) return url;
return `${baseUrl.replace(/\/$/, "")}${url.startsWith("/") ? url : `/${url}`}`;
}
export const PIN_LENGTH = 6;
export const PIN_ROTATION_SECONDS = 45;
export const PIN_GRACE_SECONDS = 15;
export function isValidPin(pin: string): boolean {
return new RegExp(`^\\d{${PIN_LENGTH}}$`).test(pin);
}
export function formatPin(pin: string): string {
if (pin.length !== PIN_LENGTH) return pin;
return `${pin.slice(0, 3)} ${pin.slice(3)}`;
}
+94
View File
@@ -0,0 +1,94 @@
import type { Widget, WidgetPlacement } from "./widget.js";
import { WIDGET_GRID_COLUMNS, WIDGET_GRID_ROWS } from "./widget.js";
export type Occupancy = boolean[][];
export function buildOccupancy(widgets: Widget[], ignoreWidgetId?: string): Occupancy {
const grid: Occupancy = Array.from({ length: WIDGET_GRID_ROWS }, () =>
Array.from({ length: WIDGET_GRID_COLUMNS }, () => false)
);
for (const widget of widgets) {
if (widget.widgetId === ignoreWidgetId) continue;
const { column, row, columnSpan, rowSpan } = widget.placement;
for (let y = row - 1; y < row - 1 + rowSpan; y += 1) {
for (let x = column - 1; x < column - 1 + columnSpan; x += 1) {
if (y >= 0 && y < WIDGET_GRID_ROWS && x >= 0 && x < WIDGET_GRID_COLUMNS) {
grid[y][x] = true;
}
}
}
}
return grid;
}
export function isWithinGrid(placement: WidgetPlacement): boolean {
return (
placement.column >= 1 &&
placement.row >= 1 &&
placement.columnSpan >= 1 &&
placement.rowSpan >= 1 &&
placement.column + placement.columnSpan - 1 <= WIDGET_GRID_COLUMNS &&
placement.row + placement.rowSpan - 1 <= WIDGET_GRID_ROWS
);
}
export function isAreaFree(occupancy: Occupancy, placement: WidgetPlacement): boolean {
if (!isWithinGrid(placement)) return false;
for (let y = placement.row - 1; y < placement.row - 1 + placement.rowSpan; y += 1) {
for (let x = placement.column - 1; x < placement.column - 1 + placement.columnSpan; x += 1) {
if (occupancy[y][x]) return false;
}
}
return true;
}
export function clampPlacement(placement: WidgetPlacement): WidgetPlacement {
const columnSpan = Math.min(Math.max(1, placement.columnSpan), WIDGET_GRID_COLUMNS);
const rowSpan = Math.min(Math.max(1, placement.rowSpan), WIDGET_GRID_ROWS);
return {
columnSpan,
rowSpan,
column: Math.min(Math.max(1, placement.column), WIDGET_GRID_COLUMNS - columnSpan + 1),
row: Math.min(Math.max(1, placement.row), WIDGET_GRID_ROWS - rowSpan + 1)
};
}
export function findFreePlacement(
widgets: Widget[],
columnSpan: number,
rowSpan: number
): WidgetPlacement | null {
const occupancy = buildOccupancy(widgets);
for (const span of shrinkingSpans(columnSpan, rowSpan)) {
for (let row = 1; row <= WIDGET_GRID_ROWS - span.rowSpan + 1; row += 1) {
for (let column = 1; column <= WIDGET_GRID_COLUMNS - span.columnSpan + 1; column += 1) {
const candidate = { column, row, ...span };
if (isAreaFree(occupancy, candidate)) return candidate;
}
}
}
return null;
}
function shrinkingSpans(columnSpan: number, rowSpan: number) {
const spans: { columnSpan: number; rowSpan: number }[] = [];
let columns = Math.min(Math.max(1, columnSpan), WIDGET_GRID_COLUMNS);
let rows = Math.min(Math.max(1, rowSpan), WIDGET_GRID_ROWS);
while (columns >= 1 && rows >= 1) {
spans.push({ columnSpan: columns, rowSpan: rows });
if (columns === 1 && rows === 1) break;
if (columns >= rows) columns -= 1;
else rows -= 1;
}
return spans;
}
+73
View File
@@ -0,0 +1,73 @@
export interface NightModeSettings {
enabled: boolean;
startTime: string;
endTime: string;
timeZone: string;
showPin: boolean;
brightness: number;
}
export const DEFAULT_NIGHT_MODE: NightModeSettings = {
enabled: false,
startTime: "22:00",
endTime: "06:30",
timeZone: "",
showPin: true,
brightness: 0.45
};
export function parseTimeOfDay(value: string): number | null {
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours > 23 || minutes > 59) return null;
return hours * 60 + minutes;
}
export function formatTimeOfDay(minutesOfDay: number): string {
const normalized = ((minutesOfDay % 1440) + 1440) % 1440;
const hours = Math.floor(normalized / 60);
const minutes = normalized % 60;
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
}
export function minutesOfDayIn(timeZone: string, now: Date): number {
if (!timeZone) return now.getHours() * 60 + now.getMinutes();
try {
const parts = new Intl.DateTimeFormat("en-GB", {
timeZone,
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23"
}).formatToParts(now);
const hours = Number(parts.find((part) => part.type === "hour")?.value ?? "0");
const minutes = Number(parts.find((part) => part.type === "minute")?.value ?? "0");
return hours * 60 + minutes;
} catch {
return now.getHours() * 60 + now.getMinutes();
}
}
export function isNightModeActive(
settings: NightModeSettings,
now: Date = new Date()
): boolean {
if (!settings.enabled) return false;
const start = parseTimeOfDay(settings.startTime);
const end = parseTimeOfDay(settings.endTime);
if (start === null || end === null || start === end) return false;
const current = minutesOfDayIn(settings.timeZone, now);
if (start < end) {
return current >= start && current < end;
}
return current >= start || current < end;
}
+94
View File
@@ -0,0 +1,94 @@
export interface BrandLink {
linkId: string;
label: string;
url: string;
}
export const LANDING_MODES = ["full", "join"] as const;
export type LandingMode = (typeof LANDING_MODES)[number];
export interface BrandTheme {
surface0: string;
surface1: string;
surface2: string;
surface3: string;
ink0: string;
ink1: string;
ink2: string;
}
export const DEFAULT_THEME: BrandTheme = {
surface0: "#0b0d10",
surface1: "#14181d",
surface2: "#1c2229",
surface3: "#262e37",
ink0: "#f4f6f8",
ink1: "#a8b3c0",
ink2: "#6b7885"
};
export interface OrganizationBranding {
name: string;
headline: string;
description: string;
logoUrl: string;
accentColor: string;
joinLabel: string;
footerNote: string;
showSourceLink: boolean;
landingMode: LandingMode;
theme: BrandTheme;
links: BrandLink[];
updatedAt: number;
}
/// Inline style declaring the palette as custom properties, so every Tailwind utility
/// built on these tokens picks up the organisation's colours without touching components.
export function themeStyle(branding: OrganizationBranding): string {
const theme = { ...DEFAULT_THEME, ...(branding.theme ?? {}) };
return [
`--color-surface-0: ${theme.surface0}`,
`--color-surface-1: ${theme.surface1}`,
`--color-surface-2: ${theme.surface2}`,
`--color-surface-3: ${theme.surface3}`,
`--color-ink-0: ${theme.ink0}`,
`--color-ink-1: ${theme.ink1}`,
`--color-ink-2: ${theme.ink2}`,
`--color-accent: ${branding.accentColor}`,
`--color-accent-strong: ${branding.accentColor}`,
`background-color: ${theme.surface0}`,
`color: ${theme.ink0}`
].join("; ");
}
export const DEFAULT_BRANDING: OrganizationBranding = {
name: "PiStation",
headline: "Any screen becomes a shared screen.",
description:
"Type the code shown on screen to present, draw on what is being shown, or open a " +
"whiteboard together. No accounts, no installs, and nothing leaves the network it runs on.",
logoUrl: "",
accentColor: "#4f7cff",
joinLabel: "Enter the code on screen",
footerNote: "",
showSourceLink: true,
landingMode: "full",
theme: DEFAULT_THEME,
links: [],
updatedAt: 0
};
export function withBrandingDefaults(
branding: Partial<OrganizationBranding> | null | undefined
): OrganizationBranding {
if (!branding) return { ...DEFAULT_BRANDING, theme: { ...DEFAULT_THEME } };
return {
...DEFAULT_BRANDING,
...branding,
theme: { ...DEFAULT_THEME, ...(branding.theme ?? {}) },
links: branding.links ?? []
};
}
+29
View File
@@ -0,0 +1,29 @@
export const ROOM_MODES = ["idle", "presentation", "whiteboard"] as const;
export type RoomMode = (typeof ROOM_MODES)[number];
export const PARTICIPANT_ROLES = ["kiosk", "presenter", "viewer", "admin"] as const;
export type ParticipantRole = (typeof PARTICIPANT_ROLES)[number];
export interface ParticipantIdentity {
participantId: string;
displayName: string;
role: ParticipantRole;
}
export interface RoomState {
roomName: string;
mode: RoomMode;
presenterId: string | null;
annotationsLocked: boolean;
updatedAt: number;
}
export function canPublishScreen(role: ParticipantRole): boolean {
return role === "presenter" || role === "admin";
}
export function canChangeMode(role: ParticipantRole): boolean {
return role === "presenter" || role === "admin";
}
+86
View File
@@ -0,0 +1,86 @@
const validationCache = new Map<string, boolean>();
export function isValidTimeZone(timeZone: string): boolean {
if (!timeZone) return false;
const cached = validationCache.get(timeZone);
if (cached !== undefined) return cached;
let valid = true;
try {
new Intl.DateTimeFormat("en-GB", { timeZone }).format(new Date());
} catch {
valid = false;
}
validationCache.set(timeZone, valid);
return valid;
}
export function safeTimeZone(timeZone: string): string | undefined {
return isValidTimeZone(timeZone) ? timeZone : undefined;
}
export function localTimeZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
} catch {
return "UTC";
}
}
export function listTimeZones(): string[] {
const supported = (
Intl as unknown as { supportedValuesOf?: (key: string) => string[] }
).supportedValuesOf;
if (typeof supported === "function") {
try {
return supported.call(Intl, "timeZone");
} catch {
return COMMON_TIME_ZONES;
}
}
return COMMON_TIME_ZONES;
}
export const COMMON_TIME_ZONES = [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Phoenix",
"America/Los_Angeles",
"America/Anchorage",
"Pacific/Honolulu",
"America/Toronto",
"America/Mexico_City",
"America/Sao_Paulo",
"Europe/London",
"Europe/Dublin",
"Europe/Paris",
"Europe/Berlin",
"Europe/Madrid",
"Europe/Rome",
"Europe/Amsterdam",
"Europe/Stockholm",
"Europe/Warsaw",
"Europe/Athens",
"Europe/Moscow",
"Africa/Cairo",
"Africa/Lagos",
"Africa/Johannesburg",
"Asia/Dubai",
"Asia/Karachi",
"Asia/Kolkata",
"Asia/Bangkok",
"Asia/Shanghai",
"Asia/Hong_Kong",
"Asia/Singapore",
"Asia/Tokyo",
"Asia/Seoul",
"Australia/Perth",
"Australia/Sydney",
"Pacific/Auckland"
];
+56
View File
@@ -0,0 +1,56 @@
export interface WhiteboardElement {
id: string;
version: number;
versionNonce: number;
isDeleted?: boolean;
[key: string]: unknown;
}
export interface WhiteboardPatchEvent {
type: "whiteboard.patch";
elements: WhiteboardElement[];
}
export interface WhiteboardSnapshotEvent {
type: "whiteboard.snapshot";
elements: WhiteboardElement[];
backgroundColor: string;
}
export interface WhiteboardRequestEvent {
type: "whiteboard.request";
}
export interface WhiteboardClearEvent {
type: "whiteboard.clear";
}
export type WhiteboardEvent =
| WhiteboardPatchEvent
| WhiteboardSnapshotEvent
| WhiteboardRequestEvent
| WhiteboardClearEvent;
export function mergeWhiteboardElements(
current: WhiteboardElement[],
incoming: WhiteboardElement[]
): WhiteboardElement[] {
const byId = new Map<string, WhiteboardElement>();
for (const element of current) {
byId.set(element.id, element);
}
for (const element of incoming) {
const existing = byId.get(element.id);
if (!existing || isNewerElement(element, existing)) {
byId.set(element.id, element);
}
}
return [...byId.values()];
}
function isNewerElement(candidate: WhiteboardElement, existing: WhiteboardElement): boolean {
if (candidate.version !== existing.version) {
return candidate.version > existing.version;
}
return candidate.versionNonce < existing.versionNonce;
}
+105
View File
@@ -0,0 +1,105 @@
export const WIDGET_BLOCK_KINDS = ["heading", "text", "metric", "list", "image", "divider"] as const;
export type WidgetBlockKind = (typeof WIDGET_BLOCK_KINDS)[number];
export interface HeadingBlock {
blockId: string;
kind: "heading";
template: string;
}
export interface TextBlock {
blockId: string;
kind: "text";
template: string;
}
export interface MetricBlock {
blockId: string;
kind: "metric";
labelTemplate: string;
valueTemplate: string;
unit: string;
}
export interface ListBlock {
blockId: string;
kind: "list";
sourcePath: string;
itemTemplate: string;
maxItems: number;
}
export interface ImageBlock {
blockId: string;
kind: "image";
urlTemplate: string;
fit: "cover" | "contain";
}
export interface DividerBlock {
blockId: string;
kind: "divider";
}
export type WidgetBlock =
| HeadingBlock
| TextBlock
| MetricBlock
| ListBlock
| ImageBlock
| DividerBlock;
export interface WidgetDataSource {
url: string;
refreshSeconds: number;
rootPath: string;
}
export interface CustomWidgetDefinition {
definitionId: string;
name: string;
blocks: WidgetBlock[];
dataSource: WidgetDataSource | null;
accentColor: string;
updatedAt: number;
}
const TEMPLATE_TOKEN = /\{\{\s*([^}]+?)\s*\}\}/g;
export function resolvePath(source: unknown, path: string): unknown {
if (!path) return source;
let current: unknown = source;
for (const segment of path.split(".")) {
if (current === null || current === undefined) return undefined;
const index = Number(segment);
if (Array.isArray(current) && Number.isInteger(index)) {
current = current[index];
} else if (typeof current === "object") {
current = (current as Record<string, unknown>)[segment];
} else {
return undefined;
}
}
return current;
}
export function renderTemplate(template: string, data: unknown): string {
return template.replace(TEMPLATE_TOKEN, (_match, path: string) => {
const value = resolvePath(data, path);
if (value === null || value === undefined) return "";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
});
}
export function createEmptyDefinition(definitionId: string): CustomWidgetDefinition {
return {
definitionId,
name: "Untitled widget",
blocks: [],
dataSource: null,
accentColor: "#4f7cff",
updatedAt: Date.now()
};
}
+158
View File
@@ -0,0 +1,158 @@
export const BUILTIN_WIDGET_KINDS = [
"clock",
"weather",
"pin",
"text",
"image",
"agenda",
"custom"
] as const;
export type WidgetKind = (typeof BUILTIN_WIDGET_KINDS)[number];
export const WIDGET_GRID_COLUMNS = 12;
export const WIDGET_GRID_ROWS = 8;
export interface WidgetPlacement {
column: number;
row: number;
columnSpan: number;
rowSpan: number;
}
export interface ClockSettings {
timeZone: string;
showSeconds: boolean;
showDate: boolean;
hour12: boolean;
}
export interface WeatherSettings {
latitude: number;
longitude: number;
locationLabel: string;
units: "metric" | "imperial";
}
export interface PinSettings {
label: string;
showJoinUrl: boolean;
}
export interface TextSettings {
heading: string;
body: string;
}
export interface ImageSettings {
imageUrl: string;
fit: "cover" | "contain";
}
export interface AgendaSettings {
heading: string;
items: string[];
}
export interface CustomSettings {
definitionId: string;
}
export interface WidgetSettingsMap {
clock: ClockSettings;
weather: WeatherSettings;
pin: PinSettings;
text: TextSettings;
image: ImageSettings;
agenda: AgendaSettings;
custom: CustomSettings;
}
export const WIDGET_ALIGNMENTS = ["start", "center", "end"] as const;
export type WidgetAlign = (typeof WIDGET_ALIGNMENTS)[number];
export interface WidgetStyle {
padding: number;
align: WidgetAlign;
verticalAlign: WidgetAlign;
backgroundColor: string;
opacity: number | null;
}
export const DEFAULT_WIDGET_STYLE: WidgetStyle = {
padding: 5,
align: "start",
verticalAlign: "center",
backgroundColor: "",
opacity: null
};
function styleFor(overrides: Partial<WidgetStyle>): WidgetStyle {
return { ...DEFAULT_WIDGET_STYLE, ...overrides };
}
export const DEFAULT_WIDGET_STYLES: Record<WidgetKind, WidgetStyle> = {
clock: styleFor({}),
weather: styleFor({}),
pin: styleFor({ align: "center" }),
text: styleFor({}),
image: styleFor({ padding: 0, align: "center", opacity: 0 }),
agenda: styleFor({}),
custom: styleFor({})
};
export function toCssColor(hexColor: string, opacity: number): string {
const normalized = hexColor.replace("#", "");
if (normalized.length !== 3 && normalized.length !== 6) {
return `rgb(255 255 255 / ${opacity})`;
}
const expanded =
normalized.length === 3
? normalized
.split("")
.map((character) => character + character)
.join("")
: normalized;
const red = Number.parseInt(expanded.slice(0, 2), 16);
const green = Number.parseInt(expanded.slice(2, 4), 16);
const blue = Number.parseInt(expanded.slice(4, 6), 16);
return `rgb(${red} ${green} ${blue} / ${opacity})`;
}
export function toFlexAlign(align: WidgetAlign): string {
if (align === "center") return "center";
return align === "end" ? "flex-end" : "flex-start";
}
export function toTextAlign(align: WidgetAlign): string {
if (align === "center") return "center";
return align === "end" ? "right" : "left";
}
export interface Widget<K extends WidgetKind = WidgetKind> {
widgetId: string;
kind: K;
placement: WidgetPlacement;
settings: WidgetSettingsMap[K];
style: WidgetStyle;
enabled: boolean;
}
export const DEFAULT_WIDGET_SETTINGS: WidgetSettingsMap = {
clock: { timeZone: "UTC", showSeconds: false, showDate: true, hour12: true },
weather: {
latitude: 38.8304,
longitude: -77.3078,
locationLabel: "Fairfax",
units: "imperial"
},
pin: { label: "Join at", showJoinUrl: true },
text: { heading: "", body: "" },
image: { imageUrl: "", fit: "cover" },
agenda: { heading: "Agenda", items: [] },
custom: { definitionId: "" }
};