Port the boolab FX controls: localStorage-backed Density, Speed, and Opacity sliders in the theme settings, shown when a canvas-background theme is active (Density is matrix-rain-only). Speed is a multiplier over each field's native base; values feed MatrixRain / NeonField live.
236 lines
8.3 KiB
TypeScript
236 lines
8.3 KiB
TypeScript
import { useState } from 'react';
|
|
import { Check } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { Card } from '@/components/ui/card';
|
|
import { Label } from '@/components/ui/label';
|
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
|
import { THEMES, setTheme, useTheme, type ThemeId, type ThemeMode } from '@/lib/theme';
|
|
import {
|
|
useAnimBg,
|
|
setAnimBg,
|
|
useAnimParams,
|
|
setAnimDensity,
|
|
setAnimSpeed,
|
|
setAnimOpacity,
|
|
ANIM_RANGES,
|
|
} from '@/lib/anim';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// v1.9: lifted out of pages/Settings.tsx so the SettingsPane Theme tab and
|
|
// the standalone /settings route render the same picker. Theme is global —
|
|
// not per-project, not per-session — so no contextual props are needed.
|
|
|
|
const MODES: { value: ThemeMode; label: string; hint: string }[] = [
|
|
{ value: 'dark', label: 'Dark', hint: 'Use the dark variant.' },
|
|
{ value: 'light', label: 'Light', hint: 'Use the light variant.' },
|
|
{ value: 'system', label: 'System', hint: 'Follow OS preference.' },
|
|
];
|
|
|
|
// Labelled range input for the animated-background params (boolab FX sliders).
|
|
// Native <input type=range> tinted with the theme accent — no shadcn Slider
|
|
// primitive exists in ui/.
|
|
function FxSlider({
|
|
label,
|
|
value,
|
|
min,
|
|
max,
|
|
step,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
min: number;
|
|
max: number;
|
|
step: number;
|
|
onChange: (v: number) => void;
|
|
}) {
|
|
return (
|
|
<label className="block space-y-1">
|
|
<span className="flex items-center justify-between text-xs text-muted-foreground">
|
|
<span>{label}</span>
|
|
<span className="font-mono tabular-nums">{value.toFixed(2)}</span>
|
|
</span>
|
|
<input
|
|
type="range"
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
value={value}
|
|
onChange={(e) => onChange(Number(e.target.value))}
|
|
className="w-full cursor-pointer"
|
|
style={{ accentColor: 'var(--primary)' }}
|
|
/>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export function ThemePicker() {
|
|
const { id: currentId, mode: currentMode } = useTheme();
|
|
const animOn = useAnimBg();
|
|
const params = useAnimParams();
|
|
// The slider set only applies to the canvas-background themes; density is
|
|
// matrix-rain-only (Classic), speed/opacity apply to both fields.
|
|
const isCanvasTheme = currentId === 'boocode-classic' || currentId === 'boocode-override';
|
|
// Track the most recent in-flight pick so the picker can show a subtle
|
|
// "applying…" state on the targeted card while the PATCH is in flight.
|
|
const [pending, setPending] = useState<
|
|
{ kind: 'theme'; id: ThemeId } | { kind: 'mode'; mode: ThemeMode } | null
|
|
>(null);
|
|
|
|
async function pickTheme(id: ThemeId) {
|
|
if (id === currentId || pending) return;
|
|
setPending({ kind: 'theme', id });
|
|
try {
|
|
await setTheme(id, currentMode);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'failed to apply theme');
|
|
} finally {
|
|
setPending(null);
|
|
}
|
|
}
|
|
|
|
async function pickMode(mode: ThemeMode) {
|
|
if (mode === currentMode || pending) return;
|
|
setPending({ kind: 'mode', mode });
|
|
try {
|
|
await setTheme(currentId, mode);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'failed to apply mode');
|
|
} finally {
|
|
setPending(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<section className="space-y-3">
|
|
<h2 className="text-sm font-medium">Mode</h2>
|
|
<RadioGroup
|
|
value={currentMode}
|
|
onValueChange={(v) => void pickMode(v as ThemeMode)}
|
|
className="flex flex-wrap gap-4"
|
|
>
|
|
{MODES.map((m) => (
|
|
<div key={m.value} className="flex items-center gap-2">
|
|
<RadioGroupItem id={`mode-${m.value}`} value={m.value} />
|
|
<Label htmlFor={`mode-${m.value}`} className="cursor-pointer">
|
|
<span className="font-medium">{m.label}</span>
|
|
<span className="ml-2 text-xs text-muted-foreground">{m.hint}</span>
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
</section>
|
|
|
|
<section className="space-y-3">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<h2 className="text-sm font-medium">Animated background</h2>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={animOn}
|
|
onClick={() => setAnimBg(!animOn)}
|
|
className={cn(
|
|
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent',
|
|
'transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
animOn ? 'bg-primary' : 'bg-input',
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform duration-200',
|
|
animOn ? 'translate-x-4' : 'translate-x-0',
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Disables canvas animations and CSS effects for all themes. Persisted locally.
|
|
</p>
|
|
{isCanvasTheme && (
|
|
<div className={cn('grid gap-x-4 gap-y-3 sm:grid-cols-2 pt-1', !animOn && 'opacity-50')}>
|
|
{currentId === 'boocode-classic' && (
|
|
<FxSlider
|
|
label="Density"
|
|
value={params.density}
|
|
min={ANIM_RANGES.density.min}
|
|
max={ANIM_RANGES.density.max}
|
|
step={ANIM_RANGES.density.step}
|
|
onChange={setAnimDensity}
|
|
/>
|
|
)}
|
|
<FxSlider
|
|
label="Speed"
|
|
value={params.speed}
|
|
min={ANIM_RANGES.speed.min}
|
|
max={ANIM_RANGES.speed.max}
|
|
step={ANIM_RANGES.speed.step}
|
|
onChange={setAnimSpeed}
|
|
/>
|
|
<FxSlider
|
|
label="Opacity"
|
|
value={params.opacity}
|
|
min={ANIM_RANGES.opacity.min}
|
|
max={ANIM_RANGES.opacity.max}
|
|
step={ANIM_RANGES.opacity.step}
|
|
onChange={setAnimOpacity}
|
|
/>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="space-y-3">
|
|
<h2 className="text-sm font-medium">Theme</h2>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
{THEMES.map((t) => {
|
|
const isActive = t.id === currentId;
|
|
const isPending = pending?.kind === 'theme' && pending.id === t.id;
|
|
const isLightOnly = !t.supportsDark;
|
|
const isDarkOnly = !t.supportsLight;
|
|
return (
|
|
<Card
|
|
key={t.id}
|
|
onClick={() => void pickTheme(t.id)}
|
|
className={cn(
|
|
'p-3 cursor-pointer transition-colors',
|
|
'hover:bg-accent/10',
|
|
isActive && 'ring-2 ring-ring',
|
|
isPending && 'opacity-60',
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<div className="font-mono text-sm truncate">{t.name}</div>
|
|
<div className="text-xs text-muted-foreground">{t.family}</div>
|
|
</div>
|
|
{isActive && (
|
|
<span className="inline-flex items-center gap-1 text-xs text-primary shrink-0">
|
|
<Check className="size-3" /> Selected
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex mt-2 rounded overflow-hidden border border-border/40">
|
|
{t.anchors.map((hex, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex-1 h-6"
|
|
style={{ backgroundColor: hex }}
|
|
aria-hidden="true"
|
|
/>
|
|
))}
|
|
</div>
|
|
{isLightOnly && (
|
|
<div className="mt-2 text-xs text-muted-foreground italic">Light only</div>
|
|
)}
|
|
{isDarkOnly && (
|
|
<div className="mt-2 text-xs text-muted-foreground italic">Dark only</div>
|
|
)}
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|