From bad511657edd64da5e6eb0c3a7acfaa4056535c3 Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Sun, 9 Aug 2026 17:08:55 -0400 Subject: [PATCH] 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. --- packages/client-core/package.json | 16 ++ .../client-core/src/annotation-renderer.ts | 211 ++++++++++++++++++ packages/client-core/src/annotation-state.ts | 142 ++++++++++++ packages/client-core/src/annotations.ts | 2 + packages/client-core/src/connection.ts | 151 +++++++++++++ packages/client-core/src/index.ts | 3 + packages/shared-types/package.json | 11 + packages/shared-types/src/annotation.ts | 87 ++++++++ packages/shared-types/src/api.ts | 99 ++++++++ packages/shared-types/src/channel.ts | 60 +++++ packages/shared-types/src/control.ts | 32 +++ packages/shared-types/src/ids.ts | 28 +++ packages/shared-types/src/index.ts | 14 ++ packages/shared-types/src/kiosk.ts | 137 ++++++++++++ packages/shared-types/src/layout-grid.ts | 94 ++++++++ packages/shared-types/src/night.ts | 73 ++++++ packages/shared-types/src/organization.ts | 94 ++++++++ packages/shared-types/src/room.ts | 29 +++ packages/shared-types/src/time-zone.ts | 86 +++++++ packages/shared-types/src/whiteboard.ts | 56 +++++ packages/shared-types/src/widget-builder.ts | 105 +++++++++ packages/shared-types/src/widget.ts | 158 +++++++++++++ packages/shared-types/tsconfig.json | 13 ++ 23 files changed, 1701 insertions(+) create mode 100644 packages/client-core/package.json create mode 100644 packages/client-core/src/annotation-renderer.ts create mode 100644 packages/client-core/src/annotation-state.ts create mode 100644 packages/client-core/src/annotations.ts create mode 100644 packages/client-core/src/connection.ts create mode 100644 packages/client-core/src/index.ts create mode 100644 packages/shared-types/package.json create mode 100644 packages/shared-types/src/annotation.ts create mode 100644 packages/shared-types/src/api.ts create mode 100644 packages/shared-types/src/channel.ts create mode 100644 packages/shared-types/src/control.ts create mode 100644 packages/shared-types/src/ids.ts create mode 100644 packages/shared-types/src/index.ts create mode 100644 packages/shared-types/src/kiosk.ts create mode 100644 packages/shared-types/src/layout-grid.ts create mode 100644 packages/shared-types/src/night.ts create mode 100644 packages/shared-types/src/organization.ts create mode 100644 packages/shared-types/src/room.ts create mode 100644 packages/shared-types/src/time-zone.ts create mode 100644 packages/shared-types/src/whiteboard.ts create mode 100644 packages/shared-types/src/widget-builder.ts create mode 100644 packages/shared-types/src/widget.ts create mode 100644 packages/shared-types/tsconfig.json diff --git a/packages/client-core/package.json b/packages/client-core/package.json new file mode 100644 index 0000000..b322619 --- /dev/null +++ b/packages/client-core/package.json @@ -0,0 +1,16 @@ +{ + "name": "@pistation/client-core", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./annotations": "./src/annotations.ts" + }, + "dependencies": { + "@pistation/shared-types": "workspace:*", + "livekit-client": "^2.7.2" + } +} diff --git a/packages/client-core/src/annotation-renderer.ts b/packages/client-core/src/annotation-renderer.ts new file mode 100644 index 0000000..961e9b8 --- /dev/null +++ b/packages/client-core/src/annotation-renderer.ts @@ -0,0 +1,211 @@ +import type { CompletedStroke, NormalizedPoint } from "@pistation/shared-types"; + +import type { AnnotationState } from "./annotation-state.js"; + +export interface RenderTarget { + context: CanvasRenderingContext2D; + width: number; + height: number; +} + +export interface RenderOptions { + /// Participant id to display name, used to label remote pointers. + labels?: Map; +} + +export function renderAnnotations( + target: RenderTarget, + state: AnnotationState, + options: RenderOptions = {} +): void { + const { context, width, height } = target; + context.clearRect(0, 0, width, height); + + for (const stroke of state.strokes) { + drawStroke(target, stroke); + } + + for (const pointer of state.pointers) { + drawPointer(target, pointer.point, pointer.color, options.labels?.get(pointer.participantId)); + } +} + +function drawStroke(target: RenderTarget, stroke: CompletedStroke): void { + const { context, width, height } = target; + if (stroke.points.length === 0) return; + + context.save(); + context.globalAlpha = stroke.style.opacity; + context.strokeStyle = stroke.style.color; + context.fillStyle = stroke.style.color; + context.lineWidth = Math.max(1, stroke.style.width * width); + context.lineCap = "round"; + context.lineJoin = "round"; + + const first = toPixels(stroke.points[0], width, height); + const last = toPixels(stroke.points[stroke.points.length - 1], width, height); + + switch (stroke.tool) { + case "rectangle": + context.strokeRect(first.x, first.y, last.x - first.x, last.y - first.y); + break; + + case "ellipse": { + const centerX = (first.x + last.x) / 2; + const centerY = (first.y + last.y) / 2; + context.beginPath(); + context.ellipse( + centerX, + centerY, + Math.abs(last.x - first.x) / 2, + Math.abs(last.y - first.y) / 2, + 0, + 0, + Math.PI * 2 + ); + context.stroke(); + break; + } + + case "arrow": + drawArrow(context, first, last, context.lineWidth); + break; + + case "laser": + drawPointer(target, stroke.points[stroke.points.length - 1], stroke.style.color); + break; + + default: + drawFreehand(context, stroke.points, width, height); + break; + } + + context.restore(); +} + +function drawFreehand( + context: CanvasRenderingContext2D, + points: NormalizedPoint[], + width: number, + height: number +): void { + context.beginPath(); + const start = toPixels(points[0], width, height); + context.moveTo(start.x, start.y); + + for (let index = 1; index < points.length; index += 1) { + const previous = toPixels(points[index - 1], width, height); + const current = toPixels(points[index], width, height); + const midpoint = { x: (previous.x + current.x) / 2, y: (previous.y + current.y) / 2 }; + context.quadraticCurveTo(previous.x, previous.y, midpoint.x, midpoint.y); + } + + const final = toPixels(points[points.length - 1], width, height); + context.lineTo(final.x, final.y); + context.stroke(); +} + +function drawArrow( + context: CanvasRenderingContext2D, + from: { x: number; y: number }, + to: { x: number; y: number }, + lineWidth: number +): void { + const deltaX = to.x - from.x; + const deltaY = to.y - from.y; + const length = Math.hypot(deltaX, deltaY); + if (length < 1) return; + + // Unit vector along the arrow, and the perpendicular used for the head's width. + const alongX = deltaX / length; + const alongY = deltaY / length; + const acrossX = -alongY; + const acrossY = alongX; + + // The head grows with the line, but never takes more than part of a short arrow. + const headLength = Math.min(length * 0.45, Math.max(lineWidth * 3.6, 10)); + const halfWidth = headLength * 0.42; + + const baseX = to.x - alongX * headLength; + const baseY = to.y - alongY * headLength; + + // Stopping the shaft just inside the head keeps a thick round cap from bulging out + // through the point of the arrow. + context.beginPath(); + context.moveTo(from.x, from.y); + context.lineTo(baseX + alongX * lineWidth * 0.5, baseY + alongY * lineWidth * 0.5); + context.stroke(); + + context.save(); + context.lineJoin = "miter"; + context.beginPath(); + context.moveTo(to.x, to.y); + context.lineTo(baseX + acrossX * halfWidth, baseY + acrossY * halfWidth); + context.lineTo(baseX - acrossX * halfWidth, baseY - acrossY * halfWidth); + context.closePath(); + context.fill(); + context.restore(); +} + +function drawPointer( + target: RenderTarget, + point: NormalizedPoint, + color: string, + label?: string +): void { + const { context, width, height } = target; + const position = toPixels(point, width, height); + const radius = Math.max(3, width * 0.0028); + + context.save(); + context.globalAlpha = 0.22; + context.fillStyle = color; + context.beginPath(); + context.arc(position.x, position.y, radius * 2.1, 0, Math.PI * 2); + context.fill(); + + context.globalAlpha = 1; + context.beginPath(); + context.arc(position.x, position.y, radius, 0, Math.PI * 2); + context.fill(); + + if (label) { + drawPointerLabel(context, position, radius, color, label, width); + } + + context.restore(); +} + +function drawPointerLabel( + context: CanvasRenderingContext2D, + position: { x: number; y: number }, + radius: number, + color: string, + label: string, + width: number +): void { + const fontSize = Math.max(10, width * 0.0072); + context.font = `600 ${fontSize}px Inter, system-ui, sans-serif`; + context.textBaseline = "middle"; + + const paddingX = fontSize * 0.5; + const paddingY = fontSize * 0.3; + const textWidth = context.measureText(label).width; + const boxWidth = textWidth + paddingX * 2; + const boxHeight = fontSize + paddingY * 2; + + const boxX = position.x + radius * 2.6; + const boxY = position.y - boxHeight / 2; + + context.globalAlpha = 0.92; + context.fillStyle = color; + context.fillRect(boxX, boxY, boxWidth, boxHeight); + + context.globalAlpha = 1; + context.fillStyle = "#0b0d10"; + context.fillText(label, boxX + paddingX, position.y); +} + +function toPixels(point: NormalizedPoint, width: number, height: number) { + return { x: point.x * width, y: point.y * height }; +} diff --git a/packages/client-core/src/annotation-state.ts b/packages/client-core/src/annotation-state.ts new file mode 100644 index 0000000..1a7e721 --- /dev/null +++ b/packages/client-core/src/annotation-state.ts @@ -0,0 +1,142 @@ +import type { + AnnotationEvent, + CompletedStroke, + NormalizedPoint, + StrokeStyle +} from "@pistation/shared-types"; +import { isPersistentTool } from "@pistation/shared-types"; + +export interface LiveStroke extends CompletedStroke { + isOpen: boolean; +} + +export interface RemotePointer { + participantId: string; + point: NormalizedPoint; + color: string; + updatedAt: number; +} + +export interface AnnotationState { + strokes: LiveStroke[]; + pointers: RemotePointer[]; +} + +export const POINTER_TIMEOUT_MS = 4000; + +export function createAnnotationState(): AnnotationState { + return { strokes: [], pointers: [] }; +} + +export function applyAnnotationEvent( + state: AnnotationState, + event: AnnotationEvent, + authorId: string +): AnnotationState { + switch (event.type) { + case "stroke.start": + return { + ...state, + strokes: [ + ...state.strokes.filter((stroke) => stroke.strokeId !== event.strokeId), + { + strokeId: event.strokeId, + authorId, + tool: event.tool, + style: event.style, + points: [event.point], + isOpen: true + } + ] + }; + + case "stroke.append": + return { + ...state, + strokes: state.strokes.map((stroke) => + stroke.strokeId === event.strokeId + ? { ...stroke, points: [...stroke.points, ...event.points] } + : stroke + ) + }; + + case "stroke.end": + return { + ...state, + strokes: state.strokes + .map((stroke) => + stroke.strokeId === event.strokeId ? { ...stroke, isOpen: false } : stroke + ) + .filter((stroke) => isPersistentTool(stroke.tool)) + }; + + case "stroke.erase": + return { + ...state, + strokes: state.strokes.filter((stroke) => !event.strokeIds.includes(stroke.strokeId)) + }; + + case "canvas.clear": + return { ...state, strokes: [] }; + + case "pointer.move": { + const others = state.pointers.filter((pointer) => pointer.participantId !== authorId); + if (!event.point) return { ...state, pointers: others }; + return { + ...state, + pointers: [ + ...others, + { + participantId: authorId, + point: event.point, + color: event.color, + updatedAt: Date.now() + } + ] + }; + } + + default: + return state; + } +} + +export function prunePointers(state: AnnotationState, now: number): AnnotationState { + const fresh = state.pointers.filter((pointer) => now - pointer.updatedAt < POINTER_TIMEOUT_MS); + return fresh.length === state.pointers.length ? state : { ...state, pointers: fresh }; +} + +export function eraseAtPoint( + state: AnnotationState, + point: NormalizedPoint, + radius: number +): string[] { + const hits: string[] = []; + for (const stroke of state.strokes) { + if (stroke.points.some((candidate) => distance(candidate, point) <= radius)) { + hits.push(stroke.strokeId); + } + } + return hits; +} + +/// Authors with a stroke still in progress, so the UI can say who is drawing right now. +export function activeAuthors(state: AnnotationState): string[] { + const authors = new Set(); + for (const stroke of state.strokes) { + if (stroke.isOpen) authors.add(stroke.authorId); + } + return [...authors]; +} + +export function makeStrokeId(): string { + return `s-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +export function withColor(style: StrokeStyle, color: string): StrokeStyle { + return { ...style, color }; +} + +function distance(a: NormalizedPoint, b: NormalizedPoint): number { + return Math.hypot(a.x - b.x, a.y - b.y); +} diff --git a/packages/client-core/src/annotations.ts b/packages/client-core/src/annotations.ts new file mode 100644 index 0000000..e39b208 --- /dev/null +++ b/packages/client-core/src/annotations.ts @@ -0,0 +1,2 @@ +export * from "./annotation-renderer.js"; +export * from "./annotation-state.js"; diff --git a/packages/client-core/src/connection.ts b/packages/client-core/src/connection.ts new file mode 100644 index 0000000..94b6e9d --- /dev/null +++ b/packages/client-core/src/connection.ts @@ -0,0 +1,151 @@ +import type { DataEnvelope, DataTopic, TopicPayloadMap } from "@pistation/shared-types"; +import { decodeEnvelope, encodeEnvelope } from "@pistation/shared-types"; +import { Room, RoomEvent } from "livekit-client"; + +export type ConnectionStatus = + | "idle" + | "connecting" + | "connected" + | "reconnecting" + | "disconnected" + | "failed"; + +export interface Credentials { + livekitUrl: string; + accessToken: string; +} + +export interface ConnectionHandlers { + onStatus(status: ConnectionStatus): void; + onEnvelope(envelope: DataEnvelope): void; + onReconnected?(): void; + onConnectionError?(detail: string): void; +} + +const RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 15000, 30000]; + +export function createRoom(): Room { + return new Room({ + adaptiveStream: true, + dynacast: true, + disconnectOnPageLeave: true + }); +} + +export function publishEnvelope( + room: Room, + topic: T, + senderId: string, + payload: TopicPayloadMap[T], + reliable = true +): void { + if (room.state !== "connected") return; + const data = encodeEnvelope(topic, senderId, payload); + void room.localParticipant.publishData(data, { reliable, topic }); +} + +export class RoomConnection { + readonly room: Room; + + private handlers: ConnectionHandlers; + private fetchCredentials: () => Promise; + private retryIndex = 0; + private retryTimer: ReturnType | null = null; + private stopped = false; + private visibilityListener: (() => void) | null = null; + + constructor(fetchCredentials: () => Promise, handlers: ConnectionHandlers) { + this.room = createRoom(); + this.handlers = handlers; + this.fetchCredentials = fetchCredentials; + this.bindRoomEvents(); + this.bindVisibility(); + } + + async start(): Promise { + this.stopped = false; + await this.attempt(); + } + + async stop(): Promise { + this.stopped = true; + this.clearRetry(); + if (this.visibilityListener && typeof document !== "undefined") { + document.removeEventListener("visibilitychange", this.visibilityListener); + this.visibilityListener = null; + } + await this.room.disconnect(); + this.handlers.onStatus("disconnected"); + } + + private bindRoomEvents(): void { + this.room + .on(RoomEvent.Connected, () => { + this.retryIndex = 0; + this.handlers.onStatus("connected"); + }) + .on(RoomEvent.Reconnecting, () => this.handlers.onStatus("reconnecting")) + .on(RoomEvent.Reconnected, () => { + this.retryIndex = 0; + this.handlers.onStatus("connected"); + this.handlers.onReconnected?.(); + }) + .on(RoomEvent.Disconnected, () => { + if (this.stopped) return; + this.handlers.onStatus("disconnected"); + this.scheduleRetry(); + }) + .on(RoomEvent.DataReceived, (payload: Uint8Array) => { + const envelope = decodeEnvelope(payload); + if (envelope) this.handlers.onEnvelope(envelope); + }); + } + + private bindVisibility(): void { + if (typeof document === "undefined") return; + this.visibilityListener = () => { + if (document.visibilityState !== "visible") return; + if (this.stopped) return; + if (this.room.state === "connected") return; + this.clearRetry(); + this.retryIndex = 0; + void this.attempt(); + }; + document.addEventListener("visibilitychange", this.visibilityListener); + } + + private async attempt(): Promise { + if (this.stopped) return; + this.handlers.onStatus("connecting"); + + try { + const credentials = await this.fetchCredentials(); + await this.room.connect(credentials.livekitUrl, credentials.accessToken); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.error(`[pistation] livekit connection failed: ${detail}`); + this.handlers.onConnectionError?.(detail); + this.scheduleRetry(); + } + } + + private scheduleRetry(): void { + if (this.stopped || this.retryTimer) return; + + const delay = RETRY_DELAYS_MS[Math.min(this.retryIndex, RETRY_DELAYS_MS.length - 1)]; + this.retryIndex += 1; + this.handlers.onStatus("reconnecting"); + + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + void this.attempt(); + }, delay); + } + + private clearRetry(): void { + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + } +} diff --git a/packages/client-core/src/index.ts b/packages/client-core/src/index.ts new file mode 100644 index 0000000..30b2095 --- /dev/null +++ b/packages/client-core/src/index.ts @@ -0,0 +1,3 @@ +export * from "./annotation-renderer.js"; +export * from "./annotation-state.js"; +export * from "./connection.js"; diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json new file mode 100644 index 0000000..3987801 --- /dev/null +++ b/packages/shared-types/package.json @@ -0,0 +1,11 @@ +{ + "name": "@pistation/shared-types", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/shared-types/src/annotation.ts b/packages/shared-types/src/annotation.ts new file mode 100644 index 0000000..29039ea --- /dev/null +++ b/packages/shared-types/src/annotation.ts @@ -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"; +} diff --git a/packages/shared-types/src/api.ts b/packages/shared-types/src/api.ts new file mode 100644 index 0000000..20f86e1 --- /dev/null +++ b/packages/shared-types/src/api.ts @@ -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; +} diff --git a/packages/shared-types/src/channel.ts b/packages/shared-types/src/channel.ts new file mode 100644 index 0000000..32e9560 --- /dev/null +++ b/packages/shared-types/src/channel.ts @@ -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 { + version: number; + topic: T; + senderId: string; + sentAt: number; + payload: TopicPayloadMap[T]; +} + +export function encodeEnvelope( + topic: T, + senderId: string, + payload: TopicPayloadMap[T] +): Uint8Array { + const envelope: DataEnvelope = { + 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( + envelope: DataEnvelope, + topic: T +): envelope is DataEnvelope { + return envelope.topic === topic; +} diff --git a/packages/shared-types/src/control.ts b/packages/shared-types/src/control.ts new file mode 100644 index 0000000..3cdfe8d --- /dev/null +++ b/packages/shared-types/src/control.ts @@ -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; diff --git a/packages/shared-types/src/ids.ts b/packages/shared-types/src/ids.ts new file mode 100644 index 0000000..e61e23c --- /dev/null +++ b/packages/shared-types/src/ids.ts @@ -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( + items: T[], + getId: (item: T) => string, + withId: (item: T, id: string) => T, + prefix: string +): T[] { + const seen = new Set(); + + 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); + }); +} diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts new file mode 100644 index 0000000..f3ac966 --- /dev/null +++ b/packages/shared-types/src/index.ts @@ -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"; diff --git a/packages/shared-types/src/kiosk.ts b/packages/shared-types/src/kiosk.ts new file mode 100644 index 0000000..ba1133a --- /dev/null +++ b/packages/shared-types/src/kiosk.ts @@ -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)}`; +} diff --git a/packages/shared-types/src/layout-grid.ts b/packages/shared-types/src/layout-grid.ts new file mode 100644 index 0000000..4d104a3 --- /dev/null +++ b/packages/shared-types/src/layout-grid.ts @@ -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; +} diff --git a/packages/shared-types/src/night.ts b/packages/shared-types/src/night.ts new file mode 100644 index 0000000..d82853e --- /dev/null +++ b/packages/shared-types/src/night.ts @@ -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; +} diff --git a/packages/shared-types/src/organization.ts b/packages/shared-types/src/organization.ts new file mode 100644 index 0000000..64aae8f --- /dev/null +++ b/packages/shared-types/src/organization.ts @@ -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 | null | undefined +): OrganizationBranding { + if (!branding) return { ...DEFAULT_BRANDING, theme: { ...DEFAULT_THEME } }; + + return { + ...DEFAULT_BRANDING, + ...branding, + theme: { ...DEFAULT_THEME, ...(branding.theme ?? {}) }, + links: branding.links ?? [] + }; +} diff --git a/packages/shared-types/src/room.ts b/packages/shared-types/src/room.ts new file mode 100644 index 0000000..71a9075 --- /dev/null +++ b/packages/shared-types/src/room.ts @@ -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"; +} diff --git a/packages/shared-types/src/time-zone.ts b/packages/shared-types/src/time-zone.ts new file mode 100644 index 0000000..27a0dfb --- /dev/null +++ b/packages/shared-types/src/time-zone.ts @@ -0,0 +1,86 @@ +const validationCache = new Map(); + +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" +]; diff --git a/packages/shared-types/src/whiteboard.ts b/packages/shared-types/src/whiteboard.ts new file mode 100644 index 0000000..ce8af61 --- /dev/null +++ b/packages/shared-types/src/whiteboard.ts @@ -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(); + 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; +} diff --git a/packages/shared-types/src/widget-builder.ts b/packages/shared-types/src/widget-builder.ts new file mode 100644 index 0000000..f46ab07 --- /dev/null +++ b/packages/shared-types/src/widget-builder.ts @@ -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)[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() + }; +} diff --git a/packages/shared-types/src/widget.ts b/packages/shared-types/src/widget.ts new file mode 100644 index 0000000..86176d9 --- /dev/null +++ b/packages/shared-types/src/widget.ts @@ -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 { + return { ...DEFAULT_WIDGET_STYLE, ...overrides }; +} + +export const DEFAULT_WIDGET_STYLES: Record = { + 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 { + 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: "" } +}; diff --git a/packages/shared-types/tsconfig.json b/packages/shared-types/tsconfig.json new file mode 100644 index 0000000..ac81f73 --- /dev/null +++ b/packages/shared-types/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "noEmit": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +}