From 4dc5604566cca2a0e57f3562620570d7413a1d91 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Fri, 8 May 2026 18:55:49 -0400 Subject: [PATCH] feat: control panel UI components Co-Authored-By: Claude Sonnet 4.6 --- src/components/ControlPanel.tsx | 40 ++++++++++++++++++++ src/components/OpacitySlider.tsx | 28 ++++++++++++++ src/components/OpenOverlayButton.tsx | 56 ++++++++++++++++++++++++++++ src/components/StatusBar.tsx | 33 ++++++++++++++++ src/components/ToggleRow.tsx | 47 +++++++++++++++++++++++ src/components/UrlField.tsx | 37 ++++++++++++++++++ 6 files changed, 241 insertions(+) create mode 100644 src/components/ControlPanel.tsx create mode 100644 src/components/OpacitySlider.tsx create mode 100644 src/components/OpenOverlayButton.tsx create mode 100644 src/components/StatusBar.tsx create mode 100644 src/components/ToggleRow.tsx create mode 100644 src/components/UrlField.tsx diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx new file mode 100644 index 0000000..70a2780 --- /dev/null +++ b/src/components/ControlPanel.tsx @@ -0,0 +1,40 @@ +import { Layers, MousePointerClick } from "lucide-react"; +import { UrlField } from "./UrlField"; +import { OpacitySlider } from "./OpacitySlider"; +import { ToggleRow } from "./ToggleRow"; +import { OpenOverlayButton } from "./OpenOverlayButton"; +import { useOverlayStore } from "../lib/store"; + +export function ControlPanel(): React.JSX.Element { + const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop); + const setAlwaysOnTop = useOverlayStore((s) => s.setAlwaysOnTop); + const clickThrough = useOverlayStore((s) => s.clickThrough); + const setClickThrough = useOverlayStore((s) => s.setClickThrough); + + return ( +
+
+ Browserlay +
+ + +
+ } + checked={alwaysOnTop} + onChange={setAlwaysOnTop} + /> + } + checked={clickThrough} + onChange={setClickThrough} + /> +
+ +
+ ); +} diff --git a/src/components/OpacitySlider.tsx b/src/components/OpacitySlider.tsx new file mode 100644 index 0000000..8f9e79d --- /dev/null +++ b/src/components/OpacitySlider.tsx @@ -0,0 +1,28 @@ +import { useOverlayStore } from "../lib/store"; + +export function OpacitySlider(): React.JSX.Element { + const opacity = useOverlayStore((s) => s.opacity); + const setOpacity = useOverlayStore((s) => s.setOpacity); + const pct = Math.round(opacity * 100); + + return ( +
+
+ + {pct}% +
+ setOpacity(Number(e.currentTarget.value))} + className="w-full accent-[var(--color-accent)]" + /> +
+ ); +} diff --git a/src/components/OpenOverlayButton.tsx b/src/components/OpenOverlayButton.tsx new file mode 100644 index 0000000..8d91a35 --- /dev/null +++ b/src/components/OpenOverlayButton.tsx @@ -0,0 +1,56 @@ +import { Play, Square } from "lucide-react"; +import { useOverlayStore } from "../lib/store"; +import { closeOverlay, openOverlay } from "../lib/overlay"; +import { validateUrl } from "../lib/url"; + +export function OpenOverlayButton(): React.JSX.Element { + const url = useOverlayStore((s) => s.url); + const isOpen = useOverlayStore((s) => s.isOpen); + const opacity = useOverlayStore((s) => s.opacity); + const alwaysOnTop = useOverlayStore((s) => s.alwaysOnTop); + const clickThrough = useOverlayStore((s) => s.clickThrough); + const setIsOpen = useOverlayStore((s) => s.setIsOpen); + + const validation = validateUrl(url); + const disabled = !isOpen && !validation.ok; + + async function handleClick(): Promise { + if (isOpen) { + await closeOverlay(); + // The destroyed listener registered in openOverlay will also flip + // isOpen, but call it here too so the UI reflects intent immediately. + setIsOpen(false); + return; + } + if (!validation.ok) return; + await openOverlay({ + url: validation.url, + opacity, + alwaysOnTop, + clickThrough, + onDestroyed: () => { + useOverlayStore.getState().setIsOpen(false); + }, + }); + setIsOpen(true); + } + + return ( + + ); +} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx new file mode 100644 index 0000000..8308e5f --- /dev/null +++ b/src/components/StatusBar.tsx @@ -0,0 +1,33 @@ +import { AlertTriangle, Keyboard } from "lucide-react"; +import { useOverlayStore } from "../lib/store"; + +export type StatusBarProps = { transient: string | null }; + +export function StatusBar({ transient }: StatusBarProps): React.JSX.Element { + const hotkeyError = useOverlayStore((s) => s.hotkeyError); + const isOpen = useOverlayStore((s) => s.isOpen); + + return ( +
+ + + + {hotkeyError ? ( + + + ) : ( + + + )} + {transient && {transient}} + +
+ ); +} diff --git a/src/components/ToggleRow.tsx b/src/components/ToggleRow.tsx new file mode 100644 index 0000000..0b570a5 --- /dev/null +++ b/src/components/ToggleRow.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; + +export type ToggleRowProps = { + label: string; + description?: string; + icon?: ReactNode; + checked: boolean; + onChange: (v: boolean) => void; +}; + +export function ToggleRow({ label, description, icon, checked, onChange }: ToggleRowProps): React.JSX.Element { + return ( + + ); +} diff --git a/src/components/UrlField.tsx b/src/components/UrlField.tsx new file mode 100644 index 0000000..31d4e4e --- /dev/null +++ b/src/components/UrlField.tsx @@ -0,0 +1,37 @@ +import { Globe } from "lucide-react"; +import { useOverlayStore } from "../lib/store"; + +export function UrlField(): React.JSX.Element { + const url = useOverlayStore((s) => s.url); + const urlError = useOverlayStore((s) => s.urlError); + const setUrl = useOverlayStore((s) => s.setUrl); + + return ( +
+ +
+
+ {urlError && ( +

{urlError}

+ )} +
+ ); +}