Add web client
Join screen, room with screen and camera sharing, live annotation, whiteboard, and the admin panel for kiosks, widgets and branding.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
PUBLIC_API_URL=http://localhost:8080
|
||||
@@ -0,0 +1,25 @@
|
||||
FROM oven/bun:1 AS builder
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=development
|
||||
|
||||
COPY package.json ./
|
||||
COPY packages packages
|
||||
COPY apps/web-client apps/web-client
|
||||
|
||||
RUN bun install
|
||||
|
||||
WORKDIR /app/apps/web-client
|
||||
RUN bun run build
|
||||
|
||||
FROM oven/bun:1-slim
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/packages ./packages
|
||||
COPY --from=builder /app/apps/web-client ./apps/web-client
|
||||
|
||||
WORKDIR /app/apps/web-client
|
||||
EXPOSE 3000
|
||||
CMD ["bun", "./build/index.js"]
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@pistation/web-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5173",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "^0.17.6",
|
||||
"@pistation/client-core": "workspace:*",
|
||||
"@pistation/shared-types": "workspace:*",
|
||||
"@pistation/ui": "workspace:*",
|
||||
"livekit-client": "^2.7.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/svelte": "^4.0.2",
|
||||
"@sveltejs/adapter-node": "^5.2.9",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "../../../packages/ui/src";
|
||||
|
||||
@theme {
|
||||
--color-surface-0: #0b0d10;
|
||||
--color-surface-1: #14181d;
|
||||
--color-surface-2: #1c2229;
|
||||
--color-surface-3: #262e37;
|
||||
--color-ink-0: #f4f6f8;
|
||||
--color-ink-1: #a8b3c0;
|
||||
--color-ink-2: #6b7885;
|
||||
--color-accent: #4f7cff;
|
||||
--color-accent-strong: #3563e9;
|
||||
--color-danger: #ff3b52;
|
||||
--color-success: #21c17a;
|
||||
--radius-none: 0px;
|
||||
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "SF Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
background-color: var(--color-surface-0);
|
||||
color: var(--color-ink-0);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
button {
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
button:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-surface-1);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-surface-3);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/logo.svg" type="image/svg+xml" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" class="h-full">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { HandleServerError } from "@sveltejs/kit";
|
||||
|
||||
export const handleError: HandleServerError = ({ error, event }) => {
|
||||
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
console.error(`[error] ${event.request.method} ${event.url.pathname}\n${detail}`);
|
||||
|
||||
return {
|
||||
message: "Something went wrong rendering this page."
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
import type {
|
||||
AdminLoginResponse,
|
||||
CreateKioskResponse,
|
||||
JoinResponse,
|
||||
Kiosk,
|
||||
KioskDetailResponse,
|
||||
KioskListResponse,
|
||||
OrganizationBranding,
|
||||
SessionRefreshResponse
|
||||
} from "@pistation/shared-types";
|
||||
|
||||
import { apiBaseUrl } from "./config";
|
||||
|
||||
export { apiBaseUrl };
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
token?: string | null;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (options.body !== undefined) headers["content-type"] = "application/json";
|
||||
if (options.token) headers["authorization"] = `Bearer ${options.token}`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
method: options.method ?? "GET",
|
||||
headers,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body)
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, "network", "Could not reach the PiStation server.");
|
||||
}
|
||||
|
||||
if (response.status === 204) return undefined as T;
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const code = (payload as { error?: string })?.error ?? "unknown";
|
||||
const message = (payload as { message?: string })?.message ?? "Something went wrong.";
|
||||
throw new ApiError(response.status, code, message);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export function joinRoom(pin: string, displayName: string): Promise<JoinResponse> {
|
||||
return request<JoinResponse>("/api/join", {
|
||||
method: "POST",
|
||||
body: { pin, displayName }
|
||||
});
|
||||
}
|
||||
|
||||
export function refreshSession(sessionId: string): Promise<SessionRefreshResponse> {
|
||||
return request<SessionRefreshResponse>("/api/session/refresh", {
|
||||
method: "POST",
|
||||
body: { sessionId }
|
||||
});
|
||||
}
|
||||
|
||||
export function adminLogin(email: string, password: string): Promise<AdminLoginResponse> {
|
||||
return request<AdminLoginResponse>("/api/admin/login", {
|
||||
method: "POST",
|
||||
body: { email, password }
|
||||
});
|
||||
}
|
||||
|
||||
export function listKiosks(token: string): Promise<KioskListResponse> {
|
||||
return request<KioskListResponse>("/api/admin/kiosks", { token });
|
||||
}
|
||||
|
||||
export function createKiosk(
|
||||
token: string,
|
||||
name: string,
|
||||
location: string
|
||||
): Promise<CreateKioskResponse> {
|
||||
return request<CreateKioskResponse>("/api/admin/kiosks", {
|
||||
method: "POST",
|
||||
body: { name, location },
|
||||
token
|
||||
});
|
||||
}
|
||||
|
||||
export function getKiosk(token: string, kioskId: string): Promise<KioskDetailResponse> {
|
||||
return request<KioskDetailResponse>(`/api/admin/kiosks/${kioskId}`, { token });
|
||||
}
|
||||
|
||||
export function getBranding(): Promise<OrganizationBranding> {
|
||||
return request<OrganizationBranding>("/api/organization");
|
||||
}
|
||||
|
||||
export function saveBranding(
|
||||
token: string,
|
||||
branding: OrganizationBranding
|
||||
): Promise<OrganizationBranding> {
|
||||
return request<OrganizationBranding>("/api/admin/organization", {
|
||||
method: "PUT",
|
||||
body: { branding },
|
||||
token
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadLogo(token: string, file: File): Promise<{ imageUrl: string }> {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${apiBaseUrl}/api/admin/organization/logo`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": file.type, authorization: `Bearer ${token}` },
|
||||
body: file
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, "network", "Could not reach the PiStation server.");
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = (payload as { message?: string })?.message ?? "Upload failed.";
|
||||
throw new ApiError(response.status, "upload", message);
|
||||
}
|
||||
|
||||
return payload as { imageUrl: string };
|
||||
}
|
||||
|
||||
export function updateKiosk(
|
||||
token: string,
|
||||
kioskId: string,
|
||||
name: string,
|
||||
location: string
|
||||
): Promise<Kiosk> {
|
||||
return request<Kiosk>(`/api/admin/kiosks/${kioskId}`, {
|
||||
method: "PATCH",
|
||||
body: { name, location },
|
||||
token
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteKiosk(token: string, kioskId: string): Promise<void> {
|
||||
return request<void>(`/api/admin/kiosks/${kioskId}`, { method: "DELETE", token });
|
||||
}
|
||||
|
||||
export function saveKioskLayout(
|
||||
token: string,
|
||||
kioskId: string,
|
||||
layout: unknown
|
||||
): Promise<unknown> {
|
||||
return request(`/api/admin/kiosks/${kioskId}/layout`, {
|
||||
method: "PUT",
|
||||
body: { layout },
|
||||
token
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadWallpaper(
|
||||
token: string,
|
||||
kioskId: string,
|
||||
file: File
|
||||
): Promise<{ imageUrl: string }> {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${apiBaseUrl}/api/admin/kiosks/${kioskId}/wallpaper`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": file.type,
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: file
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, "network", "Could not reach the PiStation server.");
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = (payload as { message?: string })?.message ?? "Upload failed.";
|
||||
throw new ApiError(response.status, "upload", message);
|
||||
}
|
||||
|
||||
return payload as { imageUrl: string };
|
||||
}
|
||||
|
||||
export async function uploadKioskPackage(
|
||||
token: string,
|
||||
file: File
|
||||
): Promise<{ downloadUrl: string; sizeBytes: number }> {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${apiBaseUrl}/api/admin/packages/kiosk`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": "application/vnd.debian.binary-package",
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
body: file
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, "network", "Could not reach the PiStation server.");
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = (payload as { message?: string })?.message ?? "Upload failed.";
|
||||
throw new ApiError(response.status, "upload", message);
|
||||
}
|
||||
|
||||
return payload as { downloadUrl: string; sizeBytes: number };
|
||||
}
|
||||
|
||||
export function rotateEnrollment(
|
||||
token: string,
|
||||
kioskId: string
|
||||
): Promise<{ enrollmentToken: string }> {
|
||||
return request(`/api/admin/kiosks/${kioskId}/enrollment`, { method: "POST", token });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { OrganizationBranding } from "@pistation/shared-types";
|
||||
import { withBrandingDefaults } from "@pistation/shared-types";
|
||||
|
||||
import { getBranding } from "./api";
|
||||
|
||||
/// Shared so the layout can paint the theme and pages can read the copy, without every
|
||||
/// page fetching branding for itself.
|
||||
export const branding = $state<{ value: OrganizationBranding }>({
|
||||
value: withBrandingDefaults(null)
|
||||
});
|
||||
|
||||
let hasRequested = false;
|
||||
|
||||
export async function ensureBranding(): Promise<void> {
|
||||
if (hasRequested) return;
|
||||
hasRequested = true;
|
||||
|
||||
try {
|
||||
branding.value = withBrandingDefaults(await getBranding());
|
||||
} catch {
|
||||
// Defaults are already in place, so a server that is not up yet just means the
|
||||
// stock palette and copy.
|
||||
}
|
||||
}
|
||||
|
||||
export function applyBranding(loaded: OrganizationBranding): void {
|
||||
branding.value = withBrandingDefaults(loaded);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { DEFAULT_STROKE_STYLE, HIGHLIGHTER_STYLE } from "@pistation/shared-types";
|
||||
|
||||
import type { RoomController } from "$lib/room.svelte";
|
||||
|
||||
let { controller }: { controller: RoomController } = $props();
|
||||
|
||||
const tools = [
|
||||
{ id: "pen", icon: "ph:pencil-simple-bold", label: "Pen" },
|
||||
{ id: "highlighter", icon: "ph:highlighter-bold", label: "Highlighter" },
|
||||
{ id: "arrow", icon: "ph:arrow-up-right-bold", label: "Arrow" },
|
||||
{ id: "rectangle", icon: "ph:rectangle-bold", label: "Rectangle" },
|
||||
{ id: "ellipse", icon: "ph:circle-bold", label: "Ellipse" },
|
||||
{ id: "laser", icon: "ph:cursor-bold", label: "Laser" }
|
||||
] as const;
|
||||
|
||||
const colors = ["#ff2d55", "#ffd60a", "#21c17a", "#4f7cff", "#f4f6f8"];
|
||||
|
||||
function isToolActive(tool: string): boolean {
|
||||
return controller.tool === tool && !controller.isEraser && !controller.isPointerMode;
|
||||
}
|
||||
|
||||
const thicknessValue = $derived(Math.round(controller.strokeStyle.width * 1000));
|
||||
|
||||
const thicknessPreview = $derived(
|
||||
Math.max(4, Math.min(22, Math.round(controller.strokeStyle.width * 1000) + 3))
|
||||
);
|
||||
|
||||
function selectTool(tool: (typeof tools)[number]["id"]) {
|
||||
controller.isEraser = false;
|
||||
controller.isPointerMode = false;
|
||||
controller.tool = tool;
|
||||
|
||||
// Switching tools keeps the colour and the thickness the user picked. Only the
|
||||
// highlighter overrides them, because a thin opaque highlighter is useless.
|
||||
controller.strokeStyle =
|
||||
tool === "highlighter"
|
||||
? { ...HIGHLIGHTER_STYLE }
|
||||
: {
|
||||
...DEFAULT_STROKE_STYLE,
|
||||
color: controller.strokeStyle.color,
|
||||
width: controller.strokeStyle.width
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1 bg-surface-2 p-2">
|
||||
{#each tools as tool}
|
||||
<button
|
||||
onclick={() => selectTool(tool.id)}
|
||||
title={tool.label}
|
||||
aria-label={tool.label}
|
||||
class="flex h-10 w-10 items-center justify-center transition-colors"
|
||||
class:bg-accent={isToolActive(tool.id)}
|
||||
class:text-white={isToolActive(tool.id)}
|
||||
class:text-ink-1={!isToolActive(tool.id)}
|
||||
class:hover:bg-surface-3={!isToolActive(tool.id)}
|
||||
>
|
||||
<Icon icon={tool.icon} width="18" />
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
onclick={() => {
|
||||
controller.isPointerMode = !controller.isPointerMode;
|
||||
if (controller.isPointerMode) controller.isEraser = false;
|
||||
}}
|
||||
title="Pointer, others see where you are pointing"
|
||||
aria-label="Pointer"
|
||||
class="flex h-10 w-10 items-center justify-center transition-colors"
|
||||
class:bg-accent={controller.isPointerMode}
|
||||
class:text-white={controller.isPointerMode}
|
||||
class:text-ink-1={!controller.isPointerMode}
|
||||
class:hover:bg-surface-3={!controller.isPointerMode}
|
||||
>
|
||||
<Icon icon="ph:hand-pointing-bold" width="18" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => {
|
||||
controller.isEraser = !controller.isEraser;
|
||||
if (controller.isEraser) controller.isPointerMode = false;
|
||||
}}
|
||||
title="Eraser"
|
||||
aria-label="Eraser"
|
||||
class="flex h-10 w-10 items-center justify-center transition-colors"
|
||||
class:bg-accent={controller.isEraser}
|
||||
class:text-white={controller.isEraser}
|
||||
class:text-ink-1={!controller.isEraser}
|
||||
class:hover:bg-surface-3={!controller.isEraser}
|
||||
>
|
||||
<Icon icon="ph:eraser-bold" width="18" />
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-8 w-px bg-surface-3"></div>
|
||||
|
||||
<label class="flex items-center gap-2 px-2" title="Line thickness">
|
||||
<span class="text-xs tracking-wide text-ink-2 uppercase">Thickness</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="30"
|
||||
value={thicknessValue}
|
||||
oninput={(event) =>
|
||||
(controller.strokeStyle = {
|
||||
...controller.strokeStyle,
|
||||
width: Number((event.target as HTMLInputElement).value) / 1000
|
||||
})}
|
||||
class="w-28"
|
||||
aria-label="Line thickness"
|
||||
/>
|
||||
<span class="w-6 text-right font-mono text-xs text-ink-1">{thicknessValue}</span>
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-current"
|
||||
style={`width: ${thicknessPreview}px; height: ${thicknessPreview}px; color: ${controller.strokeStyle.color};`}
|
||||
></span>
|
||||
</label>
|
||||
|
||||
<div class="mx-1 h-8 w-px bg-surface-3"></div>
|
||||
|
||||
{#each colors as color}
|
||||
<button
|
||||
onclick={() => (controller.strokeStyle = { ...controller.strokeStyle, color })}
|
||||
title={`Colour ${color}`}
|
||||
aria-label={`Colour ${color}`}
|
||||
class="h-8 w-8 transition-transform"
|
||||
class:scale-90={controller.strokeStyle.color !== color}
|
||||
style={`background-color: ${color}`}
|
||||
></button>
|
||||
{/each}
|
||||
|
||||
<div class="mx-1 h-8 w-px bg-surface-3"></div>
|
||||
|
||||
<button
|
||||
onclick={() => controller.clearAnnotations()}
|
||||
title="Clear annotations"
|
||||
aria-label="Clear annotations"
|
||||
class="flex h-10 items-center gap-2 px-3 text-sm text-ink-1 transition-colors hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:trash-bold" width="18" />
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { ConnectionStatus } from "@pistation/client-core";
|
||||
|
||||
let { status }: { status: ConnectionStatus } = $props();
|
||||
|
||||
const presentation = {
|
||||
idle: { label: "Idle", icon: "ph:circle-bold", color: "text-ink-2" },
|
||||
connecting: { label: "Connecting", icon: "ph:circle-notch-bold", color: "text-ink-1" },
|
||||
connected: { label: "Live", icon: "ph:circle-fill", color: "text-success" },
|
||||
reconnecting: { label: "Reconnecting", icon: "ph:circle-notch-bold", color: "text-accent" },
|
||||
disconnected: { label: "Offline", icon: "ph:circle-fill", color: "text-danger" },
|
||||
failed: { label: "Failed", icon: "ph:warning-circle-bold", color: "text-danger" }
|
||||
} as const;
|
||||
|
||||
const current = $derived(presentation[status]);
|
||||
const isSpinning = $derived(status === "connecting" || status === "reconnecting");
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2 bg-surface-2 px-3 py-2 text-sm">
|
||||
<Icon
|
||||
icon={current.icon}
|
||||
width="12"
|
||||
class={`${current.color} ${isSpinning ? "animate-spin" : ""}`}
|
||||
/>
|
||||
<span class="text-ink-1">{current.label}</span>
|
||||
</div>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { PIN_LENGTH } from "@pistation/shared-types";
|
||||
import { Logo } from "@pistation/ui";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
import { ApiError, joinRoom } from "$lib/api";
|
||||
import PinInput from "$lib/components/PinInput.svelte";
|
||||
import { saveSession } from "$lib/session";
|
||||
|
||||
let { label = "Enter the code on screen" }: { label?: string } = $props();
|
||||
|
||||
let pin = $state("");
|
||||
let displayName = $state("");
|
||||
let isJoining = $state(false);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
|
||||
const isReady = $derived(
|
||||
pin.replace(/\D/g, "").length === PIN_LENGTH && displayName.trim().length > 0
|
||||
);
|
||||
|
||||
async function join() {
|
||||
if (!isReady || isJoining) return;
|
||||
|
||||
isJoining = true;
|
||||
errorMessage = null;
|
||||
|
||||
try {
|
||||
const session = await joinRoom(pin, displayName);
|
||||
saveSession(session);
|
||||
await goto("/room");
|
||||
} catch (error) {
|
||||
errorMessage =
|
||||
error instanceof ApiError ? error.message : "Could not join. Check the PIN and try again.";
|
||||
isJoining = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex w-full max-w-md flex-col bg-surface-1 p-6 sm:p-8">
|
||||
<Logo size={40} class="mx-auto mb-4" />
|
||||
<p class="mb-1 text-center text-sm tracking-wide text-ink-2 uppercase">{label}</p>
|
||||
<p class="mb-6 text-center text-sm text-ink-2">It rotates every minute</p>
|
||||
|
||||
<PinInput bind:value={pin} onComplete={join} />
|
||||
|
||||
<label class="mt-6 block">
|
||||
<span class="mb-2 block text-sm font-medium text-ink-1">
|
||||
Your name
|
||||
<span class="text-ink-2">required</span>
|
||||
</span>
|
||||
<input
|
||||
bind:value={displayName}
|
||||
placeholder="So the room knows who you are"
|
||||
maxlength="32"
|
||||
required
|
||||
class="w-full bg-surface-2 px-4 py-3 text-ink-0 placeholder:text-ink-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="mt-4 flex items-center gap-2 bg-danger/15 px-4 py-3 text-sm text-danger">
|
||||
<Icon icon="ph:warning-circle-bold" width="18" class="shrink-0" />
|
||||
{errorMessage}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={join}
|
||||
disabled={!isReady || isJoining}
|
||||
class="mt-5 flex w-full items-center justify-center gap-2 bg-accent px-6 py-4 text-base font-semibold text-white transition-colors hover:bg-accent-strong disabled:bg-surface-3 disabled:text-ink-2"
|
||||
>
|
||||
{#if isJoining}
|
||||
<Icon icon="ph:circle-notch-bold" width="20" class="animate-spin" />
|
||||
Connecting
|
||||
{:else}
|
||||
<Icon icon="ph:sign-in-bold" width="20" />
|
||||
Join screen
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
|
||||
import type { RoomParticipant } from "$lib/room.svelte";
|
||||
|
||||
let { participants }: { participants: RoomParticipant[] } = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let container = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const close = (event: MouseEvent) => {
|
||||
if (container && !container.contains(event.target as Node)) isOpen = false;
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container} class="relative">
|
||||
<button
|
||||
onclick={() => (isOpen = !isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
class="flex items-center gap-2 bg-surface-2 px-3 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:users-bold" width="16" />
|
||||
{participants.length}
|
||||
<Icon icon={isOpen ? "ph:caret-up-bold" : "ph:caret-down-bold"} width="12" />
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="absolute top-full right-0 z-20 mt-px w-64 bg-surface-1 shadow-lg">
|
||||
<p class="bg-surface-2 px-4 py-2 text-xs tracking-wide text-ink-2 uppercase">In this room</p>
|
||||
|
||||
<ul class="max-h-72 overflow-y-auto">
|
||||
{#each participants as participant (participant.participantId)}
|
||||
{@const isBroadcasting = participant.isSharing || participant.isCameraOn}
|
||||
<li class="flex items-center gap-2 px-4 py-2.5 text-sm">
|
||||
<Icon
|
||||
icon={participant.isSharing
|
||||
? "ph:broadcast-bold"
|
||||
: participant.isCameraOn
|
||||
? "ph:video-camera-bold"
|
||||
: "ph:user-bold"}
|
||||
width="16"
|
||||
class={isBroadcasting ? "shrink-0 text-accent" : "shrink-0 text-ink-2"}
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-ink-0">
|
||||
{participant.displayName}
|
||||
</span>
|
||||
{#if participant.isSelf}
|
||||
<span class="shrink-0 text-xs text-ink-2">you</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { PIN_LENGTH } from "@pistation/shared-types";
|
||||
|
||||
let { value = $bindable(""), onComplete }: {
|
||||
value?: string;
|
||||
onComplete?: (pin: string) => void;
|
||||
} = $props();
|
||||
|
||||
let inputs: HTMLInputElement[] = $state([]);
|
||||
|
||||
const slots = $derived(
|
||||
Array.from({ length: PIN_LENGTH }, (_, index) => value[index] ?? "")
|
||||
);
|
||||
|
||||
function setDigit(index: number, digit: string) {
|
||||
const characters = value.padEnd(PIN_LENGTH, " ").split("");
|
||||
characters[index] = digit;
|
||||
value = characters.join("").trimEnd();
|
||||
|
||||
if (digit && index < PIN_LENGTH - 1) {
|
||||
inputs[index + 1]?.focus();
|
||||
}
|
||||
|
||||
if (value.replace(/\s/g, "").length === PIN_LENGTH) {
|
||||
onComplete?.(value);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function handleInput(index: number, event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const digit = target.value.replace(/\D/g, "").slice(-1);
|
||||
target.value = digit;
|
||||
setDigit(index, digit);
|
||||
}
|
||||
|
||||
function handleKeydown(index: number, event: KeyboardEvent) {
|
||||
if (event.key === "Backspace" && !slots[index] && index > 0) {
|
||||
inputs[index - 1]?.focus();
|
||||
setDigit(index - 1, "");
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft" && index > 0) inputs[index - 1]?.focus();
|
||||
if (event.key === "ArrowRight" && index < PIN_LENGTH - 1) inputs[index + 1]?.focus();
|
||||
}
|
||||
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
const pasted = event.clipboardData?.getData("text")?.replace(/\D/g, "") ?? "";
|
||||
if (!pasted) return;
|
||||
event.preventDefault();
|
||||
value = pasted.slice(0, PIN_LENGTH);
|
||||
inputs[Math.min(value.length, PIN_LENGTH - 1)]?.focus();
|
||||
if (value.length === PIN_LENGTH) onComplete?.(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex w-full flex-nowrap gap-1.5 sm:gap-2" onpaste={handlePaste}>
|
||||
{#each slots as digit, index}
|
||||
<input
|
||||
bind:this={inputs[index]}
|
||||
value={digit}
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="1"
|
||||
aria-label={`PIN digit ${index + 1}`}
|
||||
class="h-16 min-w-0 flex-1 bg-surface-2 px-0 text-center font-mono text-2xl text-ink-0 transition-colors focus:bg-surface-3 sm:h-20 sm:text-4xl"
|
||||
oninput={(event) => handleInput(index, event)}
|
||||
onkeydown={(event) => handleKeydown(index, event)}
|
||||
onfocus={(event) => (event.target as HTMLInputElement).select()}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { KioskLayout } from "@pistation/shared-types";
|
||||
import { MIN_WALLPAPER_ROTATION_SECONDS, resolveMediaUrl } from "@pistation/shared-types";
|
||||
|
||||
import { apiBaseUrl } from "$lib/api";
|
||||
|
||||
let {
|
||||
layout,
|
||||
onChange,
|
||||
onUpload,
|
||||
isUploading = false,
|
||||
uploadError = null
|
||||
}: {
|
||||
layout: KioskLayout;
|
||||
onChange: (layout: KioskLayout) => void;
|
||||
onUpload: (files: File[]) => void;
|
||||
isUploading?: boolean;
|
||||
uploadError?: string | null;
|
||||
} = $props();
|
||||
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
const images = $derived(layout.background.images ?? []);
|
||||
|
||||
function patchBackground(patch: Partial<KioskLayout["background"]>) {
|
||||
onChange({ ...layout, background: { ...layout.background, ...patch } });
|
||||
}
|
||||
|
||||
function removeImage(image: string) {
|
||||
patchBackground({ images: images.filter((candidate) => candidate !== image) });
|
||||
}
|
||||
|
||||
function handleFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = [...(input.files ?? [])];
|
||||
if (files.length > 0) onUpload(files);
|
||||
input.value = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4 bg-surface-1 p-5">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">Appearance</h3>
|
||||
|
||||
<label class="flex items-center justify-between gap-3 text-sm text-ink-1">
|
||||
Background colour
|
||||
<input
|
||||
type="color"
|
||||
value={layout.backgroundColor}
|
||||
oninput={(event) =>
|
||||
onChange({ ...layout, backgroundColor: (event.target as HTMLInputElement).value })}
|
||||
class="h-9 w-16 bg-surface-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center justify-between gap-3 text-sm text-ink-1">
|
||||
Text colour
|
||||
<input
|
||||
type="color"
|
||||
value={layout.foregroundColor}
|
||||
oninput={(event) =>
|
||||
onChange({ ...layout, foregroundColor: (event.target as HTMLInputElement).value })}
|
||||
class="h-9 w-16 bg-surface-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">
|
||||
Widget panel opacity {Math.round((layout.widgetOpacity ?? 0.5) * 100)} percent
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={(layout.widgetOpacity ?? 0.5) * 100}
|
||||
oninput={(event) =>
|
||||
onChange({
|
||||
...layout,
|
||||
widgetOpacity: Number((event.target as HTMLInputElement).value) / 100
|
||||
})}
|
||||
class="w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-col gap-3 bg-surface-2 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="flex-1 text-xs tracking-wide text-ink-2 uppercase">Wallpapers</p>
|
||||
{#if images.length > 0}
|
||||
<span class="text-xs text-ink-2">{images.length}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if images.length === 0}
|
||||
<p class="bg-surface-1 px-4 py-6 text-center text-sm text-ink-2">No wallpaper set</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each images as image, index (image)}
|
||||
<div class="group relative">
|
||||
<img
|
||||
src={resolveMediaUrl(apiBaseUrl, image)}
|
||||
alt={`Wallpaper ${index + 1}`}
|
||||
class="h-16 w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
onclick={() => removeImage(image)}
|
||||
aria-label={`Remove wallpaper ${index + 1}`}
|
||||
class="absolute top-0 right-0 flex h-6 w-6 items-center justify-center bg-surface-0/80 text-danger opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<Icon icon="ph:x-bold" width="12" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if uploadError}
|
||||
<p class="bg-danger/15 px-3 py-2 text-sm text-danger">{uploadError}</p>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
multiple
|
||||
onchange={handleFile}
|
||||
class="hidden"
|
||||
/>
|
||||
|
||||
<button
|
||||
onclick={() => fileInput?.click()}
|
||||
disabled={isUploading}
|
||||
class="flex items-center justify-center gap-2 bg-surface-1 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3 disabled:text-ink-2"
|
||||
>
|
||||
{#if isUploading}
|
||||
<Icon icon="ph:circle-notch-bold" width="16" class="animate-spin" />
|
||||
Uploading
|
||||
{:else}
|
||||
<Icon icon="ph:upload-simple-bold" width="16" />
|
||||
Add images
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<p class="text-xs text-ink-2">PNG, JPEG, WEBP or GIF, up to 8 MB each.</p>
|
||||
|
||||
{#if images.length > 1}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">
|
||||
Change every {layout.background.rotationSeconds ?? 60} seconds
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_WALLPAPER_ROTATION_SECONDS}
|
||||
max="600"
|
||||
step="5"
|
||||
value={layout.background.rotationSeconds ?? 60}
|
||||
oninput={(event) =>
|
||||
patchBackground({
|
||||
rotationSeconds: Number((event.target as HTMLInputElement).value)
|
||||
})}
|
||||
class="w-full"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if images.length > 0}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Fit</span>
|
||||
<select
|
||||
value={layout.background.fit}
|
||||
onchange={(event) =>
|
||||
patchBackground({
|
||||
fit: (event.target as HTMLSelectElement).value as "cover" | "contain"
|
||||
})}
|
||||
class="w-full bg-surface-1 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="cover">Cover</option>
|
||||
<option value="contain">Contain</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">
|
||||
Dim {Math.round(layout.background.dim * 100)} percent
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={layout.background.dim * 100}
|
||||
oninput={(event) =>
|
||||
patchBackground({ dim: Number((event.target as HTMLInputElement).value) / 100 })}
|
||||
class="w-full"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
|
||||
import { apiBaseUrl } from "$lib/api";
|
||||
|
||||
let {
|
||||
enrollmentToken,
|
||||
kioskName = ""
|
||||
}: {
|
||||
enrollmentToken: string;
|
||||
kioskName?: string;
|
||||
} = $props();
|
||||
|
||||
let hasCopied = $state(false);
|
||||
|
||||
const command = $derived(
|
||||
`curl -fsSL ${apiBaseUrl}/install.sh | sudo bash -s -- --key ${enrollmentToken}`
|
||||
);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
hasCopied = true;
|
||||
setTimeout(() => (hasCopied = false), 2000);
|
||||
} catch {
|
||||
hasCopied = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-3 overflow-hidden bg-surface-1 p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="ph:terminal-window-bold" width="18" class="text-accent" />
|
||||
<h3 class="flex-1 text-sm font-semibold tracking-wide uppercase">
|
||||
Set up {kioskName || "this kiosk"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-ink-2">
|
||||
Run this once on the Raspberry Pi over SSH, with sudo as shown. It installs the kiosk,
|
||||
enlarges swap, tunes video for the board it finds, and reboots straight into the display.
|
||||
The token works only until the Pi enrolls. Needs the 64 bit Raspberry Pi OS.
|
||||
</p>
|
||||
|
||||
<div class="flex min-w-0 items-stretch gap-px">
|
||||
<code
|
||||
class="min-w-0 flex-1 overflow-x-auto bg-surface-0 px-4 py-3 font-mono text-sm whitespace-pre text-accent"
|
||||
>{command}</code
|
||||
>
|
||||
<button
|
||||
onclick={copy}
|
||||
aria-label="Copy install command"
|
||||
class="flex w-12 shrink-0 items-center justify-center bg-surface-2 text-ink-1 transition-colors hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon={hasCopied ? "ph:check-bold" : "ph:copy-bold"} width="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs break-words text-ink-2">
|
||||
The Pi must be able to reach {apiBaseUrl}. Upload a kiosk build on the admin home page first,
|
||||
otherwise the script has nothing to install.
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { KioskMetrics } from "@pistation/shared-types";
|
||||
import {
|
||||
formatBytes,
|
||||
formatUptime,
|
||||
signalAdvice,
|
||||
signalLabel,
|
||||
signalPercent
|
||||
} from "@pistation/shared-types";
|
||||
|
||||
let {
|
||||
metrics,
|
||||
metricsAt,
|
||||
compact = false
|
||||
}: {
|
||||
metrics: KioskMetrics | null;
|
||||
metricsAt: number | null;
|
||||
compact?: boolean;
|
||||
} = $props();
|
||||
|
||||
const SIGNAL_COLORS = {
|
||||
excellent: "text-success",
|
||||
good: "text-success",
|
||||
weak: "text-accent",
|
||||
poor: "text-danger"
|
||||
} as const;
|
||||
|
||||
const SIGNAL_BARS = {
|
||||
excellent: "bg-success",
|
||||
good: "bg-success",
|
||||
weak: "bg-accent",
|
||||
poor: "bg-danger"
|
||||
} as const;
|
||||
|
||||
const WIFI_ICONS = {
|
||||
excellent: "ph:wifi-high-bold",
|
||||
good: "ph:wifi-high-bold",
|
||||
weak: "ph:wifi-medium-bold",
|
||||
poor: "ph:wifi-low-bold"
|
||||
} as const;
|
||||
|
||||
const STRENGTH_WORDS = {
|
||||
excellent: "Excellent",
|
||||
good: "Good",
|
||||
weak: "Weak",
|
||||
poor: "Poor"
|
||||
} as const;
|
||||
|
||||
const memoryPercent = $derived(
|
||||
metrics && metrics.memoryTotalBytes > 0
|
||||
? (metrics.memoryUsedBytes / metrics.memoryTotalBytes) * 100
|
||||
: null
|
||||
);
|
||||
|
||||
const isStale = $derived(metricsAt !== null && Date.now() - metricsAt > 120_000);
|
||||
|
||||
function barColor(percent: number): string {
|
||||
if (percent >= 90) return "bg-danger";
|
||||
if (percent >= 70) return "bg-accent";
|
||||
return "bg-success";
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !metrics}
|
||||
<p class="text-sm text-ink-2">No stats reported yet.</p>
|
||||
{:else if compact}
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm text-ink-2">
|
||||
{#if metrics.cpuPercent !== null}
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Icon icon="ph:cpu-bold" width="14" />
|
||||
{metrics.cpuPercent.toFixed(0)}%
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if memoryPercent !== null}
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Icon icon="ph:memory-bold" width="14" />
|
||||
{memoryPercent.toFixed(0)}%
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if metrics.wifi}
|
||||
{@const strength = signalLabel(metrics.wifi.signalDbm)}
|
||||
<span
|
||||
class={`flex items-center gap-1.5 ${SIGNAL_COLORS[strength]}`}
|
||||
title={`${metrics.wifi.signalDbm.toFixed(0)} dBm on ${metrics.wifi.interface}`}
|
||||
>
|
||||
<Icon icon={WIFI_ICONS[strength]} width="14" />
|
||||
{STRENGTH_WORDS[strength]}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if metrics.temperatureCelsius !== null}
|
||||
<span class="flex items-center gap-1.5">
|
||||
<Icon icon="ph:thermometer-simple-bold" width="14" />
|
||||
{metrics.temperatureCelsius.toFixed(0)}°C
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if isStale}
|
||||
<p class="flex items-center gap-2 bg-surface-2 px-3 py-2 text-xs text-ink-2">
|
||||
<Icon icon="ph:clock-countdown-bold" width="14" />
|
||||
These figures are more than two minutes old.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if metrics.cpuPercent !== null}
|
||||
<div>
|
||||
<div class="mb-1 flex items-baseline justify-between text-sm">
|
||||
<span class="flex items-center gap-2 text-ink-1">
|
||||
<Icon icon="ph:cpu-bold" width="16" />
|
||||
CPU
|
||||
</span>
|
||||
<span class="text-ink-2">{metrics.cpuPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full bg-surface-2">
|
||||
<div
|
||||
class={`h-full ${barColor(metrics.cpuPercent)}`}
|
||||
style={`width: ${Math.min(100, metrics.cpuPercent)}%`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if memoryPercent !== null}
|
||||
<div>
|
||||
<div class="mb-1 flex items-baseline justify-between text-sm">
|
||||
<span class="flex items-center gap-2 text-ink-1">
|
||||
<Icon icon="ph:memory-bold" width="16" />
|
||||
Memory
|
||||
</span>
|
||||
<span class="text-ink-2">
|
||||
{formatBytes(metrics.memoryUsedBytes)} of {formatBytes(metrics.memoryTotalBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full bg-surface-2">
|
||||
<div
|
||||
class={`h-full ${barColor(memoryPercent)}`}
|
||||
style={`width: ${Math.min(100, memoryPercent)}%`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if metrics.wifi}
|
||||
{@const strength = signalLabel(metrics.wifi.signalDbm)}
|
||||
{@const percent = signalPercent(metrics.wifi.signalDbm)}
|
||||
<div>
|
||||
<div class="mb-1 flex items-baseline justify-between text-sm">
|
||||
<span class="flex items-center gap-2 text-ink-1">
|
||||
<Icon icon={WIFI_ICONS[strength]} width="16" class={SIGNAL_COLORS[strength]} />
|
||||
Wi-Fi
|
||||
</span>
|
||||
<span class={SIGNAL_COLORS[strength]}>
|
||||
{STRENGTH_WORDS[strength]}
|
||||
<span class="text-ink-2">{percent}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full bg-surface-2">
|
||||
<div class={`h-full ${SIGNAL_BARS[strength]}`} style={`width: ${percent}%`}></div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-ink-2">
|
||||
{signalAdvice(metrics.wifi.signalDbm)} · {metrics.wifi.signalDbm.toFixed(0)} dBm on
|
||||
{metrics.wifi.interface}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-baseline justify-between text-sm">
|
||||
<span class="flex items-center gap-2 text-ink-1">
|
||||
<Icon icon="ph:network-bold" width="16" />
|
||||
Network
|
||||
</span>
|
||||
<span class="text-ink-2">Wired or no wireless adapter</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if metrics.temperatureCelsius !== null}
|
||||
<div class="flex items-baseline justify-between text-sm">
|
||||
<span class="flex items-center gap-2 text-ink-1">
|
||||
<Icon icon="ph:thermometer-simple-bold" width="16" />
|
||||
Temperature
|
||||
</span>
|
||||
<span class={metrics.temperatureCelsius >= 75 ? "text-danger" : "text-ink-2"}>
|
||||
{metrics.temperatureCelsius.toFixed(1)}°C
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-baseline justify-between text-sm">
|
||||
<span class="flex items-center gap-2 text-ink-1">
|
||||
<Icon icon="ph:timer-bold" width="16" />
|
||||
Uptime
|
||||
</span>
|
||||
<span class="text-ink-2">{formatUptime(metrics.uptimeSeconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import type { KioskLayout, NightModeSettings } from "@pistation/shared-types";
|
||||
import { isNightModeActive } from "@pistation/shared-types";
|
||||
|
||||
import TimeZoneField from "./TimeZoneField.svelte";
|
||||
|
||||
let {
|
||||
layout,
|
||||
onChange
|
||||
}: {
|
||||
layout: KioskLayout;
|
||||
onChange: (layout: KioskLayout) => void;
|
||||
} = $props();
|
||||
|
||||
const nightMode = $derived(layout.nightMode);
|
||||
const isActiveNow = $derived(isNightModeActive(nightMode));
|
||||
|
||||
function patch(update: Partial<NightModeSettings>) {
|
||||
onChange({ ...layout, nightMode: { ...layout.nightMode, ...update } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4 bg-surface-1 p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="flex-1 text-sm font-semibold tracking-wide text-ink-2 uppercase">Night mode</h3>
|
||||
{#if nightMode.enabled && isActiveNow}
|
||||
<span class="bg-accent px-2 py-1 text-xs font-medium text-white">Active now</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-ink-2">
|
||||
Between these times the screen shows only the clock and the PIN. Widgets, wallpaper and
|
||||
all other graphics are hidden. A live presentation always takes priority.
|
||||
</p>
|
||||
|
||||
<label class="flex items-center gap-3 text-sm text-ink-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={nightMode.enabled}
|
||||
onchange={(event) => patch({ enabled: (event.target as HTMLInputElement).checked })}
|
||||
/>
|
||||
Enable night mode
|
||||
</label>
|
||||
|
||||
{#if nightMode.enabled}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Starts</span>
|
||||
<input
|
||||
type="time"
|
||||
value={nightMode.startTime}
|
||||
oninput={(event) => patch({ startTime: (event.target as HTMLInputElement).value })}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Ends</span>
|
||||
<input
|
||||
type="time"
|
||||
value={nightMode.endTime}
|
||||
oninput={(event) => patch({ endTime: (event.target as HTMLInputElement).value })}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<TimeZoneField
|
||||
value={nightMode.timeZone}
|
||||
emptyLabel="Kiosk clock"
|
||||
onChange={(timeZone) => patch({ timeZone })}
|
||||
/>
|
||||
|
||||
<label class="flex items-center gap-3 text-sm text-ink-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={nightMode.showPin}
|
||||
onchange={(event) => patch({ showPin: (event.target as HTMLInputElement).checked })}
|
||||
/>
|
||||
Show the PIN at night
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">
|
||||
Brightness {Math.round(nightMode.brightness * 100)} percent
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="100"
|
||||
value={nightMode.brightness * 100}
|
||||
oninput={(event) =>
|
||||
patch({ brightness: Number((event.target as HTMLInputElement).value) / 100 })}
|
||||
class="w-full"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { isValidTimeZone, listTimeZones, localTimeZone } from "@pistation/shared-types";
|
||||
|
||||
let {
|
||||
value,
|
||||
label = "Time zone",
|
||||
emptyLabel = "Device clock",
|
||||
onChange
|
||||
}: {
|
||||
value: string;
|
||||
label?: string;
|
||||
emptyLabel?: string;
|
||||
onChange: (value: string) => void;
|
||||
} = $props();
|
||||
|
||||
const zones = listTimeZones();
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
const byRegion = new Map<string, string[]>();
|
||||
|
||||
for (const zone of zones) {
|
||||
const region = zone.includes("/") ? zone.split("/")[0] : "Other";
|
||||
const existing = byRegion.get(region);
|
||||
if (existing) existing.push(zone);
|
||||
else byRegion.set(region, [zone]);
|
||||
}
|
||||
|
||||
return [...byRegion.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||
});
|
||||
|
||||
const isKnown = $derived(!value || zones.includes(value));
|
||||
|
||||
const preview = $derived.by(() => {
|
||||
if (value && !isValidTimeZone(value)) return null;
|
||||
try {
|
||||
return new Date().toLocaleTimeString([], {
|
||||
timeZone: value || undefined,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
function labelFor(zone: string): string {
|
||||
return zone.includes("/") ? zone.split("/").slice(1).join("/").replace(/_/g, " ") : zone;
|
||||
}
|
||||
</script>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">{label}</span>
|
||||
|
||||
<select
|
||||
{value}
|
||||
onchange={(event) => onChange((event.target as HTMLSelectElement).value)}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm text-ink-0"
|
||||
>
|
||||
<option value="">{emptyLabel} ({localTimeZone()})</option>
|
||||
|
||||
{#if !isKnown}
|
||||
<option value={value}>{value}</option>
|
||||
{/if}
|
||||
|
||||
{#each groups as [region, regionZones]}
|
||||
<optgroup label={region}>
|
||||
{#each regionZones as zone}
|
||||
<option value={zone}>{labelFor(zone)}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if preview}
|
||||
<span class="mt-1 block text-xs text-ink-2">Currently {preview} there</span>
|
||||
{/if}
|
||||
</label>
|
||||
@@ -0,0 +1,322 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { CustomWidgetDefinition, WidgetBlock } from "@pistation/shared-types";
|
||||
import { createEmptyDefinition, createId, WIDGET_BLOCK_KINDS } from "@pistation/shared-types";
|
||||
|
||||
let {
|
||||
definitions,
|
||||
onChange
|
||||
}: {
|
||||
definitions: CustomWidgetDefinition[];
|
||||
onChange: (definitions: CustomWidgetDefinition[]) => void;
|
||||
} = $props();
|
||||
|
||||
let selectedId = $state<string | null>(null);
|
||||
|
||||
const selected = $derived(
|
||||
definitions.find((definition) => definition.definitionId === selectedId) ?? null
|
||||
);
|
||||
|
||||
const makeId = createId;
|
||||
|
||||
function addDefinition() {
|
||||
const definition = createEmptyDefinition(makeId("def"));
|
||||
onChange([...definitions, definition]);
|
||||
selectedId = definition.definitionId;
|
||||
}
|
||||
|
||||
function updateDefinition(patch: Partial<CustomWidgetDefinition>) {
|
||||
if (!selected) return;
|
||||
onChange(
|
||||
definitions.map((definition) =>
|
||||
definition.definitionId === selected.definitionId
|
||||
? { ...definition, ...patch, updatedAt: Date.now() }
|
||||
: definition
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function removeDefinition(definitionId: string) {
|
||||
onChange(definitions.filter((definition) => definition.definitionId !== definitionId));
|
||||
if (selectedId === definitionId) selectedId = null;
|
||||
}
|
||||
|
||||
function addBlock(kind: (typeof WIDGET_BLOCK_KINDS)[number]) {
|
||||
if (!selected) return;
|
||||
const blockId = makeId("block");
|
||||
|
||||
const block = {
|
||||
heading: { blockId, kind: "heading", template: "Title" },
|
||||
text: { blockId, kind: "text", template: "Some text" },
|
||||
metric: { blockId, kind: "metric", labelTemplate: "Label", valueTemplate: "{{value}}", unit: "" },
|
||||
list: { blockId, kind: "list", sourcePath: "items", itemTemplate: "{{name}}", maxItems: 5 },
|
||||
image: { blockId, kind: "image", urlTemplate: "", fit: "contain" },
|
||||
divider: { blockId, kind: "divider" }
|
||||
}[kind] as WidgetBlock;
|
||||
|
||||
updateDefinition({ blocks: [...selected.blocks, block] });
|
||||
}
|
||||
|
||||
function updateBlock(blockId: string, patch: Record<string, unknown>) {
|
||||
if (!selected) return;
|
||||
updateDefinition({
|
||||
blocks: selected.blocks.map((block) =>
|
||||
block.blockId === blockId ? ({ ...block, ...patch } as WidgetBlock) : block
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
function removeBlock(blockId: string) {
|
||||
if (!selected) return;
|
||||
updateDefinition({ blocks: selected.blocks.filter((block) => block.blockId !== blockId) });
|
||||
}
|
||||
|
||||
function moveBlock(index: number, offset: number) {
|
||||
if (!selected) return;
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= selected.blocks.length) return;
|
||||
|
||||
const blocks = [...selected.blocks];
|
||||
const [moved] = blocks.splice(index, 1);
|
||||
blocks.splice(target, 0, moved);
|
||||
updateDefinition({ blocks });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="flex-1 text-sm font-semibold tracking-wide text-ink-2 uppercase">Widget builder</h3>
|
||||
<button
|
||||
onclick={addDefinition}
|
||||
class="flex items-center gap-2 bg-surface-2 px-3 py-2 text-sm text-ink-1 hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:plus-bold" width="16" />
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-px">
|
||||
{#each definitions as definition (definition.definitionId)}
|
||||
<button
|
||||
onclick={() => (selectedId = definition.definitionId)}
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
|
||||
class:bg-accent={selectedId === definition.definitionId}
|
||||
class:text-white={selectedId === definition.definitionId}
|
||||
class:bg-surface-2={selectedId !== definition.definitionId}
|
||||
class:text-ink-1={selectedId !== definition.definitionId}
|
||||
>
|
||||
{definition.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if selected}
|
||||
<div class="flex flex-col gap-4 bg-surface-1 p-5">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label class="min-w-40 flex-1">
|
||||
<span class="mb-1 block text-xs text-ink-2">Name</span>
|
||||
<input
|
||||
value={selected.name}
|
||||
oninput={(event) =>
|
||||
updateDefinition({ name: (event.target as HTMLInputElement).value })}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span class="mb-1 block text-xs text-ink-2">Accent</span>
|
||||
<input
|
||||
type="color"
|
||||
value={selected.accentColor}
|
||||
oninput={(event) =>
|
||||
updateDefinition({ accentColor: (event.target as HTMLInputElement).value })}
|
||||
class="h-10 w-16 bg-surface-2"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onclick={() => removeDefinition(selected.definitionId)}
|
||||
class="self-end bg-surface-2 px-4 py-2 text-sm text-danger hover:bg-surface-3"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 bg-surface-2 p-4">
|
||||
<p class="text-xs tracking-wide text-ink-2 uppercase">Data source</p>
|
||||
<input
|
||||
value={selected.dataSource?.url ?? ""}
|
||||
placeholder="https://example.com/api.json"
|
||||
oninput={(event) =>
|
||||
updateDefinition({
|
||||
dataSource: {
|
||||
url: (event.target as HTMLInputElement).value,
|
||||
refreshSeconds: selected.dataSource?.refreshSeconds ?? 300,
|
||||
rootPath: selected.dataSource?.rootPath ?? ""
|
||||
}
|
||||
})}
|
||||
class="w-full bg-surface-1 px-3 py-2 text-sm"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label>
|
||||
<span class="mb-1 block text-xs text-ink-2">Refresh seconds</span>
|
||||
<input
|
||||
type="number"
|
||||
min="10"
|
||||
value={selected.dataSource?.refreshSeconds ?? 300}
|
||||
oninput={(event) =>
|
||||
updateDefinition({
|
||||
dataSource: {
|
||||
url: selected.dataSource?.url ?? "",
|
||||
refreshSeconds: Number((event.target as HTMLInputElement).value),
|
||||
rootPath: selected.dataSource?.rootPath ?? ""
|
||||
}
|
||||
})}
|
||||
class="w-full bg-surface-1 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span class="mb-1 block text-xs text-ink-2">Root path</span>
|
||||
<input
|
||||
value={selected.dataSource?.rootPath ?? ""}
|
||||
placeholder="data.current"
|
||||
oninput={(event) =>
|
||||
updateDefinition({
|
||||
dataSource: {
|
||||
url: selected.dataSource?.url ?? "",
|
||||
refreshSeconds: selected.dataSource?.refreshSeconds ?? 300,
|
||||
rootPath: (event.target as HTMLInputElement).value
|
||||
}
|
||||
})}
|
||||
class="w-full bg-surface-1 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-ink-2">
|
||||
Reference fields in any template with double braces, for example
|
||||
<code class="text-accent">{"{{temperature}}"}</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each WIDGET_BLOCK_KINDS as kind}
|
||||
<button
|
||||
onclick={() => addBlock(kind)}
|
||||
class="bg-surface-2 px-3 py-2 text-sm text-ink-1 capitalize hover:bg-surface-3"
|
||||
>
|
||||
+ {kind}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-px">
|
||||
{#each selected.blocks as block, index (block.blockId)}
|
||||
<div class="flex flex-col gap-2 bg-surface-2 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex-1 text-xs tracking-wide text-ink-2 uppercase">{block.kind}</span>
|
||||
<button
|
||||
onclick={() => moveBlock(index, -1)}
|
||||
aria-label="Move up"
|
||||
class="flex h-7 w-7 items-center justify-center bg-surface-1 text-ink-1 hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:arrow-up-bold" width="14" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => moveBlock(index, 1)}
|
||||
aria-label="Move down"
|
||||
class="flex h-7 w-7 items-center justify-center bg-surface-1 text-ink-1 hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:arrow-down-bold" width="14" />
|
||||
</button>
|
||||
<button
|
||||
onclick={() => removeBlock(block.blockId)}
|
||||
aria-label="Remove block"
|
||||
class="flex h-7 w-7 items-center justify-center bg-surface-1 text-danger hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:x-bold" width="14" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if block.kind === "heading" || block.kind === "text"}
|
||||
<input
|
||||
value={block.template}
|
||||
oninput={(event) =>
|
||||
updateBlock(block.blockId, {
|
||||
template: (event.target as HTMLInputElement).value
|
||||
})}
|
||||
class="w-full bg-surface-1 px-3 py-2 text-sm"
|
||||
/>
|
||||
{:else if block.kind === "metric"}
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<input
|
||||
value={block.labelTemplate}
|
||||
placeholder="Label"
|
||||
oninput={(event) =>
|
||||
updateBlock(block.blockId, {
|
||||
labelTemplate: (event.target as HTMLInputElement).value
|
||||
})}
|
||||
class="bg-surface-1 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={block.valueTemplate}
|
||||
placeholder={"{{value}}"}
|
||||
oninput={(event) =>
|
||||
updateBlock(block.blockId, {
|
||||
valueTemplate: (event.target as HTMLInputElement).value
|
||||
})}
|
||||
class="bg-surface-1 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={block.unit}
|
||||
placeholder="Unit"
|
||||
oninput={(event) =>
|
||||
updateBlock(block.blockId, { unit: (event.target as HTMLInputElement).value })}
|
||||
class="bg-surface-1 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{:else if block.kind === "list"}
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<input
|
||||
value={block.sourcePath}
|
||||
placeholder="items"
|
||||
oninput={(event) =>
|
||||
updateBlock(block.blockId, {
|
||||
sourcePath: (event.target as HTMLInputElement).value
|
||||
})}
|
||||
class="bg-surface-1 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={block.itemTemplate}
|
||||
placeholder={"{{name}}"}
|
||||
oninput={(event) =>
|
||||
updateBlock(block.blockId, {
|
||||
itemTemplate: (event.target as HTMLInputElement).value
|
||||
})}
|
||||
class="bg-surface-1 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={block.maxItems}
|
||||
oninput={(event) =>
|
||||
updateBlock(block.blockId, {
|
||||
maxItems: Number((event.target as HTMLInputElement).value)
|
||||
})}
|
||||
class="bg-surface-1 px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{:else if block.kind === "image"}
|
||||
<input
|
||||
value={block.urlTemplate}
|
||||
placeholder={"https://example.com/{{path}}"}
|
||||
oninput={(event) =>
|
||||
updateBlock(block.blockId, {
|
||||
urlTemplate: (event.target as HTMLInputElement).value
|
||||
})}
|
||||
class="w-full bg-surface-1 px-3 py-2 text-sm"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import type { KioskLayout, Widget, WidgetPlacement } from "@pistation/shared-types";
|
||||
import {
|
||||
buildOccupancy,
|
||||
clampPlacement,
|
||||
isAreaFree,
|
||||
WIDGET_GRID_COLUMNS,
|
||||
WIDGET_GRID_ROWS
|
||||
} from "@pistation/shared-types";
|
||||
import { WidgetSurface } from "@pistation/ui";
|
||||
|
||||
let {
|
||||
layout,
|
||||
pin = null,
|
||||
joinUrl = "",
|
||||
mediaBaseUrl = "",
|
||||
selectedWidgetId = null,
|
||||
onSelect,
|
||||
onWidgetsChange
|
||||
}: {
|
||||
layout: KioskLayout;
|
||||
pin?: string | null;
|
||||
joinUrl?: string;
|
||||
mediaBaseUrl?: string;
|
||||
selectedWidgetId?: string | null;
|
||||
onSelect: (widgetId: string) => void;
|
||||
onWidgetsChange: (widgets: Widget[]) => void;
|
||||
} = $props();
|
||||
|
||||
const GRID_PADDING_RATIO = 0.016;
|
||||
const GRID_GAP_RATIO = 0.012;
|
||||
|
||||
interface DragState {
|
||||
widgetId: string;
|
||||
mode: "move" | "resize";
|
||||
pointerX: number;
|
||||
pointerY: number;
|
||||
origin: WidgetPlacement;
|
||||
}
|
||||
|
||||
let surfaceWidth = $state(0);
|
||||
let surfaceHeight = $state(0);
|
||||
let drag = $state<DragState | null>(null);
|
||||
let draft = $state<WidgetPlacement | null>(null);
|
||||
let isDraftValid = $state(true);
|
||||
|
||||
const visibleWidgets = $derived(layout.widgets.filter((widget) => widget.enabled));
|
||||
|
||||
const stepX = $derived.by(() => {
|
||||
const padding = surfaceWidth * GRID_PADDING_RATIO;
|
||||
const gap = surfaceWidth * GRID_GAP_RATIO;
|
||||
const content = surfaceWidth - padding * 2;
|
||||
const cell = (content - gap * (WIDGET_GRID_COLUMNS - 1)) / WIDGET_GRID_COLUMNS;
|
||||
return cell + gap;
|
||||
});
|
||||
|
||||
const stepY = $derived.by(() => {
|
||||
const padding = surfaceWidth * GRID_PADDING_RATIO;
|
||||
const gap = surfaceHeight * GRID_GAP_RATIO;
|
||||
const content = surfaceHeight - padding * 2;
|
||||
const cell = (content - gap * (WIDGET_GRID_ROWS - 1)) / WIDGET_GRID_ROWS;
|
||||
return cell + gap;
|
||||
});
|
||||
|
||||
function placementFor(widget: Widget): WidgetPlacement {
|
||||
return drag?.widgetId === widget.widgetId && draft ? draft : widget.placement;
|
||||
}
|
||||
|
||||
function beginDrag(event: PointerEvent, widget: Widget, mode: "move" | "resize") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
onSelect(widget.widgetId);
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
|
||||
drag = {
|
||||
widgetId: widget.widgetId,
|
||||
mode,
|
||||
pointerX: event.clientX,
|
||||
pointerY: event.clientY,
|
||||
origin: { ...widget.placement }
|
||||
};
|
||||
draft = { ...widget.placement };
|
||||
isDraftValid = true;
|
||||
}
|
||||
|
||||
function updateDrag(event: PointerEvent) {
|
||||
if (!drag || stepX <= 0 || stepY <= 0) return;
|
||||
|
||||
const deltaColumns = Math.round((event.clientX - drag.pointerX) / stepX);
|
||||
const deltaRows = Math.round((event.clientY - drag.pointerY) / stepY);
|
||||
|
||||
const candidate =
|
||||
drag.mode === "move"
|
||||
? clampPlacement({
|
||||
...drag.origin,
|
||||
column: drag.origin.column + deltaColumns,
|
||||
row: drag.origin.row + deltaRows
|
||||
})
|
||||
: clampPlacement({
|
||||
...drag.origin,
|
||||
columnSpan: Math.min(
|
||||
Math.max(1, drag.origin.columnSpan + deltaColumns),
|
||||
WIDGET_GRID_COLUMNS - drag.origin.column + 1
|
||||
),
|
||||
rowSpan: Math.min(
|
||||
Math.max(1, drag.origin.rowSpan + deltaRows),
|
||||
WIDGET_GRID_ROWS - drag.origin.row + 1
|
||||
)
|
||||
});
|
||||
|
||||
draft = candidate;
|
||||
isDraftValid = isAreaFree(buildOccupancy(layout.widgets, drag.widgetId), candidate);
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
if (drag && draft && isDraftValid) {
|
||||
commitPlacement(drag.widgetId, draft);
|
||||
}
|
||||
drag = null;
|
||||
draft = null;
|
||||
isDraftValid = true;
|
||||
}
|
||||
|
||||
function commitPlacement(widgetId: string, placement: WidgetPlacement) {
|
||||
onWidgetsChange(
|
||||
layout.widgets.map((widget) =>
|
||||
widget.widgetId === widgetId ? { ...widget, placement } : widget
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function nudge(event: KeyboardEvent, widget: Widget) {
|
||||
const directions: Record<string, [number, number]> = {
|
||||
ArrowLeft: [-1, 0],
|
||||
ArrowRight: [1, 0],
|
||||
ArrowUp: [0, -1],
|
||||
ArrowDown: [0, 1]
|
||||
};
|
||||
|
||||
const direction = directions[event.key];
|
||||
if (!direction) return;
|
||||
|
||||
event.preventDefault();
|
||||
const [deltaColumns, deltaRows] = direction;
|
||||
|
||||
const candidate = clampPlacement(
|
||||
event.shiftKey
|
||||
? {
|
||||
...widget.placement,
|
||||
columnSpan: Math.max(1, widget.placement.columnSpan + deltaColumns),
|
||||
rowSpan: Math.max(1, widget.placement.rowSpan + deltaRows)
|
||||
}
|
||||
: {
|
||||
...widget.placement,
|
||||
column: widget.placement.column + deltaColumns,
|
||||
row: widget.placement.row + deltaRows
|
||||
}
|
||||
);
|
||||
|
||||
if (isAreaFree(buildOccupancy(layout.widgets, widget.widgetId), candidate)) {
|
||||
commitPlacement(widget.widgetId, candidate);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative aspect-video w-full overflow-hidden bg-surface-1 select-none"
|
||||
bind:clientWidth={surfaceWidth}
|
||||
bind:clientHeight={surfaceHeight}
|
||||
>
|
||||
<WidgetSurface {layout} {pin} {joinUrl} {mediaBaseUrl} />
|
||||
|
||||
<div
|
||||
class="absolute inset-0 grid"
|
||||
style={`
|
||||
grid-template-columns: repeat(${WIDGET_GRID_COLUMNS}, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(${WIDGET_GRID_ROWS}, minmax(0, 1fr));
|
||||
gap: 1.2%;
|
||||
padding: 1.6%;
|
||||
`}
|
||||
>
|
||||
{#if drag}
|
||||
{#each Array(WIDGET_GRID_COLUMNS * WIDGET_GRID_ROWS) as _, index}
|
||||
<div
|
||||
class="pointer-events-none bg-white/5"
|
||||
style={`grid-column: ${(index % WIDGET_GRID_COLUMNS) + 1}; grid-row: ${Math.floor(index / WIDGET_GRID_COLUMNS) + 1};`}
|
||||
></div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#each visibleWidgets as widget (widget.widgetId)}
|
||||
{@const placement = placementFor(widget)}
|
||||
<div
|
||||
class="group relative cursor-grab touch-none transition-colors hover:bg-white/5"
|
||||
class:cursor-grabbing={drag?.widgetId === widget.widgetId}
|
||||
class:outline={selectedWidgetId === widget.widgetId || drag?.widgetId === widget.widgetId}
|
||||
class:outline-2={selectedWidgetId === widget.widgetId || drag?.widgetId === widget.widgetId}
|
||||
class:outline-accent={isDraftValid}
|
||||
class:outline-danger={!isDraftValid && drag?.widgetId === widget.widgetId}
|
||||
style={`
|
||||
grid-column: ${placement.column} / span ${placement.columnSpan};
|
||||
grid-row: ${placement.row} / span ${placement.rowSpan};
|
||||
`}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={`Move ${widget.kind} widget`}
|
||||
onpointerdown={(event) => beginDrag(event, widget, "move")}
|
||||
onpointermove={updateDrag}
|
||||
onpointerup={endDrag}
|
||||
onpointercancel={endDrag}
|
||||
onkeydown={(event) => nudge(event, widget)}
|
||||
onfocus={() => onSelect(widget.widgetId)}
|
||||
>
|
||||
<span
|
||||
class="absolute top-1 left-1 bg-surface-0/80 px-2 py-0.5 text-xs text-ink-1 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
{widget.kind}
|
||||
</span>
|
||||
|
||||
<button
|
||||
class="absolute right-0 bottom-0 h-5 w-5 cursor-nwse-resize bg-accent opacity-0 transition-opacity group-hover:opacity-100"
|
||||
class:opacity-100={selectedWidgetId === widget.widgetId}
|
||||
aria-label={`Resize ${widget.kind} widget`}
|
||||
onpointerdown={(event) => beginDrag(event, widget, "resize")}
|
||||
onpointermove={updateDrag}
|
||||
onpointerup={endDrag}
|
||||
onpointercancel={endDrag}
|
||||
></button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { Widget } from "@pistation/shared-types";
|
||||
|
||||
let {
|
||||
widgets,
|
||||
selectedWidgetId,
|
||||
onSelect,
|
||||
onChange
|
||||
}: {
|
||||
widgets: Widget[];
|
||||
selectedWidgetId: string | null;
|
||||
onSelect: (widgetId: string) => void;
|
||||
onChange: (widgets: Widget[]) => void;
|
||||
} = $props();
|
||||
|
||||
function move(index: number, offset: number) {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= widgets.length) return;
|
||||
|
||||
const reordered = [...widgets];
|
||||
const [moved] = reordered.splice(index, 1);
|
||||
reordered.splice(target, 0, moved);
|
||||
onChange(reordered);
|
||||
}
|
||||
|
||||
function toggle(widgetId: string) {
|
||||
onChange(
|
||||
widgets.map((widget) =>
|
||||
widget.widgetId === widgetId ? { ...widget, enabled: !widget.enabled } : widget
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function remove(widgetId: string) {
|
||||
onChange(widgets.filter((widget) => widget.widgetId !== widgetId));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 bg-surface-1 p-5">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">Widgets</h3>
|
||||
|
||||
{#if widgets.length === 0}
|
||||
<p class="py-4 text-center text-sm text-ink-2">No widgets yet.</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-px">
|
||||
{#each widgets as widget, index (widget.widgetId)}
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-2 transition-colors"
|
||||
class:bg-surface-2={selectedWidgetId !== widget.widgetId}
|
||||
class:bg-accent={selectedWidgetId === widget.widgetId}
|
||||
>
|
||||
<button
|
||||
onclick={() => onSelect(widget.widgetId)}
|
||||
class="flex-1 text-left text-sm capitalize"
|
||||
class:text-white={selectedWidgetId === widget.widgetId}
|
||||
class:text-ink-1={selectedWidgetId !== widget.widgetId}
|
||||
class:opacity-40={!widget.enabled}
|
||||
>
|
||||
{widget.kind}
|
||||
<span class="ml-2 text-xs opacity-60">
|
||||
{widget.placement.columnSpan} by {widget.placement.rowSpan}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => toggle(widget.widgetId)}
|
||||
aria-label={widget.enabled ? "Hide widget" : "Show widget"}
|
||||
class="flex h-7 w-7 items-center justify-center text-ink-1 hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon={widget.enabled ? "ph:eye-bold" : "ph:eye-slash-bold"} width="14" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => move(index, -1)}
|
||||
aria-label="Move earlier"
|
||||
class="flex h-7 w-7 items-center justify-center text-ink-1 hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:arrow-up-bold" width="14" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => move(index, 1)}
|
||||
aria-label="Move later"
|
||||
class="flex h-7 w-7 items-center justify-center text-ink-1 hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:arrow-down-bold" width="14" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => remove(widget.widgetId)}
|
||||
aria-label="Delete widget"
|
||||
class="flex h-7 w-7 items-center justify-center text-danger hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:trash-bold" width="14" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,409 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type {
|
||||
AgendaSettings,
|
||||
ClockSettings,
|
||||
CustomSettings,
|
||||
CustomWidgetDefinition,
|
||||
ImageSettings,
|
||||
PinSettings,
|
||||
TextSettings,
|
||||
WeatherSettings,
|
||||
Widget,
|
||||
WidgetStyle
|
||||
} from "@pistation/shared-types";
|
||||
import {
|
||||
DEFAULT_WIDGET_OPACITY,
|
||||
DEFAULT_WIDGET_STYLE,
|
||||
WIDGET_GRID_COLUMNS,
|
||||
WIDGET_GRID_ROWS
|
||||
} from "@pistation/shared-types";
|
||||
|
||||
import TimeZoneField from "./TimeZoneField.svelte";
|
||||
|
||||
let {
|
||||
widget,
|
||||
definitions,
|
||||
layoutOpacity = DEFAULT_WIDGET_OPACITY,
|
||||
onChange,
|
||||
onRemove
|
||||
}: {
|
||||
widget: Widget;
|
||||
definitions: CustomWidgetDefinition[];
|
||||
layoutOpacity?: number;
|
||||
onChange: (widget: Widget) => void;
|
||||
onRemove: () => void;
|
||||
} = $props();
|
||||
|
||||
const ALIGN_OPTIONS = [
|
||||
{
|
||||
value: "start" as const,
|
||||
label: "Start",
|
||||
horizontalIcon: "ph:align-left-simple-bold",
|
||||
verticalIcon: "ph:align-top-simple-bold"
|
||||
},
|
||||
{
|
||||
value: "center" as const,
|
||||
label: "Centre",
|
||||
horizontalIcon: "ph:align-center-horizontal-simple-bold",
|
||||
verticalIcon: "ph:align-center-vertical-simple-bold"
|
||||
},
|
||||
{
|
||||
value: "end" as const,
|
||||
label: "End",
|
||||
horizontalIcon: "ph:align-right-simple-bold",
|
||||
verticalIcon: "ph:align-bottom-simple-bold"
|
||||
}
|
||||
];
|
||||
|
||||
const style = $derived({ ...DEFAULT_WIDGET_STYLE, ...(widget.style ?? {}) });
|
||||
|
||||
function patchSettings(patch: Record<string, unknown>) {
|
||||
onChange({ ...widget, settings: { ...widget.settings, ...patch } as Widget["settings"] });
|
||||
}
|
||||
|
||||
function patchStyle(patch: Partial<WidgetStyle>) {
|
||||
onChange({ ...widget, style: { ...style, ...patch } });
|
||||
}
|
||||
|
||||
function patchPlacement(patch: Record<string, number>) {
|
||||
onChange({ ...widget, placement: { ...widget.placement, ...patch } });
|
||||
}
|
||||
|
||||
const clock = $derived(widget.settings as ClockSettings);
|
||||
const weather = $derived(widget.settings as WeatherSettings);
|
||||
const pin = $derived(widget.settings as PinSettings);
|
||||
const text = $derived(widget.settings as TextSettings);
|
||||
const image = $derived(widget.settings as ImageSettings);
|
||||
const agenda = $derived(widget.settings as AgendaSettings);
|
||||
const custom = $derived(widget.settings as CustomSettings);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-5 bg-surface-1 p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="flex-1 text-sm font-semibold tracking-wide uppercase">{widget.kind}</h3>
|
||||
<label class="flex items-center gap-2 text-sm text-ink-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={widget.enabled}
|
||||
onchange={(event) =>
|
||||
onChange({ ...widget, enabled: (event.target as HTMLInputElement).checked })}
|
||||
/>
|
||||
Shown
|
||||
</label>
|
||||
<button
|
||||
onclick={onRemove}
|
||||
aria-label="Remove widget"
|
||||
class="flex h-8 w-8 items-center justify-center bg-surface-2 text-danger hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:trash-bold" width="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
{#each [
|
||||
{ key: "column", label: "Col", max: WIDGET_GRID_COLUMNS },
|
||||
{ key: "row", label: "Row", max: WIDGET_GRID_ROWS },
|
||||
{ key: "columnSpan", label: "Width", max: WIDGET_GRID_COLUMNS },
|
||||
{ key: "rowSpan", label: "Height", max: WIDGET_GRID_ROWS }
|
||||
] as field}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">{field.label}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max={field.max}
|
||||
value={widget.placement[field.key as keyof typeof widget.placement]}
|
||||
oninput={(event) =>
|
||||
patchPlacement({
|
||||
[field.key]: Number((event.target as HTMLInputElement).value)
|
||||
})}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 bg-surface-2 p-4">
|
||||
<p class="text-xs tracking-wide text-ink-2 uppercase">Style</p>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Padding {style.padding}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="20"
|
||||
step="0.5"
|
||||
value={style.padding}
|
||||
oninput={(event) =>
|
||||
patchStyle({ padding: Number((event.target as HTMLInputElement).value) })}
|
||||
class="w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<span class="mb-1 block text-xs text-ink-2">Horizontal</span>
|
||||
<div class="flex gap-px">
|
||||
{#each ALIGN_OPTIONS as option}
|
||||
<button
|
||||
onclick={() => patchStyle({ align: option.value })}
|
||||
aria-label={option.label}
|
||||
title={option.label}
|
||||
class="flex h-9 flex-1 items-center justify-center transition-colors"
|
||||
class:bg-accent={style.align === option.value}
|
||||
class:text-white={style.align === option.value}
|
||||
class:bg-surface-1={style.align !== option.value}
|
||||
class:text-ink-1={style.align !== option.value}
|
||||
>
|
||||
<Icon icon={option.horizontalIcon} width="16" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="mb-1 block text-xs text-ink-2">Vertical</span>
|
||||
<div class="flex gap-px">
|
||||
{#each ALIGN_OPTIONS as option}
|
||||
<button
|
||||
onclick={() => patchStyle({ verticalAlign: option.value })}
|
||||
aria-label={option.label}
|
||||
title={option.label}
|
||||
class="flex h-9 flex-1 items-center justify-center transition-colors"
|
||||
class:bg-accent={style.verticalAlign === option.value}
|
||||
class:text-white={style.verticalAlign === option.value}
|
||||
class:bg-surface-1={style.verticalAlign !== option.value}
|
||||
class:text-ink-1={style.verticalAlign !== option.value}
|
||||
>
|
||||
<Icon icon={option.verticalIcon} width="16" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-xs text-ink-2">Panel colour</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if style.backgroundColor}
|
||||
<button
|
||||
onclick={() => patchStyle({ backgroundColor: "" })}
|
||||
class="bg-surface-1 px-3 py-1.5 text-xs text-ink-1 hover:bg-surface-3"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
{/if}
|
||||
<input
|
||||
type="color"
|
||||
value={style.backgroundColor || "#ffffff"}
|
||||
oninput={(event) =>
|
||||
patchStyle({ backgroundColor: (event.target as HTMLInputElement).value })}
|
||||
class="h-9 w-14 bg-surface-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">
|
||||
Panel opacity
|
||||
{style.opacity === null ? "follows the layout" : `${Math.round(style.opacity * 100)} percent`}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={(style.opacity ?? layoutOpacity) * 100}
|
||||
oninput={(event) =>
|
||||
patchStyle({ opacity: Number((event.target as HTMLInputElement).value) / 100 })}
|
||||
class="w-full"
|
||||
/>
|
||||
{#if style.opacity !== null}
|
||||
<button
|
||||
onclick={() => patchStyle({ opacity: null })}
|
||||
class="shrink-0 bg-surface-1 px-3 py-1.5 text-xs text-ink-1 hover:bg-surface-3"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if widget.kind === "clock"}
|
||||
<TimeZoneField
|
||||
value={clock.timeZone}
|
||||
onChange={(timeZone) => patchSettings({ timeZone })}
|
||||
/>
|
||||
<div class="flex gap-4 text-sm text-ink-1">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clock.showSeconds}
|
||||
onchange={(event) =>
|
||||
patchSettings({ showSeconds: (event.target as HTMLInputElement).checked })}
|
||||
/>
|
||||
Seconds
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clock.showDate}
|
||||
onchange={(event) =>
|
||||
patchSettings({ showDate: (event.target as HTMLInputElement).checked })}
|
||||
/>
|
||||
Date
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clock.hour12}
|
||||
onchange={(event) =>
|
||||
patchSettings({ hour12: (event.target as HTMLInputElement).checked })}
|
||||
/>
|
||||
12 hour
|
||||
</label>
|
||||
</div>
|
||||
{:else if widget.kind === "weather"}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Latitude</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={weather.latitude}
|
||||
oninput={(event) =>
|
||||
patchSettings({ latitude: Number((event.target as HTMLInputElement).value) })}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Longitude</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={weather.longitude}
|
||||
oninput={(event) =>
|
||||
patchSettings({ longitude: Number((event.target as HTMLInputElement).value) })}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Location label</span>
|
||||
<input
|
||||
value={weather.locationLabel}
|
||||
oninput={(event) =>
|
||||
patchSettings({ locationLabel: (event.target as HTMLInputElement).value })}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Units</span>
|
||||
<select
|
||||
value={weather.units}
|
||||
onchange={(event) => patchSettings({ units: (event.target as HTMLSelectElement).value })}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="metric">Celsius</option>
|
||||
<option value="imperial">Fahrenheit</option>
|
||||
</select>
|
||||
</label>
|
||||
{:else if widget.kind === "pin"}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Label</span>
|
||||
<input
|
||||
value={pin.label}
|
||||
oninput={(event) => patchSettings({ label: (event.target as HTMLInputElement).value })}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-ink-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pin.showJoinUrl}
|
||||
onchange={(event) =>
|
||||
patchSettings({ showJoinUrl: (event.target as HTMLInputElement).checked })}
|
||||
/>
|
||||
Show join address
|
||||
</label>
|
||||
{:else if widget.kind === "text"}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Heading</span>
|
||||
<input
|
||||
value={text.heading}
|
||||
oninput={(event) => patchSettings({ heading: (event.target as HTMLInputElement).value })}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Body</span>
|
||||
<textarea
|
||||
value={text.body}
|
||||
rows="4"
|
||||
oninput={(event) => patchSettings({ body: (event.target as HTMLTextAreaElement).value })}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm"
|
||||
></textarea>
|
||||
</label>
|
||||
{:else if widget.kind === "image"}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Image URL</span>
|
||||
<input
|
||||
value={image.imageUrl}
|
||||
oninput={(event) => patchSettings({ imageUrl: (event.target as HTMLInputElement).value })}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Fit</span>
|
||||
<select
|
||||
value={image.fit}
|
||||
onchange={(event) => patchSettings({ fit: (event.target as HTMLSelectElement).value })}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="cover">Cover</option>
|
||||
<option value="contain">Contain</option>
|
||||
</select>
|
||||
</label>
|
||||
{:else if widget.kind === "agenda"}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Heading</span>
|
||||
<input
|
||||
value={agenda.heading}
|
||||
oninput={(event) => patchSettings({ heading: (event.target as HTMLInputElement).value })}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Items, one per line</span>
|
||||
<textarea
|
||||
value={agenda.items.join("\n")}
|
||||
rows="5"
|
||||
oninput={(event) =>
|
||||
patchSettings({
|
||||
items: (event.target as HTMLTextAreaElement).value
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
})}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm"
|
||||
></textarea>
|
||||
</label>
|
||||
{:else if widget.kind === "custom"}
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Definition</span>
|
||||
<select
|
||||
value={custom.definitionId}
|
||||
onchange={(event) =>
|
||||
patchSettings({ definitionId: (event.target as HTMLSelectElement).value })}
|
||||
class="w-full bg-surface-2 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Choose a custom widget</option>
|
||||
{#each definitions as definition}
|
||||
<option value={definition.definitionId}>{definition.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
import { env } from "$env/dynamic/public";
|
||||
|
||||
export const apiBaseUrl = (env.PUBLIC_API_URL ?? "http://localhost:8080").replace(/\/$/, "");
|
||||
@@ -0,0 +1,17 @@
|
||||
export const SITE_NAME = "PiStation";
|
||||
|
||||
export const SITE_DESCRIPTION =
|
||||
"Turn any TV or monitor into a screen the whole room can use. Type the code on screen to " +
|
||||
"present, annotate live, or open a shared whiteboard. Self hosted on hardware you own.";
|
||||
|
||||
export const SITE_KEYWORDS = [
|
||||
"screen sharing",
|
||||
"wireless presentation",
|
||||
"raspberry pi kiosk",
|
||||
"digital signage",
|
||||
"self hosted",
|
||||
"whiteboard",
|
||||
"livekit"
|
||||
].join(", ");
|
||||
|
||||
export const THEME_COLOR = "#0b0d10";
|
||||
@@ -0,0 +1,546 @@
|
||||
import type {
|
||||
ControlEvent,
|
||||
DataEnvelope,
|
||||
DataTopic,
|
||||
NormalizedPoint,
|
||||
RoomMode,
|
||||
StrokeStyle,
|
||||
TopicPayloadMap,
|
||||
WhiteboardElement,
|
||||
WhiteboardEvent
|
||||
} from "@pistation/shared-types";
|
||||
import { DEFAULT_STROKE_STYLE, isTopic, mergeWhiteboardElements } from "@pistation/shared-types";
|
||||
import type { AnnotationState, ConnectionStatus } from "@pistation/client-core";
|
||||
import {
|
||||
applyAnnotationEvent,
|
||||
createAnnotationState,
|
||||
eraseAtPoint,
|
||||
makeStrokeId,
|
||||
prunePointers,
|
||||
publishEnvelope,
|
||||
RoomConnection
|
||||
} from "@pistation/client-core";
|
||||
import {
|
||||
LocalTrackPublication,
|
||||
LocalVideoTrack,
|
||||
RemoteParticipant,
|
||||
RemoteTrack,
|
||||
RemoteTrackPublication,
|
||||
RoomEvent,
|
||||
Track
|
||||
} from "livekit-client";
|
||||
|
||||
import { refreshSession } from "./api";
|
||||
import type { StoredSession } from "./session";
|
||||
|
||||
export type AnnotationTool = "pen" | "highlighter" | "arrow" | "rectangle" | "ellipse" | "laser";
|
||||
|
||||
export interface RoomParticipant {
|
||||
participantId: string;
|
||||
displayName: string;
|
||||
isSelf: boolean;
|
||||
isSharing: boolean;
|
||||
isCameraOn: boolean;
|
||||
}
|
||||
|
||||
export interface CameraFeed {
|
||||
participantId: string;
|
||||
displayName: string;
|
||||
track: RemoteTrack;
|
||||
}
|
||||
|
||||
export class RoomController {
|
||||
status = $state<ConnectionStatus>("idle");
|
||||
mode = $state<RoomMode>("idle");
|
||||
annotations = $state<AnnotationState>(createAnnotationState());
|
||||
whiteboardElements = $state<WhiteboardElement[]>([]);
|
||||
screenTrack = $state<RemoteTrack | null>(null);
|
||||
localScreenTrack = $state<LocalVideoTrack | null>(null);
|
||||
localCameraTrack = $state<LocalVideoTrack | null>(null);
|
||||
cameraFeeds = $state<CameraFeed[]>([]);
|
||||
isSharing = $state(false);
|
||||
isCameraOn = $state(false);
|
||||
facingMode = $state<"user" | "environment">("environment");
|
||||
participants = $state<RoomParticipant[]>([]);
|
||||
sharingParticipantName = $state<string | null>(null);
|
||||
errorMessage = $state<string | null>(null);
|
||||
|
||||
tool = $state<AnnotationTool>("pen");
|
||||
strokeStyle = $state<StrokeStyle>({ ...DEFAULT_STROKE_STYLE });
|
||||
isEraser = $state(false);
|
||||
isPointerMode = $state(false);
|
||||
|
||||
private session: StoredSession;
|
||||
private connection: RoomConnection | null = null;
|
||||
private activeStrokeId: string | null = null;
|
||||
private pendingPoints: NormalizedPoint[] = [];
|
||||
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(session: StoredSession) {
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
get canPresent(): boolean {
|
||||
return this.session.role !== "viewer";
|
||||
}
|
||||
|
||||
get participantId(): string {
|
||||
return this.session.participantId;
|
||||
}
|
||||
|
||||
get kioskName(): string {
|
||||
return this.session.kioskName;
|
||||
}
|
||||
|
||||
get role(): string {
|
||||
return this.session.role;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.connection = new RoomConnection(
|
||||
async () => {
|
||||
const refreshed = await refreshSession(this.session.sessionId);
|
||||
return {
|
||||
livekitUrl: this.session.livekitUrl,
|
||||
accessToken: refreshed.accessToken
|
||||
};
|
||||
},
|
||||
{
|
||||
onStatus: (status) => {
|
||||
this.status = status;
|
||||
},
|
||||
onEnvelope: (envelope) => this.handleEnvelope(envelope),
|
||||
onReconnected: () => this.requestRoomState()
|
||||
}
|
||||
);
|
||||
|
||||
this.bindTrackEvents();
|
||||
await this.connection.start();
|
||||
this.requestRoomState();
|
||||
this.startPointerPruning();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.flushTimer) clearInterval(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
await this.connection?.stop();
|
||||
this.connection = null;
|
||||
}
|
||||
|
||||
private bindTrackEvents(): void {
|
||||
const room = this.connection?.room;
|
||||
if (!room) return;
|
||||
|
||||
room
|
||||
.on(
|
||||
RoomEvent.TrackSubscribed,
|
||||
(
|
||||
track: RemoteTrack,
|
||||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant
|
||||
) => {
|
||||
if (track.kind !== Track.Kind.Video) return;
|
||||
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
this.screenTrack = track;
|
||||
}
|
||||
|
||||
if (publication.source === Track.Source.Camera) {
|
||||
this.cameraFeeds = [
|
||||
...this.cameraFeeds.filter((feed) => feed.track !== track),
|
||||
{
|
||||
participantId: participant.identity,
|
||||
displayName: participant.name || participant.identity,
|
||||
track
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
this.refreshParticipants();
|
||||
}
|
||||
)
|
||||
.on(RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {
|
||||
if (this.screenTrack === track) this.screenTrack = null;
|
||||
this.cameraFeeds = this.cameraFeeds.filter((feed) => feed.track !== track);
|
||||
this.refreshParticipants();
|
||||
})
|
||||
.on(RoomEvent.LocalTrackPublished, (publication: LocalTrackPublication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
this.isSharing = true;
|
||||
this.localScreenTrack = (publication.track as LocalVideoTrack) ?? null;
|
||||
}
|
||||
|
||||
if (publication.source === Track.Source.Camera) {
|
||||
this.isCameraOn = true;
|
||||
this.localCameraTrack = (publication.track as LocalVideoTrack) ?? null;
|
||||
}
|
||||
|
||||
this.refreshParticipants();
|
||||
})
|
||||
.on(RoomEvent.LocalTrackUnpublished, (publication: LocalTrackPublication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
this.isSharing = false;
|
||||
this.localScreenTrack = null;
|
||||
}
|
||||
|
||||
if (publication.source === Track.Source.Camera) {
|
||||
this.isCameraOn = false;
|
||||
this.localCameraTrack = null;
|
||||
}
|
||||
|
||||
this.refreshParticipants();
|
||||
})
|
||||
.on(RoomEvent.ParticipantConnected, () => this.refreshParticipants())
|
||||
.on(RoomEvent.ParticipantDisconnected, () => this.refreshParticipants())
|
||||
.on(RoomEvent.Connected, () => this.refreshParticipants());
|
||||
}
|
||||
|
||||
private refreshParticipants(): void {
|
||||
const room = this.connection?.room;
|
||||
if (!room) {
|
||||
this.participants = [];
|
||||
this.sharingParticipantName = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const publishes = (
|
||||
participant: { trackPublications: Map<string, { source: Track.Source }> },
|
||||
source: Track.Source
|
||||
) =>
|
||||
[...participant.trackPublications.values()].some(
|
||||
(publication) => publication.source === source
|
||||
);
|
||||
|
||||
const self: RoomParticipant = {
|
||||
participantId: this.session.participantId,
|
||||
displayName: this.session.displayName,
|
||||
isSelf: true,
|
||||
isSharing: this.isSharing,
|
||||
isCameraOn: this.isCameraOn
|
||||
};
|
||||
|
||||
const others = [...room.remoteParticipants.values()].map((participant) => ({
|
||||
participantId: participant.identity,
|
||||
displayName: participant.name || participant.identity,
|
||||
isSelf: false,
|
||||
isSharing: publishes(participant, Track.Source.ScreenShare),
|
||||
isCameraOn: publishes(participant, Track.Source.Camera)
|
||||
}));
|
||||
|
||||
this.participants = [self, ...others];
|
||||
this.sharingParticipantName = this.broadcaster?.displayName ?? null;
|
||||
}
|
||||
|
||||
/// The room has a single broadcast slot. Whoever holds it, with either a screen or a
|
||||
/// camera, holds it until they stop.
|
||||
get broadcaster(): RoomParticipant | null {
|
||||
return (
|
||||
this.participants.find((participant) => participant.isSharing || participant.isCameraOn) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
get isSomeoneElseSharing(): boolean {
|
||||
const holder = this.broadcaster;
|
||||
return Boolean(holder && !holder.isSelf);
|
||||
}
|
||||
|
||||
/// One source at a time, per room and per person, so a phone camera cannot quietly ride
|
||||
/// alongside a screen share.
|
||||
get canStartScreenShare(): boolean {
|
||||
return !this.isSomeoneElseSharing && !this.isCameraOn;
|
||||
}
|
||||
|
||||
get canStartCamera(): boolean {
|
||||
return !this.isSomeoneElseSharing && !this.isSharing;
|
||||
}
|
||||
|
||||
get blockedReason(): string | null {
|
||||
const holder = this.broadcaster;
|
||||
if (holder && !holder.isSelf) {
|
||||
const what = holder.isSharing ? "sharing a screen" : "sharing a camera";
|
||||
return `${holder.displayName} is ${what}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
get participantCount(): number {
|
||||
return this.participants.length;
|
||||
}
|
||||
|
||||
async startScreenShare(): Promise<void> {
|
||||
const room = this.connection?.room;
|
||||
if (!room) return;
|
||||
|
||||
if (!this.canStartScreenShare) {
|
||||
this.errorMessage =
|
||||
this.blockedReason ?? "Turn your camera off before sharing your screen.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.errorMessage = null;
|
||||
await room.localParticipant.setScreenShareEnabled(true, {
|
||||
audio: false,
|
||||
contentHint: "detail"
|
||||
});
|
||||
this.setMode("presentation");
|
||||
} catch {
|
||||
this.errorMessage = "Screen sharing was blocked or cancelled.";
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes the device camera. Phones default to the rear lens, which is what people
|
||||
/// point at a whiteboard or a room, and can be flipped without dropping the track.
|
||||
async startCamera(): Promise<void> {
|
||||
const room = this.connection?.room;
|
||||
if (!room) return;
|
||||
|
||||
if (!this.canStartCamera) {
|
||||
this.errorMessage =
|
||||
this.blockedReason ?? "Stop sharing your screen before turning the camera on.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.errorMessage = null;
|
||||
await room.localParticipant.setCameraEnabled(true, {
|
||||
facingMode: this.facingMode,
|
||||
resolution: { width: 1280, height: 720 }
|
||||
});
|
||||
} catch {
|
||||
this.errorMessage = "Could not use the camera. Check the site has permission.";
|
||||
this.isCameraOn = false;
|
||||
}
|
||||
}
|
||||
|
||||
async stopCamera(): Promise<void> {
|
||||
const room = this.connection?.room;
|
||||
if (!room) return;
|
||||
await room.localParticipant.setCameraEnabled(false);
|
||||
}
|
||||
|
||||
async flipCamera(): Promise<void> {
|
||||
this.facingMode = this.facingMode === "environment" ? "user" : "environment";
|
||||
if (!this.isCameraOn) return;
|
||||
|
||||
const track = this.localCameraTrack;
|
||||
if (!track) return;
|
||||
|
||||
try {
|
||||
await track.restartTrack({ facingMode: this.facingMode });
|
||||
} catch {
|
||||
this.errorMessage = "This device only has one camera.";
|
||||
}
|
||||
}
|
||||
|
||||
async stopScreenShare(): Promise<void> {
|
||||
const room = this.connection?.room;
|
||||
if (!room) return;
|
||||
await room.localParticipant.setScreenShareEnabled(false);
|
||||
this.setMode("idle");
|
||||
}
|
||||
|
||||
setMode(mode: RoomMode): void {
|
||||
if (!this.canPresent) return;
|
||||
this.mode = mode;
|
||||
this.send("control", { type: "mode.set", mode });
|
||||
}
|
||||
|
||||
beginStroke(point: NormalizedPoint): void {
|
||||
if (this.isPointerMode) {
|
||||
this.movePointer(point);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isEraser) {
|
||||
this.eraseAt(point);
|
||||
return;
|
||||
}
|
||||
|
||||
const strokeId = makeStrokeId();
|
||||
this.activeStrokeId = strokeId;
|
||||
this.pendingPoints = [];
|
||||
|
||||
const event = {
|
||||
type: "stroke.start" as const,
|
||||
strokeId,
|
||||
tool: this.tool,
|
||||
style: { ...this.strokeStyle },
|
||||
point
|
||||
};
|
||||
|
||||
this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId);
|
||||
this.send("annotation", event);
|
||||
}
|
||||
|
||||
extendStroke(point: NormalizedPoint): void {
|
||||
if (this.isPointerMode) {
|
||||
this.movePointer(point);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isEraser) {
|
||||
this.eraseAt(point);
|
||||
return;
|
||||
}
|
||||
if (!this.activeStrokeId) return;
|
||||
|
||||
const event = {
|
||||
type: "stroke.append" as const,
|
||||
strokeId: this.activeStrokeId,
|
||||
points: [point]
|
||||
};
|
||||
|
||||
this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId);
|
||||
this.pendingPoints.push(point);
|
||||
this.flushPendingPoints();
|
||||
}
|
||||
|
||||
endStroke(): void {
|
||||
if (this.isPointerMode) return;
|
||||
if (!this.activeStrokeId) return;
|
||||
const strokeId = this.activeStrokeId;
|
||||
this.activeStrokeId = null;
|
||||
this.flushPendingPoints(true);
|
||||
|
||||
const event = { type: "stroke.end" as const, strokeId };
|
||||
this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId);
|
||||
this.send("annotation", event);
|
||||
}
|
||||
|
||||
movePointer(point: NormalizedPoint | null): void {
|
||||
this.send(
|
||||
"annotation",
|
||||
{ type: "pointer.move", point, color: this.strokeStyle.color },
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
clearAnnotations(): void {
|
||||
const event = { type: "canvas.clear" as const };
|
||||
this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId);
|
||||
this.send("annotation", event);
|
||||
}
|
||||
|
||||
pushWhiteboardElements(elements: WhiteboardElement[]): void {
|
||||
if (elements.length === 0) return;
|
||||
this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, elements);
|
||||
this.send("whiteboard", { type: "whiteboard.patch", elements });
|
||||
}
|
||||
|
||||
private eraseAt(point: NormalizedPoint): void {
|
||||
const strokeIds = eraseAtPoint(this.annotations, point, 0.02);
|
||||
if (strokeIds.length === 0) return;
|
||||
|
||||
const event = { type: "stroke.erase" as const, strokeIds };
|
||||
this.annotations = applyAnnotationEvent(this.annotations, event, this.session.participantId);
|
||||
this.send("annotation", event);
|
||||
}
|
||||
|
||||
private flushPendingPoints(force = false): void {
|
||||
if (this.pendingPoints.length === 0) return;
|
||||
if (!force && this.pendingPoints.length < 3) return;
|
||||
if (!this.activeStrokeId && !force) return;
|
||||
|
||||
const strokeId = this.activeStrokeId;
|
||||
const points = this.pendingPoints;
|
||||
this.pendingPoints = [];
|
||||
if (!strokeId) return;
|
||||
|
||||
this.send("annotation", { type: "stroke.append", strokeId, points }, false);
|
||||
}
|
||||
|
||||
private requestRoomState(): void {
|
||||
this.send("control", { type: "room.state.request" });
|
||||
this.send("whiteboard", { type: "whiteboard.request" });
|
||||
}
|
||||
|
||||
private startPointerPruning(): void {
|
||||
this.flushTimer = setInterval(() => {
|
||||
this.annotations = prunePointers(this.annotations, Date.now());
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private send<T extends DataTopic>(
|
||||
topic: T,
|
||||
payload: TopicPayloadMap[T],
|
||||
reliable = true
|
||||
): void {
|
||||
const room = this.connection?.room;
|
||||
if (!room) return;
|
||||
publishEnvelope(room, topic, this.session.participantId, payload, reliable);
|
||||
}
|
||||
|
||||
private handleEnvelope(envelope: DataEnvelope): void {
|
||||
if (envelope.senderId === this.session.participantId) return;
|
||||
|
||||
if (isTopic(envelope, "annotation")) {
|
||||
this.annotations = applyAnnotationEvent(
|
||||
this.annotations,
|
||||
envelope.payload,
|
||||
envelope.senderId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTopic(envelope, "control")) {
|
||||
this.handleControlEvent(envelope.payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTopic(envelope, "whiteboard")) {
|
||||
this.handleWhiteboardEvent(envelope.payload);
|
||||
}
|
||||
}
|
||||
|
||||
private handleControlEvent(event: ControlEvent): void {
|
||||
if (event.type === "mode.set") {
|
||||
this.mode = event.mode;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "room.state") {
|
||||
this.mode = event.state.mode;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "room.state.request" && this.isSharing) {
|
||||
this.send("control", {
|
||||
type: "room.state",
|
||||
state: {
|
||||
roomName: this.session.roomName,
|
||||
mode: this.mode,
|
||||
presenterId: this.session.participantId,
|
||||
annotationsLocked: false,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleWhiteboardEvent(event: WhiteboardEvent): void {
|
||||
if (event.type === "whiteboard.patch") {
|
||||
this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, event.elements);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "whiteboard.snapshot") {
|
||||
this.whiteboardElements = event.elements;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "whiteboard.clear") {
|
||||
this.whiteboardElements = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "whiteboard.request" && this.whiteboardElements.length > 0) {
|
||||
this.send("whiteboard", {
|
||||
type: "whiteboard.snapshot",
|
||||
elements: this.whiteboardElements,
|
||||
backgroundColor: "#0b0d10"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { JoinResponse } from "@pistation/shared-types";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
const SESSION_KEY = "pistation.session";
|
||||
const ADMIN_KEY = "pistation.admin";
|
||||
|
||||
export type StoredSession = JoinResponse;
|
||||
|
||||
export function saveSession(session: StoredSession): void {
|
||||
if (!browser) return;
|
||||
sessionStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
export function loadSession(): StoredSession | null {
|
||||
if (!browser) return null;
|
||||
const raw = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as StoredSession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
if (!browser) return;
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
}
|
||||
|
||||
export interface StoredAdmin {
|
||||
accessToken: string;
|
||||
email: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export function saveAdmin(admin: StoredAdmin): void {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(ADMIN_KEY, JSON.stringify(admin));
|
||||
}
|
||||
|
||||
export function loadAdmin(): StoredAdmin | null {
|
||||
if (!browser) return null;
|
||||
const raw = localStorage.getItem(ADMIN_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const admin = JSON.parse(raw) as StoredAdmin;
|
||||
if (admin.expiresAt <= Date.now()) {
|
||||
localStorage.removeItem(ADMIN_KEY);
|
||||
return null;
|
||||
}
|
||||
return admin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAdmin(): void {
|
||||
if (!browser) return;
|
||||
localStorage.removeItem(ADMIN_KEY);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { themeStyle } from "@pistation/shared-types";
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
import { branding, ensureBranding } from "$lib/branding.svelte";
|
||||
import { SITE_DESCRIPTION, SITE_NAME, THEME_COLOR } from "$lib/meta";
|
||||
import "../app.css";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
void ensureBranding();
|
||||
});
|
||||
|
||||
const canonicalUrl = $derived(`${$page.url.origin}${$page.url.pathname}`);
|
||||
const imageUrl = $derived(`${$page.url.origin}/og-image.svg`);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta name="theme-color" content={THEME_COLOR} />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
|
||||
<meta property="og:site_name" content={SITE_NAME} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:image" content={imageUrl} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content={SITE_NAME} />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:image" content={imageUrl} />
|
||||
|
||||
<meta name="description" content={SITE_DESCRIPTION} />
|
||||
<meta property="og:description" content={SITE_DESCRIPTION} />
|
||||
<meta name="twitter:description" content={SITE_DESCRIPTION} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-full" style={themeStyle(branding.value)}>
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -0,0 +1,262 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { Logo } from "@pistation/ui";
|
||||
|
||||
import { resolveMediaUrl } from "@pistation/shared-types";
|
||||
|
||||
import { apiBaseUrl } from "$lib/api";
|
||||
import { branding as brandingStore } from "$lib/branding.svelte";
|
||||
import JoinCard from "$lib/components/JoinCard.svelte";
|
||||
import { SITE_DESCRIPTION, SITE_KEYWORDS } from "$lib/meta";
|
||||
|
||||
const branding = $derived(brandingStore.value);
|
||||
const isJoinOnly = $derived(branding.landingMode === "join");
|
||||
|
||||
const logoUrl = $derived(branding.logoUrl ? resolveMediaUrl(apiBaseUrl, branding.logoUrl) : "");
|
||||
const title = $derived(`${branding.name} · ${branding.headline}`);
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: "ph:broadcast-bold",
|
||||
title: "Share your screen",
|
||||
body: "Publish a tab, a window or your whole desktop to the big screen. Nothing to install, no cable to hunt for, and anyone in the room can take a turn."
|
||||
},
|
||||
{
|
||||
icon: "ph:pencil-simple-bold",
|
||||
title: "Annotate live",
|
||||
body: "Draw over whatever is on screen with an adjustable pen, arrows, shapes and a highlighter. Every stroke lands in the same place on the TV as on your phone."
|
||||
},
|
||||
{
|
||||
icon: "ph:video-camera-bold",
|
||||
title: "Share a camera",
|
||||
body: "Point a phone at a whiteboard, a workbench or the room itself. Front and rear cameras both work, and a screen share always takes priority."
|
||||
},
|
||||
{
|
||||
icon: "ph:hand-pointing-bold",
|
||||
title: "Point things out",
|
||||
body: "Pointer mode shows the room where you are gesturing without leaving a mark, and everyone's cursor carries their name."
|
||||
},
|
||||
{
|
||||
icon: "ph:scribble-loop-bold",
|
||||
title: "Shared whiteboard",
|
||||
body: "Flip the room into a full Excalidraw canvas that everyone can edit at once, mirrored live onto the screen."
|
||||
},
|
||||
{
|
||||
icon: "ph:squares-four-bold",
|
||||
title: "Idle dashboard",
|
||||
body: "When nobody is presenting the screen becomes a dashboard. Clock, weather, agenda, rotating wallpapers, or a widget you build yourself."
|
||||
},
|
||||
{
|
||||
icon: "ph:moon-bold",
|
||||
title: "Night mode",
|
||||
body: "Outside working hours the display drops to just the time and the join code, dimmed to whatever level suits the room."
|
||||
}
|
||||
];
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: "Plug in the Pi",
|
||||
body: "A Raspberry Pi Zero 2 W, 4 or 5 connects to any TV or monitor over HDMI and boots straight into the kiosk. One command sets it up over SSH."
|
||||
},
|
||||
{
|
||||
title: "Read the code",
|
||||
body: "The screen shows a six digit code that rotates every minute, so an old photo of it is worthless to anyone."
|
||||
},
|
||||
{
|
||||
title: "Take the screen",
|
||||
body: "Type the code here and give your name, then share a screen or a camera. You keep the screen until you leave, even as the code carries on rotating."
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title}</title>
|
||||
<meta name="keywords" content={SITE_KEYWORDS} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="description" content={SITE_DESCRIPTION} />
|
||||
</svelte:head>
|
||||
|
||||
{#if isJoinOnly}
|
||||
<main class="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt="" class="h-16 w-16 object-contain" />
|
||||
{:else}
|
||||
<Logo size={64} />
|
||||
{/if}
|
||||
<h1 class="text-3xl font-semibold tracking-tight">{branding.name}</h1>
|
||||
{#if branding.description}
|
||||
<p class="max-w-md text-ink-2">{branding.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<JoinCard label={branding.joinLabel} />
|
||||
</main>
|
||||
{:else}
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<header class="sticky top-0 z-10 bg-surface-0/90 backdrop-blur">
|
||||
<nav class="mx-auto flex w-full max-w-6xl items-center gap-4 px-6 py-4">
|
||||
<a href="/" class="flex items-center gap-3">
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt="" class="h-9 w-9 object-contain" />
|
||||
{:else}
|
||||
<Logo size={36} />
|
||||
{/if}
|
||||
<span class="text-lg font-semibold tracking-tight">{branding.name}</span>
|
||||
</a>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<a
|
||||
href="#how-it-works"
|
||||
class="hidden px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-1 sm:block"
|
||||
>
|
||||
How it works
|
||||
</a>
|
||||
<a
|
||||
href="#features"
|
||||
class="hidden px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-1 sm:block"
|
||||
>
|
||||
Features
|
||||
</a>
|
||||
<a
|
||||
href="#self-host"
|
||||
class="hidden px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-1 sm:block"
|
||||
>
|
||||
Self host
|
||||
</a>
|
||||
<a
|
||||
href="/admin"
|
||||
class="flex items-center gap-2 bg-surface-2 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:shield-check-bold" width="16" />
|
||||
Admin
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="flex-1">
|
||||
<section class="mx-auto w-full max-w-6xl px-6 py-16 sm:py-24">
|
||||
<div class="grid items-center gap-12 lg:grid-cols-[1.1fr_1fr]">
|
||||
<div>
|
||||
<h1 class="text-4xl leading-[1.05] font-semibold tracking-tight sm:text-6xl">
|
||||
{branding.headline}
|
||||
</h1>
|
||||
|
||||
<p class="mt-6 max-w-xl text-lg text-ink-1">{branding.description}</p>
|
||||
</div>
|
||||
|
||||
<JoinCard label={branding.joinLabel} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="how-it-works" class="bg-surface-1">
|
||||
<div class="mx-auto w-full max-w-6xl px-6 py-16 sm:py-20">
|
||||
<h2 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">How it works</h2>
|
||||
<p class="mt-3 max-w-2xl text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
Three steps, no setup for the people joining.
|
||||
</p>
|
||||
|
||||
<div class="mt-10 grid gap-px sm:grid-cols-3">
|
||||
{#each steps as step, index}
|
||||
<div class="flex flex-col gap-3 bg-surface-0 p-8">
|
||||
<span class="font-mono text-4xl font-semibold text-accent">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<h3 class="text-lg font-medium">{step.title}</h3>
|
||||
<p class="text-ink-2">{step.body}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="features" class="mx-auto w-full max-w-6xl px-6 py-16 sm:py-20">
|
||||
<h2 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">Features</h2>
|
||||
<p class="mt-3 max-w-2xl text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
Everything the room needs on one screen.
|
||||
</p>
|
||||
|
||||
<div class="mt-10 grid gap-px sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each features as feature}
|
||||
<div class="flex flex-col gap-3 bg-surface-1 p-8">
|
||||
<Icon icon={feature.icon} width="26" class="text-accent" />
|
||||
<h3 class="text-lg font-medium">{feature.title}</h3>
|
||||
<p class="text-ink-2">{feature.body}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="self-host" class="bg-surface-1">
|
||||
<div class="mx-auto grid w-full max-w-6xl gap-10 px-6 py-16 sm:py-20 lg:grid-cols-2">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">Self host</h2>
|
||||
<p class="mt-3 text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
Your screens, your server, your data.
|
||||
</p>
|
||||
<p class="mt-4 text-ink-1">
|
||||
PiStation ships as a Docker Compose stack: a LiveKit media server, a Rust backend and
|
||||
this site. Media travels directly between devices on your own network, so a
|
||||
presentation never touches anyone else's infrastructure.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-col gap-3">
|
||||
{#each ["One command to bring the whole stack up", "SQLite for storage, no external database", "Add as many kiosks and rooms as you like", "Every kiosk reports its own CPU, memory and signal strength"] as line}
|
||||
<p class="flex items-start gap-3 text-ink-2">
|
||||
<Icon icon="ph:check-bold" width="18" class="mt-1 shrink-0 text-success" />
|
||||
{line}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-center bg-surface-0 p-6 sm:p-8">
|
||||
<p class="mb-3 text-xs tracking-wide text-ink-2 uppercase">Get started</p>
|
||||
<pre class="overflow-x-auto font-mono text-sm text-ink-1"><code
|
||||
>cp infra/.env.example infra/.env
|
||||
docker compose -f infra/docker-compose.yml up -d</code
|
||||
></pre>
|
||||
<p class="mt-4 text-sm text-ink-2">
|
||||
Then sign in to the admin panel, add a kiosk, and copy its enrollment token onto the Pi.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="mx-auto flex w-full max-w-6xl flex-wrap items-center gap-4 px-6 py-8">
|
||||
<p class="text-sm text-ink-2">
|
||||
{branding.name}{branding.footerNote ? ` · ${branding.footerNote}` : ""}
|
||||
</p>
|
||||
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
{#each branding.links as link (link.linkId)}
|
||||
{#if link.label && link.url}
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-4 py-2 text-sm text-ink-2 transition-colors hover:text-ink-0"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if branding.showSourceLink}
|
||||
<a
|
||||
href="https://github.com/SirBlobby/pistation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-2 bg-surface-1 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-2 hover:text-ink-0"
|
||||
>
|
||||
<Icon icon="ph:github-logo-bold" width="16" />
|
||||
Source on GitHub
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,435 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { Kiosk } from "@pistation/shared-types";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
import {
|
||||
adminLogin,
|
||||
ApiError,
|
||||
createKiosk,
|
||||
deleteKiosk,
|
||||
listKiosks,
|
||||
updateKiosk,
|
||||
uploadKioskPackage
|
||||
} from "$lib/api";
|
||||
import { Logo } from "@pistation/ui";
|
||||
|
||||
import InstallCommand from "$lib/components/admin/InstallCommand.svelte";
|
||||
import KioskStats from "$lib/components/admin/KioskStats.svelte";
|
||||
import { clearAdmin, loadAdmin, saveAdmin, type StoredAdmin } from "$lib/session";
|
||||
|
||||
let admin = $state<StoredAdmin | null>(null);
|
||||
let kiosks = $state<Kiosk[]>([]);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let isBusy = $state(false);
|
||||
|
||||
let email = $state("");
|
||||
let password = $state("");
|
||||
|
||||
let newName = $state("");
|
||||
let newLocation = $state("");
|
||||
let issuedToken = $state<{ name: string; token: string } | null>(null);
|
||||
let editingId = $state<string | null>(null);
|
||||
let editName = $state("");
|
||||
let editLocation = $state("");
|
||||
|
||||
function startRename(kiosk: Kiosk) {
|
||||
editingId = kiosk.kioskId;
|
||||
editName = kiosk.name;
|
||||
editLocation = kiosk.location;
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
async function saveRename(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!admin || !editingId || !editName.trim()) return;
|
||||
|
||||
isBusy = true;
|
||||
errorMessage = null;
|
||||
|
||||
try {
|
||||
await updateKiosk(admin.accessToken, editingId, editName, editLocation);
|
||||
editingId = null;
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof ApiError ? error.message : "Could not rename the kiosk.";
|
||||
} finally {
|
||||
isBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
let packageInput = $state<HTMLInputElement | null>(null);
|
||||
let isUploadingPackage = $state(false);
|
||||
let packageMessage = $state<string | null>(null);
|
||||
|
||||
async function handlePackageUpload(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file || !admin) return;
|
||||
|
||||
isUploadingPackage = true;
|
||||
packageMessage = null;
|
||||
errorMessage = null;
|
||||
|
||||
try {
|
||||
const result = await uploadKioskPackage(admin.accessToken, file);
|
||||
const megabytes = (result.sizeBytes / 1024 / 1024).toFixed(1);
|
||||
packageMessage = `Uploaded ${file.name}, ${megabytes} MB. Kiosks will install this build.`;
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof ApiError ? error.message : "Could not upload the package.";
|
||||
} finally {
|
||||
isUploadingPackage = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
admin = loadAdmin();
|
||||
if (admin) void refresh();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (admin && !editingId) void refresh();
|
||||
}, 15000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
async function signIn(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
isBusy = true;
|
||||
errorMessage = null;
|
||||
|
||||
try {
|
||||
const response = await adminLogin(email, password);
|
||||
const stored = {
|
||||
accessToken: response.accessToken,
|
||||
email: response.email,
|
||||
expiresAt: response.expiresAt
|
||||
};
|
||||
saveAdmin(stored);
|
||||
admin = stored;
|
||||
password = "";
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof ApiError ? error.message : "Sign in failed.";
|
||||
} finally {
|
||||
isBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
clearAdmin();
|
||||
admin = null;
|
||||
kiosks = [];
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!admin) return;
|
||||
try {
|
||||
const response = await listKiosks(admin.accessToken);
|
||||
kiosks = response.kiosks;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) signOut();
|
||||
else errorMessage = "Could not load kiosks.";
|
||||
}
|
||||
}
|
||||
|
||||
async function addKiosk(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!admin || !newName.trim()) return;
|
||||
|
||||
isBusy = true;
|
||||
try {
|
||||
const response = await createKiosk(admin.accessToken, newName, newLocation);
|
||||
issuedToken = { name: response.kiosk.name, token: response.enrollmentToken };
|
||||
newName = "";
|
||||
newLocation = "";
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof ApiError ? error.message : "Could not create kiosk.";
|
||||
} finally {
|
||||
isBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeKiosk(kiosk: Kiosk) {
|
||||
if (!admin) return;
|
||||
if (!confirm(`Delete ${kiosk.name}? This cannot be undone.`)) return;
|
||||
|
||||
await deleteKiosk(admin.accessToken, kiosk.kioskId);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function formatLastSeen(timestamp: number | null) {
|
||||
if (!timestamp) return "never";
|
||||
return new Date(timestamp).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Admin · PiStation</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
{#if !admin}
|
||||
<main class="flex min-h-screen flex-col items-center justify-center px-6 py-12">
|
||||
<div class="w-full max-w-sm">
|
||||
<a
|
||||
href="/"
|
||||
class="mb-8 inline-flex items-center gap-2 text-sm text-ink-2 transition-colors hover:text-ink-0"
|
||||
>
|
||||
<Icon icon="ph:arrow-left-bold" width="16" />
|
||||
Back to PiStation
|
||||
</a>
|
||||
|
||||
<div class="mb-8 flex flex-col items-center text-center">
|
||||
<Logo size={56} class="mb-4" />
|
||||
<h1 class="text-2xl font-semibold tracking-tight">PiStation admin</h1>
|
||||
<p class="mt-1 text-sm text-ink-2">Sign in to manage kiosks</p>
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="mb-6 flex items-center gap-2 bg-danger/15 px-4 py-3 text-sm text-danger">
|
||||
<Icon icon="ph:warning-circle-bold" width="18" class="shrink-0" />
|
||||
{errorMessage}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={signIn} class="bg-surface-1 p-6">
|
||||
<label class="mb-4 block">
|
||||
<span class="mb-2 block text-sm font-medium text-ink-1">Email</span>
|
||||
<input
|
||||
bind:value={email}
|
||||
type="email"
|
||||
required
|
||||
class="w-full bg-surface-2 px-4 py-3 text-ink-0"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="mb-6 block">
|
||||
<span class="mb-2 block text-sm font-medium text-ink-1">Password</span>
|
||||
<input
|
||||
bind:value={password}
|
||||
type="password"
|
||||
required
|
||||
class="w-full bg-surface-2 px-4 py-3 text-ink-0"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isBusy}
|
||||
class="w-full bg-accent px-6 py-3 font-semibold text-white transition-colors hover:bg-accent-strong disabled:bg-surface-3"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
{:else}
|
||||
<main class="mx-auto w-full max-w-5xl px-6 py-12">
|
||||
<header class="mb-10 flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href="/"
|
||||
aria-label="Back to PiStation"
|
||||
class="flex h-10 w-10 items-center justify-center bg-surface-2 text-ink-1 transition-colors hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:arrow-left-bold" width="18" />
|
||||
</a>
|
||||
|
||||
<Logo size={40} />
|
||||
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-semibold tracking-tight">PiStation admin</h1>
|
||||
<p class="text-sm text-ink-2">{admin.email}</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/admin/organization"
|
||||
class="flex items-center gap-2 bg-surface-2 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:buildings-bold" width="16" />
|
||||
Organisation
|
||||
</a>
|
||||
|
||||
<button
|
||||
onclick={signOut}
|
||||
class="bg-surface-2 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="mb-6 flex items-center gap-2 bg-danger/15 px-4 py-3 text-sm text-danger">
|
||||
<Icon icon="ph:warning-circle-bold" width="18" class="shrink-0" />
|
||||
{errorMessage}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if issuedToken}
|
||||
<div class="mb-8 flex flex-col gap-3">
|
||||
<InstallCommand enrollmentToken={issuedToken.token} kioskName={issuedToken.name} />
|
||||
<button
|
||||
onclick={() => (issuedToken = null)}
|
||||
class="self-start bg-surface-2 px-4 py-2 text-sm text-ink-1 hover:bg-surface-3"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="mb-10 flex flex-col gap-3 bg-surface-1 p-5">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Icon icon="ph:package-bold" width="18" class="text-accent" />
|
||||
<h2 class="flex-1 text-sm font-semibold tracking-wide uppercase">Kiosk build</h2>
|
||||
|
||||
<input
|
||||
bind:this={packageInput}
|
||||
type="file"
|
||||
accept=".deb"
|
||||
onchange={handlePackageUpload}
|
||||
class="hidden"
|
||||
/>
|
||||
|
||||
<button
|
||||
onclick={() => packageInput?.click()}
|
||||
disabled={isUploadingPackage}
|
||||
class="flex items-center gap-2 bg-surface-2 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3 disabled:text-ink-2"
|
||||
>
|
||||
{#if isUploadingPackage}
|
||||
<Icon icon="ph:circle-notch-bold" width="16" class="animate-spin" />
|
||||
Uploading
|
||||
{:else}
|
||||
<Icon icon="ph:upload-simple-bold" width="16" />
|
||||
Upload .deb
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-ink-2">
|
||||
The installer script downloads this package onto each Pi. Upload the arm64 build produced
|
||||
by the release workflow, then every kiosk is one command to set up.
|
||||
</p>
|
||||
|
||||
{#if packageMessage}
|
||||
<p class="bg-success/15 px-4 py-2 text-sm text-success">{packageMessage}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="mb-4 text-sm font-semibold tracking-wide text-ink-2 uppercase">Kiosks</h2>
|
||||
|
||||
{#if kiosks.length === 0}
|
||||
<p class="bg-surface-1 px-6 py-8 text-center text-ink-2">
|
||||
No kiosks yet. Add one below to get an enrollment token.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-px">
|
||||
{#each kiosks as kiosk (kiosk.kioskId)}
|
||||
<div class="flex flex-wrap items-center gap-4 bg-surface-1 px-5 py-4">
|
||||
<span
|
||||
class="h-2 w-2 shrink-0"
|
||||
class:bg-success={kiosk.status === "online"}
|
||||
class:bg-ink-2={kiosk.status !== "online"}
|
||||
></span>
|
||||
|
||||
{#if editingId === kiosk.kioskId}
|
||||
<form onsubmit={saveRename} class="flex min-w-60 flex-1 flex-wrap items-center gap-2">
|
||||
<input
|
||||
bind:value={editName}
|
||||
placeholder="Name"
|
||||
required
|
||||
class="min-w-32 flex-1 bg-surface-2 px-3 py-2 text-sm text-ink-0"
|
||||
/>
|
||||
<input
|
||||
bind:value={editLocation}
|
||||
placeholder="Location"
|
||||
class="min-w-32 flex-1 bg-surface-2 px-3 py-2 text-sm text-ink-0"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isBusy || !editName.trim()}
|
||||
class="flex items-center gap-2 bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-strong disabled:bg-surface-3 disabled:text-ink-2"
|
||||
>
|
||||
<Icon icon="ph:check-bold" width="16" />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={cancelRename}
|
||||
class="bg-surface-2 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="min-w-40 flex-1">
|
||||
<p class="font-medium">{kiosk.name}</p>
|
||||
<p class="text-sm text-ink-2">
|
||||
{kiosk.location || "No location"} · last seen {formatLastSeen(kiosk.lastSeenAt)}
|
||||
</p>
|
||||
{#if kiosk.status === "online" && kiosk.metrics}
|
||||
<div class="mt-1.5">
|
||||
<KioskStats metrics={kiosk.metrics} metricsAt={kiosk.metricsAt} compact />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={() => startRename(kiosk)}
|
||||
aria-label={`Rename ${kiosk.name}`}
|
||||
class="flex h-9 w-9 items-center justify-center bg-surface-2 text-ink-1 transition-colors hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:pencil-simple-bold" width="16" />
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={`/admin/kiosks/${kiosk.kioskId}`}
|
||||
class="flex items-center gap-2 bg-surface-2 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:squares-four-bold" width="16" />
|
||||
Widgets
|
||||
</a>
|
||||
|
||||
<button
|
||||
onclick={() => removeKiosk(kiosk)}
|
||||
aria-label={`Delete ${kiosk.name}`}
|
||||
class="flex h-9 w-9 items-center justify-center bg-surface-2 text-danger transition-colors hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:trash-bold" width="16" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-surface-1 p-6">
|
||||
<h2 class="mb-4 text-sm font-semibold tracking-wide text-ink-2 uppercase">Add a kiosk</h2>
|
||||
<form onsubmit={addKiosk} class="flex flex-wrap gap-3">
|
||||
<input
|
||||
bind:value={newName}
|
||||
placeholder="Name"
|
||||
required
|
||||
class="min-w-40 flex-1 bg-surface-2 px-4 py-3 text-ink-0 placeholder:text-ink-2"
|
||||
/>
|
||||
<input
|
||||
bind:value={newLocation}
|
||||
placeholder="Location"
|
||||
class="min-w-40 flex-1 bg-surface-2 px-4 py-3 text-ink-0 placeholder:text-ink-2"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isBusy}
|
||||
class="flex items-center gap-2 bg-accent px-6 py-3 font-semibold text-white transition-colors hover:bg-accent-strong disabled:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:plus-bold" width="18" />
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
{/if}
|
||||
@@ -0,0 +1,391 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { CustomWidgetDefinition, Kiosk, KioskLayout, Widget, WidgetKind } from "@pistation/shared-types";
|
||||
import {
|
||||
BUILTIN_WIDGET_KINDS,
|
||||
createId,
|
||||
DEFAULT_WIDGET_SETTINGS,
|
||||
DEFAULT_WIDGET_STYLES,
|
||||
ensureUniqueIds,
|
||||
findFreePlacement
|
||||
} from "@pistation/shared-types";
|
||||
import { NightSurface } from "@pistation/ui";
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
apiBaseUrl,
|
||||
getKiosk,
|
||||
rotateEnrollment,
|
||||
saveKioskLayout,
|
||||
uploadWallpaper
|
||||
} from "$lib/api";
|
||||
import AppearancePanel from "$lib/components/admin/AppearancePanel.svelte";
|
||||
import InstallCommand from "$lib/components/admin/InstallCommand.svelte";
|
||||
import KioskStats from "$lib/components/admin/KioskStats.svelte";
|
||||
import NightModePanel from "$lib/components/admin/NightModePanel.svelte";
|
||||
import WidgetBuilder from "$lib/components/admin/WidgetBuilder.svelte";
|
||||
import WidgetGridEditor from "$lib/components/admin/WidgetGridEditor.svelte";
|
||||
import WidgetList from "$lib/components/admin/WidgetList.svelte";
|
||||
import WidgetSettingsPanel from "$lib/components/admin/WidgetSettingsPanel.svelte";
|
||||
import { loadAdmin } from "$lib/session";
|
||||
|
||||
const kioskId = $derived($page.params.kioskId ?? "");
|
||||
|
||||
let kiosk = $state<Kiosk | null>(null);
|
||||
let layout = $state<KioskLayout | null>(null);
|
||||
let currentPin = $state<string | null>(null);
|
||||
let selectedWidgetId = $state<string | null>(null);
|
||||
let statusMessage = $state<string | null>(null);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let newEnrollmentToken = $state<string | null>(null);
|
||||
let isPreviewingNight = $state(false);
|
||||
let isUploading = $state(false);
|
||||
let uploadError = $state<string | null>(null);
|
||||
|
||||
const admin = loadAdmin();
|
||||
const selectedWidget = $derived(
|
||||
layout?.widgets.find((widget) => widget.widgetId === selectedWidgetId) ?? null
|
||||
);
|
||||
const joinUrl = $derived(
|
||||
typeof window === "undefined" ? "" : window.location.host
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
|
||||
// Only the device stats are refreshed on a timer. Re-reading the layout would throw
|
||||
// away whatever the admin is part way through editing.
|
||||
const interval = setInterval(() => void refreshStats(), 15000);
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
async function refreshStats() {
|
||||
if (!admin) return;
|
||||
|
||||
try {
|
||||
const detail = await getKiosk(admin.accessToken, kioskId);
|
||||
kiosk = detail.kiosk;
|
||||
currentPin = detail.currentPin;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!admin) return;
|
||||
try {
|
||||
const detail = await getKiosk(admin.accessToken, kioskId);
|
||||
kiosk = detail.kiosk;
|
||||
currentPin = detail.currentPin;
|
||||
layout = {
|
||||
...detail.layout,
|
||||
widgets: ensureUniqueIds(
|
||||
detail.layout.widgets,
|
||||
(widget) => widget.widgetId,
|
||||
(widget, widgetId) => ({ ...widget, widgetId }),
|
||||
"w"
|
||||
)
|
||||
};
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof ApiError ? error.message : "Could not load this kiosk.";
|
||||
}
|
||||
}
|
||||
|
||||
const PREFERRED_SPANS: Partial<Record<WidgetKind, [number, number]>> = {
|
||||
pin: [12, 4],
|
||||
clock: [5, 2],
|
||||
weather: [5, 2],
|
||||
image: [6, 4],
|
||||
agenda: [4, 4]
|
||||
};
|
||||
|
||||
function addWidget(kind: WidgetKind) {
|
||||
if (!layout) return;
|
||||
|
||||
const [columnSpan, rowSpan] = PREFERRED_SPANS[kind] ?? [4, 2];
|
||||
const placement = findFreePlacement(layout.widgets, columnSpan, rowSpan);
|
||||
|
||||
if (!placement) {
|
||||
errorMessage = "There is no free space left on the grid. Remove or shrink a widget first.";
|
||||
return;
|
||||
}
|
||||
|
||||
const widget: Widget = {
|
||||
widgetId: createId("w"),
|
||||
kind,
|
||||
placement,
|
||||
settings: structuredClone(DEFAULT_WIDGET_SETTINGS[kind]),
|
||||
style: structuredClone(DEFAULT_WIDGET_STYLES[kind]),
|
||||
enabled: true
|
||||
};
|
||||
|
||||
errorMessage = null;
|
||||
layout = { ...layout, widgets: [...layout.widgets, widget] };
|
||||
selectedWidgetId = widget.widgetId;
|
||||
}
|
||||
|
||||
function replaceWidgets(widgets: Widget[]) {
|
||||
if (!layout) return;
|
||||
layout = { ...layout, widgets };
|
||||
|
||||
if (selectedWidgetId && !widgets.some((widget) => widget.widgetId === selectedWidgetId)) {
|
||||
selectedWidgetId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateWidget(updated: Widget) {
|
||||
if (!layout) return;
|
||||
layout = {
|
||||
...layout,
|
||||
widgets: layout.widgets.map((widget) =>
|
||||
widget.widgetId === updated.widgetId ? updated : widget
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function removeWidget(widgetId: string) {
|
||||
if (!layout) return;
|
||||
layout = {
|
||||
...layout,
|
||||
widgets: layout.widgets.filter((widget) => widget.widgetId !== widgetId)
|
||||
};
|
||||
if (selectedWidgetId === widgetId) selectedWidgetId = null;
|
||||
}
|
||||
|
||||
function updateDefinitions(definitions: CustomWidgetDefinition[]) {
|
||||
if (!layout) return;
|
||||
layout = { ...layout, customDefinitions: definitions };
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!admin || !layout) return;
|
||||
statusMessage = null;
|
||||
errorMessage = null;
|
||||
|
||||
try {
|
||||
await saveKioskLayout(admin.accessToken, kioskId, layout);
|
||||
statusMessage = "Layout saved. The kiosk will pick it up within a minute.";
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof ApiError ? error.message : "Could not save the layout.";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWallpaperUpload(files: File[]) {
|
||||
if (!admin || !layout) return;
|
||||
|
||||
isUploading = true;
|
||||
uploadError = null;
|
||||
|
||||
const uploaded: string[] = [];
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const result = await uploadWallpaper(admin.accessToken, kioskId, file);
|
||||
uploaded.push(result.imageUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
uploadError = error instanceof ApiError ? error.message : "Upload failed.";
|
||||
}
|
||||
|
||||
if (uploaded.length > 0) {
|
||||
layout = {
|
||||
...layout,
|
||||
background: {
|
||||
...layout.background,
|
||||
images: [...(layout.background.images ?? []), ...uploaded]
|
||||
}
|
||||
};
|
||||
statusMessage = `Added ${uploaded.length} image${uploaded.length === 1 ? "" : "s"}. Save the layout to send it to the kiosk.`;
|
||||
}
|
||||
|
||||
isUploading = false;
|
||||
}
|
||||
|
||||
async function regenerateEnrollment() {
|
||||
if (!admin) return;
|
||||
if (!confirm("Rotating the enrollment token disconnects the current Pi until it re-enrolls.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await rotateEnrollment(admin.accessToken, kioskId);
|
||||
newEnrollmentToken = response.enrollmentToken;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{kiosk ? `${kiosk.name} · Admin · PiStation` : "Kiosk · Admin · PiStation"}</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<main class="mx-auto w-full max-w-6xl px-6 py-12">
|
||||
<header class="mb-8 flex flex-wrap items-center gap-4">
|
||||
<a
|
||||
href="/admin"
|
||||
aria-label="Back to kiosks"
|
||||
class="flex h-10 w-10 items-center justify-center bg-surface-2 text-ink-1 hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:arrow-left-bold" width="18" />
|
||||
</a>
|
||||
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-semibold tracking-tight">{kiosk?.name ?? "Kiosk"}</h1>
|
||||
<p class="text-sm text-ink-2">{kiosk?.location || "No location"}</p>
|
||||
</div>
|
||||
|
||||
{#if currentPin}
|
||||
<div class="bg-surface-1 px-4 py-2 text-center">
|
||||
<p class="text-xs tracking-wide text-ink-2 uppercase">Current PIN</p>
|
||||
<p class="font-mono text-lg font-semibold">{currentPin}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={save}
|
||||
class="flex items-center gap-2 bg-accent px-5 py-3 font-semibold text-white transition-colors hover:bg-accent-strong"
|
||||
>
|
||||
<Icon icon="ph:floppy-disk-bold" width="18" />
|
||||
Save layout
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if statusMessage}
|
||||
<p class="mb-6 flex items-center gap-2 bg-success/15 px-4 py-3 text-sm text-success">
|
||||
<Icon icon="ph:check-circle-bold" width="18" />
|
||||
{statusMessage}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="mb-6 flex items-center gap-2 bg-danger/15 px-4 py-3 text-sm text-danger">
|
||||
<Icon icon="ph:warning-circle-bold" width="18" />
|
||||
{errorMessage}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if layout}
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
|
||||
<section class="flex min-w-0 flex-col gap-8">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="flex-1 text-sm font-semibold tracking-wide text-ink-2 uppercase">
|
||||
Preview
|
||||
</h2>
|
||||
<button
|
||||
onclick={() => (isPreviewingNight = !isPreviewingNight)}
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm transition-colors"
|
||||
class:bg-accent={isPreviewingNight}
|
||||
class:text-white={isPreviewingNight}
|
||||
class:bg-surface-2={!isPreviewingNight}
|
||||
class:text-ink-1={!isPreviewingNight}
|
||||
>
|
||||
<Icon icon="ph:moon-bold" width="16" />
|
||||
Night mode
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if isPreviewingNight}
|
||||
<div class="aspect-video w-full overflow-hidden bg-surface-1">
|
||||
<NightSurface {layout} pin={currentPin} />
|
||||
</div>
|
||||
{:else}
|
||||
<WidgetGridEditor
|
||||
{layout}
|
||||
pin={currentPin}
|
||||
{joinUrl}
|
||||
mediaBaseUrl={apiBaseUrl}
|
||||
{selectedWidgetId}
|
||||
onSelect={(widgetId) => (selectedWidgetId = widgetId)}
|
||||
onWidgetsChange={replaceWidgets}
|
||||
/>
|
||||
<p class="text-xs text-ink-2">
|
||||
Drag a widget to move it, drag its corner to resize. With one selected, the arrow
|
||||
keys nudge it and shift with the arrow keys resizes.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<h2 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">Add a widget</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each BUILTIN_WIDGET_KINDS as kind}
|
||||
<button
|
||||
onclick={() => addWidget(kind)}
|
||||
class="bg-surface-2 px-4 py-3 text-sm text-ink-1 capitalize transition-colors hover:bg-surface-3"
|
||||
>
|
||||
+ {kind}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WidgetBuilder definitions={layout.customDefinitions} onChange={updateDefinitions} />
|
||||
</section>
|
||||
|
||||
<aside class="flex min-w-0 flex-col gap-6">
|
||||
<WidgetList
|
||||
widgets={layout.widgets}
|
||||
{selectedWidgetId}
|
||||
onSelect={(widgetId) => (selectedWidgetId = widgetId)}
|
||||
onChange={replaceWidgets}
|
||||
/>
|
||||
|
||||
{#if selectedWidget}
|
||||
<WidgetSettingsPanel
|
||||
widget={selectedWidget}
|
||||
definitions={layout.customDefinitions}
|
||||
layoutOpacity={layout.widgetOpacity}
|
||||
onChange={updateWidget}
|
||||
onRemove={() => removeWidget(selectedWidget.widgetId)}
|
||||
/>
|
||||
{:else}
|
||||
<p class="bg-surface-1 px-5 py-8 text-center text-sm text-ink-2">
|
||||
Select a widget to edit its settings.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-4 bg-surface-1 p-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="flex-1 text-sm font-semibold tracking-wide text-ink-2 uppercase">
|
||||
Device stats
|
||||
</h3>
|
||||
<span
|
||||
class="h-2 w-2 shrink-0"
|
||||
class:bg-success={kiosk?.status === "online"}
|
||||
class:bg-ink-2={kiosk?.status !== "online"}
|
||||
></span>
|
||||
</div>
|
||||
<KioskStats metrics={kiosk?.metrics ?? null} metricsAt={kiosk?.metricsAt ?? null} />
|
||||
</div>
|
||||
|
||||
<AppearancePanel
|
||||
{layout}
|
||||
onChange={(updated) => (layout = updated)}
|
||||
onUpload={handleWallpaperUpload}
|
||||
{isUploading}
|
||||
{uploadError}
|
||||
/>
|
||||
|
||||
<NightModePanel {layout} onChange={(updated) => (layout = updated)} />
|
||||
|
||||
{#if newEnrollmentToken}
|
||||
<InstallCommand enrollmentToken={newEnrollmentToken} kioskName={kiosk?.name ?? ""} />
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-3 bg-surface-1 p-5">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">Enrollment</h3>
|
||||
<p class="text-sm text-ink-2">
|
||||
Rotating issues a fresh token and a new install command for re-imaging this Pi.
|
||||
</p>
|
||||
<button
|
||||
onclick={regenerateEnrollment}
|
||||
class="bg-surface-2 px-4 py-2 text-sm text-ink-1 transition-colors hover:bg-surface-3"
|
||||
>
|
||||
Rotate enrollment token
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
@@ -0,0 +1,391 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { BrandLink, OrganizationBranding } from "@pistation/shared-types";
|
||||
import {
|
||||
createId,
|
||||
DEFAULT_THEME,
|
||||
resolveMediaUrl,
|
||||
withBrandingDefaults
|
||||
} from "@pistation/shared-types";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
import { ApiError, apiBaseUrl, getBranding, saveBranding, uploadLogo } from "$lib/api";
|
||||
import { applyBranding } from "$lib/branding.svelte";
|
||||
import { loadAdmin } from "$lib/session";
|
||||
|
||||
const admin = loadAdmin();
|
||||
|
||||
const LANDING_OPTIONS = [
|
||||
{
|
||||
value: "full" as const,
|
||||
label: "Full homepage",
|
||||
description: "Marketing sections, features and footer"
|
||||
},
|
||||
{
|
||||
value: "join" as const,
|
||||
label: "Join only",
|
||||
description: "Just the logo and the code entry"
|
||||
}
|
||||
];
|
||||
|
||||
const THEME_FIELDS = [
|
||||
{ key: "surface0" as const, label: "Page background" },
|
||||
{ key: "surface1" as const, label: "Panels" },
|
||||
{ key: "surface2" as const, label: "Inputs" },
|
||||
{ key: "surface3" as const, label: "Borders and hovers" },
|
||||
{ key: "ink0" as const, label: "Primary text" },
|
||||
{ key: "ink1" as const, label: "Secondary text" },
|
||||
{ key: "ink2" as const, label: "Muted text" }
|
||||
];
|
||||
|
||||
let branding = $state<OrganizationBranding | null>(null);
|
||||
let statusMessage = $state<string | null>(null);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let isSaving = $state(false);
|
||||
let isUploading = $state(false);
|
||||
let logoInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
const logoPreview = $derived(
|
||||
branding?.logoUrl ? resolveMediaUrl(apiBaseUrl, branding.logoUrl) : ""
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
branding = withBrandingDefaults(await getBranding());
|
||||
} catch {
|
||||
branding = withBrandingDefaults(null);
|
||||
errorMessage = "Could not load the current branding, showing defaults.";
|
||||
}
|
||||
}
|
||||
|
||||
function patch(update: Partial<OrganizationBranding>) {
|
||||
if (!branding) return;
|
||||
branding = { ...branding, ...update };
|
||||
}
|
||||
|
||||
async function handleLogo(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (!file || !admin) return;
|
||||
|
||||
isUploading = true;
|
||||
errorMessage = null;
|
||||
|
||||
try {
|
||||
const result = await uploadLogo(admin.accessToken, file);
|
||||
patch({ logoUrl: result.imageUrl });
|
||||
statusMessage = "Logo uploaded. Save to publish it.";
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof ApiError ? error.message : "Could not upload the logo.";
|
||||
} finally {
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addLink() {
|
||||
if (!branding) return;
|
||||
const link: BrandLink = { linkId: createId("link"), label: "", url: "" };
|
||||
patch({ links: [...branding.links, link] });
|
||||
}
|
||||
|
||||
function updateLink(linkId: string, update: Partial<BrandLink>) {
|
||||
if (!branding) return;
|
||||
patch({
|
||||
links: branding.links.map((link) => (link.linkId === linkId ? { ...link, ...update } : link))
|
||||
});
|
||||
}
|
||||
|
||||
function removeLink(linkId: string) {
|
||||
if (!branding) return;
|
||||
patch({ links: branding.links.filter((link) => link.linkId !== linkId) });
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!admin || !branding) return;
|
||||
|
||||
isSaving = true;
|
||||
statusMessage = null;
|
||||
errorMessage = null;
|
||||
|
||||
try {
|
||||
branding = withBrandingDefaults(await saveBranding(admin.accessToken, branding));
|
||||
// Push it into the shared store so the theme changes under you straight away.
|
||||
applyBranding(branding);
|
||||
statusMessage = "Branding saved and applied.";
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof ApiError ? error.message : "Could not save the branding.";
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Organisation · Admin · PiStation</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<main class="mx-auto w-full max-w-4xl px-6 py-12">
|
||||
<header class="mb-8 flex flex-wrap items-center gap-4">
|
||||
<a
|
||||
href="/admin"
|
||||
aria-label="Back to kiosks"
|
||||
class="flex h-10 w-10 items-center justify-center bg-surface-2 text-ink-1 hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:arrow-left-bold" width="18" />
|
||||
</a>
|
||||
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-semibold tracking-tight">Organisation</h1>
|
||||
<p class="text-sm text-ink-2">Branding shown on the public homepage</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={save}
|
||||
disabled={isSaving || !branding}
|
||||
class="flex items-center gap-2 bg-accent px-5 py-3 font-semibold text-white transition-colors hover:bg-accent-strong disabled:bg-surface-3 disabled:text-ink-2"
|
||||
>
|
||||
<Icon icon="ph:floppy-disk-bold" width="18" />
|
||||
Save
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if statusMessage}
|
||||
<p class="mb-6 flex items-center gap-2 bg-success/15 px-4 py-3 text-sm text-success">
|
||||
<Icon icon="ph:check-circle-bold" width="18" class="shrink-0" />
|
||||
{statusMessage}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="mb-6 flex items-center gap-2 bg-danger/15 px-4 py-3 text-sm text-danger">
|
||||
<Icon icon="ph:warning-circle-bold" width="18" class="shrink-0" />
|
||||
{errorMessage}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if branding}
|
||||
{@const current = branding}
|
||||
<div class="flex flex-col gap-6">
|
||||
<section class="flex flex-col gap-4 bg-surface-1 p-6">
|
||||
<h2 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">Identity</h2>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Organisation name</span>
|
||||
<input
|
||||
value={current.name}
|
||||
oninput={(event) => patch({ name: (event.target as HTMLInputElement).value })}
|
||||
class="w-full bg-surface-2 px-4 py-3 text-ink-0"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if logoPreview}
|
||||
<img src={logoPreview} alt="Logo" class="h-14 w-14 object-contain" />
|
||||
{:else}
|
||||
<div class="flex h-14 w-14 items-center justify-center bg-surface-2 text-ink-2">
|
||||
<Icon icon="ph:image-bold" width="22" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
bind:this={logoInput}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
onchange={handleLogo}
|
||||
class="hidden"
|
||||
/>
|
||||
|
||||
<button
|
||||
onclick={() => logoInput?.click()}
|
||||
disabled={isUploading}
|
||||
class="flex items-center gap-2 bg-surface-2 px-4 py-2 text-sm text-ink-1 hover:bg-surface-3 disabled:text-ink-2"
|
||||
>
|
||||
{#if isUploading}
|
||||
<Icon icon="ph:circle-notch-bold" width="16" class="animate-spin" />
|
||||
Uploading
|
||||
{:else}
|
||||
<Icon icon="ph:upload-simple-bold" width="16" />
|
||||
Upload logo
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if current.logoUrl}
|
||||
<button
|
||||
onclick={() => patch({ logoUrl: "" })}
|
||||
class="px-3 py-2 text-sm text-danger hover:bg-surface-2"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<label class="ml-auto flex items-center gap-3 text-sm text-ink-1">
|
||||
Accent colour
|
||||
<input
|
||||
type="color"
|
||||
value={current.accentColor}
|
||||
oninput={(event) =>
|
||||
patch({ accentColor: (event.target as HTMLInputElement).value })}
|
||||
class="h-10 w-16 bg-surface-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-4 bg-surface-1 p-6">
|
||||
<h2 class="text-sm font-semibold tracking-wide text-ink-2 uppercase">Homepage</h2>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xs text-ink-2">What visitors see at the root address</span>
|
||||
<div class="flex gap-px">
|
||||
{#each LANDING_OPTIONS as option}
|
||||
<button
|
||||
onclick={() => patch({ landingMode: option.value })}
|
||||
class="flex-1 px-4 py-3 text-left transition-colors"
|
||||
class:bg-accent={current.landingMode === option.value}
|
||||
class:text-white={current.landingMode === option.value}
|
||||
class:bg-surface-2={current.landingMode !== option.value}
|
||||
class:text-ink-1={current.landingMode !== option.value}
|
||||
>
|
||||
<span class="block text-sm font-medium">{option.label}</span>
|
||||
<span class="block text-xs opacity-70">{option.description}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Headline</span>
|
||||
<input
|
||||
value={current.headline}
|
||||
oninput={(event) => patch({ headline: (event.target as HTMLInputElement).value })}
|
||||
class="w-full bg-surface-2 px-4 py-3 text-ink-0"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Description</span>
|
||||
<textarea
|
||||
value={current.description}
|
||||
rows="3"
|
||||
oninput={(event) =>
|
||||
patch({ description: (event.target as HTMLTextAreaElement).value })}
|
||||
class="w-full bg-surface-2 px-4 py-3 text-ink-0"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Label above the join code</span>
|
||||
<input
|
||||
value={current.joinLabel}
|
||||
oninput={(event) => patch({ joinLabel: (event.target as HTMLInputElement).value })}
|
||||
class="w-full bg-surface-2 px-4 py-3 text-ink-0"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-4 bg-surface-1 p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="flex-1 text-sm font-semibold tracking-wide text-ink-2 uppercase">Theme</h2>
|
||||
<button
|
||||
onclick={() => patch({ theme: { ...DEFAULT_THEME } })}
|
||||
class="bg-surface-2 px-3 py-2 text-sm text-ink-1 hover:bg-surface-3"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-ink-2">
|
||||
These colours apply across the whole website, including the join screen and the admin
|
||||
panel.
|
||||
</p>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
{#each THEME_FIELDS as field}
|
||||
<label class="flex items-center justify-between gap-3 bg-surface-2 px-4 py-3">
|
||||
<span class="text-sm text-ink-1">{field.label}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={current.theme[field.key]}
|
||||
oninput={(event) =>
|
||||
patch({
|
||||
theme: {
|
||||
...current.theme,
|
||||
[field.key]: (event.target as HTMLInputElement).value
|
||||
}
|
||||
})}
|
||||
class="h-9 w-16 bg-surface-1"
|
||||
/>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-4 bg-surface-1 p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="flex-1 text-sm font-semibold tracking-wide text-ink-2 uppercase">Footer</h2>
|
||||
<button
|
||||
onclick={addLink}
|
||||
class="flex items-center gap-2 bg-surface-2 px-3 py-2 text-sm text-ink-1 hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:plus-bold" width="16" />
|
||||
Add link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-xs text-ink-2">Footer note</span>
|
||||
<input
|
||||
value={current.footerNote}
|
||||
placeholder="Internal use only, contact IT for help"
|
||||
oninput={(event) => patch({ footerNote: (event.target as HTMLInputElement).value })}
|
||||
class="w-full bg-surface-2 px-4 py-3 text-ink-0 placeholder:text-ink-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#each current.links as link (link.linkId)}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={link.label}
|
||||
placeholder="Label"
|
||||
oninput={(event) =>
|
||||
updateLink(link.linkId, { label: (event.target as HTMLInputElement).value })}
|
||||
class="min-w-32 flex-1 bg-surface-2 px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={link.url}
|
||||
placeholder="https://"
|
||||
oninput={(event) =>
|
||||
updateLink(link.linkId, { url: (event.target as HTMLInputElement).value })}
|
||||
class="min-w-48 flex-2 bg-surface-2 px-3 py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
onclick={() => removeLink(link.linkId)}
|
||||
aria-label="Remove link"
|
||||
class="flex h-9 w-9 items-center justify-center bg-surface-2 text-danger hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:trash-bold" width="16" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<label class="flex items-center gap-3 text-sm text-ink-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={current.showSourceLink}
|
||||
onchange={(event) =>
|
||||
patch({ showSourceLink: (event.target as HTMLInputElement).checked })}
|
||||
/>
|
||||
Show the source link in the footer
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
@@ -0,0 +1,298 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
import { activeAuthors } from "@pistation/client-core/annotations";
|
||||
import { AnnotationOverlay, Logo, TrackVideo, WhiteboardCanvas } from "@pistation/ui";
|
||||
|
||||
import AnnotationToolbar from "$lib/components/AnnotationToolbar.svelte";
|
||||
import ConnectionBadge from "$lib/components/ConnectionBadge.svelte";
|
||||
import ParticipantMenu from "$lib/components/ParticipantMenu.svelte";
|
||||
import { RoomController } from "$lib/room.svelte";
|
||||
import { clearSession, loadSession } from "$lib/session";
|
||||
|
||||
let controller = $state<RoomController | null>(null);
|
||||
let isAnnotating = $state(false);
|
||||
|
||||
const participantLabels = $derived(
|
||||
new Map((controller?.participants ?? []).map((p) => [p.participantId, p.displayName]))
|
||||
);
|
||||
|
||||
const screenBlocked = $derived(
|
||||
Boolean(controller && !controller.isSharing && !controller.canStartScreenShare)
|
||||
);
|
||||
const cameraBlocked = $derived(
|
||||
Boolean(controller && !controller.isCameraOn && !controller.canStartCamera)
|
||||
);
|
||||
|
||||
const cameraTiles = $derived.by(() => {
|
||||
if (!controller) return [];
|
||||
|
||||
const remote = controller.cameraFeeds.map((feed) => ({
|
||||
key: feed.participantId,
|
||||
label: feed.displayName,
|
||||
track: feed.track
|
||||
}));
|
||||
|
||||
if (!controller.localCameraTrack) return remote;
|
||||
|
||||
return [
|
||||
{ key: "self", label: "You", track: controller.localCameraTrack },
|
||||
...remote
|
||||
];
|
||||
});
|
||||
|
||||
const drawingNow = $derived.by(() => {
|
||||
if (!controller) return [];
|
||||
return activeAuthors(controller.annotations)
|
||||
.filter((authorId) => authorId !== controller?.participantId)
|
||||
.map((authorId) => participantLabels.get(authorId) ?? "Someone");
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const session = loadSession();
|
||||
if (!session) {
|
||||
void goto("/");
|
||||
return;
|
||||
}
|
||||
|
||||
const instance = new RoomController(session);
|
||||
controller = instance;
|
||||
void instance.connect();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
void controller?.disconnect();
|
||||
});
|
||||
|
||||
async function leave() {
|
||||
await controller?.disconnect();
|
||||
clearSession();
|
||||
await goto("/");
|
||||
}
|
||||
|
||||
function toggleShare() {
|
||||
if (!controller) return;
|
||||
if (controller.isSharing) {
|
||||
void controller.stopScreenShare();
|
||||
} else {
|
||||
void controller.startScreenShare();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCamera() {
|
||||
if (!controller) return;
|
||||
if (controller.isCameraOn) {
|
||||
void controller.stopCamera();
|
||||
} else {
|
||||
void controller.startCamera();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleWhiteboard() {
|
||||
if (!controller) return;
|
||||
controller.setMode(controller.mode === "whiteboard" ? "idle" : "whiteboard");
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{controller ? `${controller.kioskName} · PiStation` : "Room · PiStation"}</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
{#if controller}
|
||||
{@const current = controller}
|
||||
<div class="flex h-screen flex-col">
|
||||
<header class="flex shrink-0 flex-wrap items-center gap-3 bg-surface-1 px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<Logo size={32} />
|
||||
<div>
|
||||
<p class="text-sm font-semibold">{current.kioskName}</p>
|
||||
<p class="text-xs text-ink-2">
|
||||
{current.role === "presenter" ? "Presenting" : "Viewing"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConnectionBadge status={current.status} />
|
||||
|
||||
<ParticipantMenu participants={current.participants} />
|
||||
|
||||
<div class="ml-auto flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onclick={toggleShare}
|
||||
disabled={screenBlocked}
|
||||
title={screenBlocked ? (current.blockedReason ?? "Turn your camera off first") : undefined}
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors disabled:bg-surface-2 disabled:text-ink-2"
|
||||
class:bg-danger={current.isSharing}
|
||||
class:text-white={current.isSharing}
|
||||
class:bg-accent={!current.isSharing && !screenBlocked}
|
||||
class:hover:bg-accent-strong={!current.isSharing && !screenBlocked}
|
||||
>
|
||||
<Icon icon={current.isSharing ? "ph:stop-circle-bold" : "ph:broadcast-bold"} width="18" />
|
||||
{current.isSharing ? "Stop sharing" : "Share screen"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={toggleCamera}
|
||||
disabled={cameraBlocked}
|
||||
title={cameraBlocked
|
||||
? (current.blockedReason ?? "Stop sharing your screen first")
|
||||
: undefined}
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors disabled:bg-surface-2 disabled:text-ink-2"
|
||||
class:bg-danger={current.isCameraOn}
|
||||
class:text-white={current.isCameraOn}
|
||||
class:bg-surface-2={!current.isCameraOn && cameraBlocked}
|
||||
class:bg-accent={!current.isCameraOn && !cameraBlocked}
|
||||
class:hover:bg-accent-strong={!current.isCameraOn && !cameraBlocked}
|
||||
>
|
||||
<Icon
|
||||
icon={current.isCameraOn ? "ph:video-camera-slash-bold" : "ph:video-camera-bold"}
|
||||
width="18"
|
||||
/>
|
||||
{current.isCameraOn ? "Stop camera" : "Camera"}
|
||||
</button>
|
||||
|
||||
{#if current.isCameraOn}
|
||||
<button
|
||||
onclick={() => current.flipCamera()}
|
||||
aria-label="Switch camera"
|
||||
title="Switch camera"
|
||||
class="flex h-9 w-9 items-center justify-center bg-surface-2 text-ink-1 transition-colors hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:arrows-clockwise-bold" width="16" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={toggleWhiteboard}
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors"
|
||||
class:bg-accent={current.mode === "whiteboard"}
|
||||
class:text-white={current.mode === "whiteboard"}
|
||||
class:bg-surface-2={current.mode !== "whiteboard"}
|
||||
class:text-ink-1={current.mode !== "whiteboard"}
|
||||
class:hover:bg-surface-3={current.mode !== "whiteboard"}
|
||||
>
|
||||
<Icon icon="ph:scribble-loop-bold" width="18" />
|
||||
Whiteboard
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => (isAnnotating = !isAnnotating)}
|
||||
disabled={current.mode === "whiteboard"}
|
||||
class="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors disabled:bg-surface-2 disabled:text-ink-2"
|
||||
class:bg-accent={isAnnotating && current.mode !== "whiteboard"}
|
||||
class:text-white={isAnnotating && current.mode !== "whiteboard"}
|
||||
class:bg-surface-2={!isAnnotating}
|
||||
class:text-ink-1={!isAnnotating}
|
||||
>
|
||||
<Icon icon="ph:pencil-simple-bold" width="18" />
|
||||
Annotate
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={leave}
|
||||
class="flex items-center gap-2 bg-surface-2 px-4 py-2 text-sm font-medium text-danger transition-colors hover:bg-surface-3"
|
||||
>
|
||||
<Icon icon="ph:sign-out-bold" width="18" />
|
||||
Leave
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if current.errorMessage}
|
||||
<p class="flex items-center gap-2 bg-danger/15 px-4 py-2 text-sm text-danger">
|
||||
<Icon icon="ph:warning-circle-bold" width="16" />
|
||||
{current.errorMessage}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<main class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="relative min-h-0 flex-1 bg-surface-0">
|
||||
{#if current.mode === "whiteboard"}
|
||||
<WhiteboardCanvas
|
||||
elements={current.whiteboardElements}
|
||||
onLocalChange={(changed) => current.pushWhiteboardElements(changed)}
|
||||
/>
|
||||
{:else if current.localScreenTrack}
|
||||
<TrackVideo track={current.localScreenTrack} />
|
||||
<span
|
||||
class="absolute top-4 left-4 flex items-center gap-2 bg-surface-0/80 px-3 py-1.5 text-sm text-ink-1"
|
||||
>
|
||||
<Icon icon="ph:broadcast-bold" width="14" class="text-accent" />
|
||||
You are sharing this screen
|
||||
</span>
|
||||
<AnnotationOverlay
|
||||
annotations={current.annotations}
|
||||
labels={participantLabels}
|
||||
interactive={isAnnotating}
|
||||
onStrokeStart={(point) => current.beginStroke(point)}
|
||||
onStrokeExtend={(point) => current.extendStroke(point)}
|
||||
onStrokeEnd={() => current.endStroke()}
|
||||
onPointerMove={(point) => current.movePointer(point)}
|
||||
/>
|
||||
{:else if current.screenTrack}
|
||||
<TrackVideo track={current.screenTrack} />
|
||||
<AnnotationOverlay
|
||||
annotations={current.annotations}
|
||||
labels={participantLabels}
|
||||
interactive={isAnnotating}
|
||||
onStrokeStart={(point) => current.beginStroke(point)}
|
||||
onStrokeExtend={(point) => current.extendStroke(point)}
|
||||
onStrokeEnd={() => current.endStroke()}
|
||||
onPointerMove={(point) => current.movePointer(point)}
|
||||
/>
|
||||
{:else if cameraTiles.length > 0}
|
||||
<div
|
||||
class="grid h-full gap-px p-px"
|
||||
class:grid-cols-1={cameraTiles.length === 1}
|
||||
class:grid-cols-2={cameraTiles.length > 1}
|
||||
>
|
||||
{#each cameraTiles as tile (tile.key)}
|
||||
<div class="relative min-h-0 bg-black">
|
||||
<TrackVideo track={tile.track} />
|
||||
<span
|
||||
class="absolute bottom-3 left-3 flex items-center gap-2 bg-surface-0/80 px-3 py-1.5 text-sm text-ink-1"
|
||||
>
|
||||
<Icon icon="ph:video-camera-bold" width="14" class="text-accent" />
|
||||
{tile.label}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full flex-col items-center justify-center gap-4 text-center">
|
||||
<Icon icon="ph:monitor-bold" width="48" class="text-ink-2" />
|
||||
<div>
|
||||
<p class="text-lg font-medium">Nothing on screen yet</p>
|
||||
<p class="text-sm text-ink-2">
|
||||
Share your screen, turn on a camera, or open the whiteboard to begin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if drawingNow.length > 0}
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-20 left-4 flex items-center gap-2 bg-surface-0/85 px-3 py-2 text-sm text-ink-1"
|
||||
>
|
||||
<Icon icon="ph:pencil-simple-bold" width="14" class="text-accent" />
|
||||
{drawingNow.join(", ")}
|
||||
{drawingNow.length === 1 ? "is" : "are"} drawing
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isAnnotating && current.mode !== "whiteboard"}
|
||||
<AnnotationToolbar controller={current} />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-screen items-center justify-center">
|
||||
<Icon icon="ph:circle-notch-bold" width="32" class="animate-spin text-ink-2" />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,24 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 32 32"
|
||||
shape-rendering="crispEdges"
|
||||
role="img"
|
||||
aria-label="PiStation"
|
||||
>
|
||||
<title>PiStation</title>
|
||||
|
||||
<rect x="2" y="4" width="28" height="19" fill="#4f7cff" />
|
||||
<rect x="4" y="6" width="24" height="14" fill="#14181d" />
|
||||
|
||||
<rect x="8" y="8" width="4" height="4" fill="#f4f6f8" />
|
||||
<rect x="14" y="8" width="4" height="4" fill="#f4f6f8" />
|
||||
<rect x="20" y="8" width="4" height="4" fill="#f4f6f8" />
|
||||
<rect x="8" y="14" width="4" height="4" fill="#f4f6f8" />
|
||||
<rect x="14" y="14" width="4" height="4" fill="#f4f6f8" />
|
||||
<rect x="20" y="14" width="4" height="4" fill="#4f7cff" />
|
||||
|
||||
<rect x="14" y="23" width="4" height="3" fill="#4f7cff" />
|
||||
<rect x="9" y="26" width="14" height="2" fill="#4f7cff" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 804 B |
@@ -0,0 +1,60 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1200"
|
||||
height="630"
|
||||
viewBox="0 0 1200 630"
|
||||
role="img"
|
||||
aria-label="PiStation"
|
||||
>
|
||||
<title>PiStation</title>
|
||||
|
||||
<rect width="1200" height="630" fill="#0b0d10" />
|
||||
<rect x="0" y="0" width="1200" height="6" fill="#4f7cff" />
|
||||
|
||||
<g transform="translate(96 168) scale(5.5)" shape-rendering="crispEdges">
|
||||
<rect x="2" y="4" width="28" height="19" fill="#4f7cff" />
|
||||
<rect x="4" y="6" width="24" height="14" fill="#14181d" />
|
||||
<rect x="8" y="8" width="4" height="4" fill="#f4f6f8" />
|
||||
<rect x="14" y="8" width="4" height="4" fill="#f4f6f8" />
|
||||
<rect x="20" y="8" width="4" height="4" fill="#f4f6f8" />
|
||||
<rect x="8" y="14" width="4" height="4" fill="#f4f6f8" />
|
||||
<rect x="14" y="14" width="4" height="4" fill="#f4f6f8" />
|
||||
<rect x="20" y="14" width="4" height="4" fill="#4f7cff" />
|
||||
<rect x="14" y="23" width="4" height="3" fill="#4f7cff" />
|
||||
<rect x="9" y="26" width="14" height="2" fill="#4f7cff" />
|
||||
</g>
|
||||
|
||||
<text
|
||||
x="356"
|
||||
y="268"
|
||||
font-family="Inter, system-ui, -apple-system, 'Segoe UI', sans-serif"
|
||||
font-size="88"
|
||||
font-weight="600"
|
||||
letter-spacing="-2"
|
||||
fill="#f4f6f8"
|
||||
>
|
||||
PiStation
|
||||
</text>
|
||||
|
||||
<text
|
||||
x="356"
|
||||
y="336"
|
||||
font-family="Inter, system-ui, -apple-system, 'Segoe UI', sans-serif"
|
||||
font-size="38"
|
||||
font-weight="400"
|
||||
fill="#a8b3c0"
|
||||
>
|
||||
Any screen becomes a shared screen.
|
||||
</text>
|
||||
|
||||
<text
|
||||
x="356"
|
||||
y="402"
|
||||
font-family="Inter, system-ui, -apple-system, 'Segoe UI', sans-serif"
|
||||
font-size="28"
|
||||
font-weight="400"
|
||||
fill="#6b7885"
|
||||
>
|
||||
Present, annotate and whiteboard together. Self hosted.
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,12 @@
|
||||
import adapter from "@sveltejs/adapter-node";
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
// The workspace root, so the dev server may serve the shared packages.
|
||||
fs: {
|
||||
allow: ["../.."]
|
||||
}
|
||||
},
|
||||
ssr: {
|
||||
noExternal: ["@pistation/shared-types", "@pistation/client-core", "@pistation/ui"]
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ["react", "react-dom", "react-dom/client"]
|
||||
},
|
||||
define: {
|
||||
"process.env.IS_PREACT": JSON.stringify("false")
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user