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.
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./annotation-renderer.js";
|
||||||
|
export * from "./annotation-state.js";
|
||||||
@@ -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<T extends DataTopic>(
|
||||||
|
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<Credentials>;
|
||||||
|
private retryIndex = 0;
|
||||||
|
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private stopped = false;
|
||||||
|
private visibilityListener: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor(fetchCredentials: () => Promise<Credentials>, handlers: ConnectionHandlers) {
|
||||||
|
this.room = createRoom();
|
||||||
|
this.handlers = handlers;
|
||||||
|
this.fetchCredentials = fetchCredentials;
|
||||||
|
this.bindRoomEvents();
|
||||||
|
this.bindVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.stopped = false;
|
||||||
|
await this.attempt();
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./annotation-renderer.js";
|
||||||
|
export * from "./annotation-state.js";
|
||||||
|
export * from "./connection.js";
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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: "" }
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"verbatimModuleSyntax": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user