feat: add AtmospherePanel component with toggles and intensity sliders

This commit is contained in:
Aaron Wood 2026-04-10 19:29:00 -04:00
parent 8da9ac2cf6
commit 55e9019fa9

View file

@ -0,0 +1,141 @@
import { useEffect, useRef, useState } from "react";
import type { AtmosphereState } from "../lib/atmosphereTypes";
import styles from "./AtmospherePanel.module.css";
type EffectKey = keyof AtmosphereState;
const EFFECTS: { key: EffectKey; emoji: string; label: string }[] = [
{ key: "fog", emoji: "🌫", label: "Fog" },
{ key: "fire", emoji: "🔥", label: "Fire" },
{ key: "rain", emoji: "🌧", label: "Rain" },
{ key: "embers", emoji: "✨", label: "Embers" },
];
interface AtmospherePanelProps {
atmosphere: AtmosphereState;
onAtmosphereChange: (next: AtmosphereState) => void;
}
export default function AtmospherePanel({
atmosphere,
onAtmosphereChange,
}: AtmospherePanelProps) {
const [open, setOpen] = useState(false);
// Local slider values so the label updates while dragging without triggering remounts
const [localIntensity, setLocalIntensity] = useState({
fog: atmosphere.fog.intensity,
fire: atmosphere.fire.intensity,
rain: atmosphere.rain.intensity,
embers: atmosphere.embers.intensity,
});
const containerRef = useRef<HTMLDivElement>(null);
// Close panel on outside click
useEffect(() => {
if (!open) return;
function onMouseDown(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [open]);
// Sync local intensity when atmosphere changes from socket
useEffect(() => {
setLocalIntensity({
fog: atmosphere.fog.intensity,
fire: atmosphere.fire.intensity,
rain: atmosphere.rain.intensity,
embers: atmosphere.embers.intensity,
});
}, [atmosphere]);
const activeEmojis = EFFECTS
.filter(({ key }) => atmosphere[key].active)
.map(({ emoji }) => emoji)
.join("");
function toggleEffect(key: EffectKey) {
onAtmosphereChange({
...atmosphere,
[key]: { ...atmosphere[key], active: !atmosphere[key].active },
});
}
function handleSliderChange(key: EffectKey, value: number) {
// Update label only — no socket emit yet
setLocalIntensity((prev) => ({ ...prev, [key]: value }));
}
function handleSliderCommit(key: EffectKey, value: number) {
// Emit on mouse/touch release
onAtmosphereChange({
...atmosphere,
[key]: { ...atmosphere[key], intensity: value },
});
}
return (
<div className={styles.container} ref={containerRef}>
<button
className={`${styles.trigger} ${activeEmojis ? styles.triggerActive : ""}`}
onClick={() => setOpen((o) => !o)}
title="Atmosphere effects"
>
{activeEmojis || "🌫"}
</button>
{open && (
<div className={styles.panel}>
<div className={styles.panelTitle}>Atmosphere</div>
{EFFECTS.map(({ key, emoji, label }) => (
<div key={key} className={styles.effectRow}>
<div className={styles.effectTop}>
<span className={styles.effectLabel}>{emoji} {label}</span>
<button
className={`${styles.toggle} ${atmosphere[key].active ? styles.toggleOn : ""}`}
onClick={() => toggleEffect(key)}
aria-label={`Toggle ${label}`}
/>
</div>
<div
className={`${styles.intensityRow} ${
atmosphere[key].active ? styles.intensityActive : ""
}`}
>
<span className={styles.intensityLabel}>
{localIntensity[key]}%
</span>
<input
type="range"
min={0}
max={100}
value={localIntensity[key]}
disabled={!atmosphere[key].active}
className={styles.slider}
onChange={(e) =>
handleSliderChange(key, Number(e.target.value))
}
onMouseUp={(e) =>
handleSliderCommit(
key,
Number((e.target as HTMLInputElement).value),
)
}
onTouchEnd={(e) =>
handleSliderCommit(
key,
Number((e.currentTarget as HTMLInputElement).value),
)
}
/>
</div>
</div>
))}
</div>
)}
</div>
);
}