"use client"; import { useEffect, useMemo, useState } from "react"; import { buildWidgetQuery, patchWidgetConfig, WIDGET_DEFAULTS, type WidgetConfig, } from "../../lib/config"; import { WidgetMenu } from "../../components/WidgetMenu"; import type { PresetsPayload } from "../../components/PresetsRow"; import { loadWidgetConfig, saveWidgetConfig } from "../widgetConfigStorage"; // The two wide presets exist because columns need width to be worth having — // the original three are all narrower than two readable columns. `Viewport` is // the one that isn't a pinned-window size: a fill row only shows what it does // when the frame has a height to leave over, so arranging one at 200px tall // would be arranging it blind. const SIZE_PRESETS: { label: string; width: number; height: number; // CSS override for the iframe box, for a size that isn't a fixed pixel pair. css?: { width: string; height: string }; }[] = [ { label: "Small", width: 320, height: 200 }, { label: "Medium", width: 380, height: 320 }, { label: "Tall", width: 360, height: 520 }, { label: "Wide", width: 640, height: 260 }, { label: "Panel", width: 720, height: 420 }, { label: "Viewport", width: 720, height: 640, css: { width: "100%", height: "80vh" }, }, ]; // How long the preview waits before re-navigating. Changing an iframe's src is a // full navigation, and a drag produces a change per pointer move — so the link // field updates instantly and only the preview is debounced. const PREVIEW_DEBOUNCE_MS = 250; export function WidgetBuilder({ initialPresets, }: { initialPresets?: PresetsPayload; }) { const [config, setConfig] = useState(WIDGET_DEFAULTS); const [size, setSize] = useState(SIZE_PRESETS[0]); const [origin, setOrigin] = useState(""); const [copied, setCopied] = useState(false); // Gate persistence until the stored config has loaded, so the initial // defaults render (SSR-stable) isn't written over the saved value. const [loaded, setLoaded] = useState(false); // Resolve the absolute origin on the client to avoid an SSR/CSR mismatch. useEffect(() => { setOrigin(window.location.origin); }, []); // Restore the last-used builder config from localStorage on mount (client // only — defaults render first on both server and client to avoid a hydration // mismatch, then this overlays the saved form state). useEffect(() => { setConfig(loadWidgetConfig()); setLoaded(true); }, []); // Persist the form on every change once the saved value has been restored. useEffect(() => { if (loaded) saveWidgetConfig(config); }, [config, loaded]); const query = useMemo(() => buildWidgetQuery(config), [config]); const relativeUrl = query ? `/widget?${query}` : "/widget"; const fullUrl = origin ? `${origin}${relativeUrl}` : relativeUrl; const [previewUrl, setPreviewUrl] = useState("/widget"); useEffect(() => { const t = setTimeout(() => setPreviewUrl(relativeUrl), PREVIEW_DEBOUNCE_MS); return () => clearTimeout(t); }, [relativeUrl]); function patch(p: Partial) { setConfig((c) => patchWidgetConfig(c, p)); setCopied(false); } async function copy() { try { await navigator.clipboard.writeText(fullUrl); setCopied(true); } catch { // clipboard blocked — leave the field for manual copy } } function openPopup() { // `popup` asks the browser for a minimal, chromeless window (no tab strip, // toolbar, or menu) — ideal for a pinned always-on-top monitor. The named // target reuses the same window on repeat clicks instead of stacking. window.open( relativeUrl, "monitor-widget", `popup=yes,width=${size.width},height=${size.height}`, ); } return (
Widget link
e.currentTarget.select()} className="min-w-0 flex-1 rounded border border-border bg-muted px-2 py-1 font-mono text-xs" />

The link carries the whole floorplan, so the arrangement above is what a pinned window or an embedded{" "} <iframe> will show. Sections in the Off tray are simply absent from it — the link only names what is on, which is why an old link still works and still renders in the original order. A preset is just this link under a name, so loading one anywhere gives the same widget. The{" "} settings gear reopens these same controls inside the widget; turn it off for a locked-down link.{" "} Open popup launches a chromeless window at the selected preview size.

Preview
{SIZE_PRESETS.map((p) => ( ))}