feat: control panel UI components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-05-08 18:55:49 -04:00
parent 8359e4df45
commit 4dc5604566
6 changed files with 241 additions and 0 deletions

View File

@@ -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 (
<section className="flex flex-1 flex-col gap-4 p-4">
<header className="flex items-center gap-2">
<span className="text-base font-semibold tracking-tight text-[var(--color-text)]">Browserlay</span>
</header>
<UrlField />
<OpacitySlider />
<div className="flex flex-col gap-2">
<ToggleRow
label="Always on top"
description="Keep the overlay above other windows"
icon={<Layers size={14} />}
checked={alwaysOnTop}
onChange={setAlwaysOnTop}
/>
<ToggleRow
label="Click-through"
description="Mouse passes through to apps below"
icon={<MousePointerClick size={14} />}
checked={clickThrough}
onChange={setClickThrough}
/>
</div>
<OpenOverlayButton />
</section>
);
}

View File

@@ -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 (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label htmlFor="opacity" className="text-xs uppercase tracking-wide text-[var(--color-muted)]">
Opacity
</label>
<span className="text-xs tabular-nums text-[var(--color-muted-strong)]">{pct}%</span>
</div>
<input
id="opacity"
type="range"
min={0.1}
max={1.0}
step={0.01}
value={opacity}
onChange={(e) => setOpacity(Number(e.currentTarget.value))}
className="w-full accent-[var(--color-accent)]"
/>
</div>
);
}

View File

@@ -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<void> {
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 (
<button
type="button"
onClick={() => {
void handleClick();
}}
disabled={disabled}
className={`mt-1 flex items-center justify-center gap-2 rounded-[var(--radius-md)] px-4 py-2.5 text-sm font-medium transition-colors
${disabled
? "bg-[var(--color-surface-elevated)] text-[var(--color-muted)] cursor-not-allowed"
: isOpen
? "bg-[var(--color-surface-elevated)] text-[var(--color-text)] hover:bg-[var(--color-border-strong)]"
: "bg-[var(--color-accent)] text-black hover:bg-[var(--color-accent-hover)]"}`}
>
{isOpen ? <Square size={14} /> : <Play size={14} />}
{isOpen ? "Close overlay" : "Open overlay"}
</button>
);
}

View File

@@ -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 (
<footer className="border-t border-[var(--color-border)] px-4 py-2 text-xs flex items-center justify-between text-[var(--color-muted)]">
<span className="flex items-center gap-2">
<span
className={`h-1.5 w-1.5 rounded-full ${isOpen ? "bg-[var(--color-accent)]" : "bg-[var(--color-border-strong)]"}`}
aria-hidden="true"
/>
{isOpen ? "Overlay open" : "Overlay closed"}
</span>
<span className="flex items-center gap-3">
{hotkeyError ? (
<span className="flex items-center gap-1 text-[var(--color-warning)]">
<AlertTriangle size={12} aria-hidden="true" /> {hotkeyError}
</span>
) : (
<span className="flex items-center gap-1">
<Keyboard size={12} aria-hidden="true" /> Ctrl+Alt+Space toggles click-through
</span>
)}
{transient && <span className="text-[var(--color-muted-strong)]">{transient}</span>}
</span>
</footer>
);
}

View File

@@ -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 (
<label className="flex items-center justify-between gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2.5 cursor-pointer hover:border-[var(--color-border-strong)] transition-colors">
<span className="flex items-center gap-2.5 min-w-0">
{icon && <span className="text-[var(--color-muted-strong)]" aria-hidden="true">{icon}</span>}
<span className="flex flex-col min-w-0">
<span className="text-sm text-[var(--color-text)]">{label}</span>
{description && (
<span className="text-xs text-[var(--color-muted)] truncate">{description}</span>
)}
</span>
</span>
<span
role="switch"
aria-checked={checked}
aria-label={label}
onClick={(e) => {
e.preventDefault();
onChange(!checked);
}}
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors
${checked ? "bg-[var(--color-accent)]" : "bg-[var(--color-border-strong)]"}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all
${checked ? "left-[calc(100%-1.125rem)]" : "left-0.5"}`}
/>
</span>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.currentTarget.checked)}
className="sr-only"
/>
</label>
);
}

View File

@@ -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 (
<div className="flex flex-col gap-1.5">
<label htmlFor="overlay-url" className="text-xs uppercase tracking-wide text-[var(--color-muted)]">
URL
</label>
<div
className={`flex items-center gap-2 rounded-[var(--radius-md)] border px-3 py-2 transition-colors
bg-[var(--color-surface)]
${urlError ? "border-[var(--color-danger)]" : "border-[var(--color-border)] focus-within:border-[var(--color-border-strong)]"}`}
>
<Globe size={16} className="text-[var(--color-muted)]" aria-hidden="true" />
<input
id="overlay-url"
type="text"
inputMode="url"
autoComplete="off"
spellCheck={false}
placeholder="twitch.tv/somechannel"
value={url}
onChange={(e) => setUrl(e.currentTarget.value)}
className="w-full bg-transparent outline-none text-[var(--color-text)] placeholder:text-[var(--color-muted)]"
/>
</div>
{urlError && (
<p className="text-xs text-[var(--color-danger)]">{urlError}</p>
)}
</div>
);
}