feat: control panel UI components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
40
src/components/ControlPanel.tsx
Normal file
40
src/components/ControlPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/components/OpacitySlider.tsx
Normal file
28
src/components/OpacitySlider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/OpenOverlayButton.tsx
Normal file
56
src/components/OpenOverlayButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/components/StatusBar.tsx
Normal file
33
src/components/StatusBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/components/ToggleRow.tsx
Normal file
47
src/components/ToggleRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/UrlField.tsx
Normal file
37
src/components/UrlField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user