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