fix: native button toggles, scroll enabled, remove whileHover interference
All checks were successful
Deploy to Coolify / deploy (push) Successful in 4s

This commit is contained in:
Cosmo
2026-04-22 11:00:24 +00:00
parent 98fdcafb73
commit 9c01fd235f
3 changed files with 155 additions and 224 deletions

View File

@@ -64,7 +64,7 @@ export default function Home() {
return ( return (
<div <div
className="relative w-screen overflow-hidden no-select" className="relative w-screen overflow-hidden"
style={{ height: "100dvh", background: "var(--bg)" }} style={{ height: "100dvh", background: "var(--bg)" }}
> >
{/* Ambient orbs */} {/* Ambient orbs */}
@@ -128,14 +128,13 @@ export default function Home() {
</AnimatePresence> </AnimatePresence>
{/* Content area — fills remaining space */} {/* Content area — fills remaining space */}
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-y-auto">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{/* ═══════════════ HOME TAB ═══════════════ */} {/* ═══════════════ HOME TAB ═══════════════ */}
{activeTab === "home" && ( {activeTab === "home" && (
<motion.div <motion.div
key="home" key="home"
className="h-full"
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
animate="visible" animate="visible"
@@ -146,10 +145,9 @@ export default function Home() {
Row 2: [Очиститель ×2] [Погода] Row 2: [Очиститель ×2] [Погода]
*/} */}
<div <div
className="h-full grid gap-3" className="grid gap-3"
style={{ style={{
gridTemplateColumns: "1fr 1fr 1fr", gridTemplateColumns: "1fr 1fr 1fr",
gridTemplateRows: "1fr 1fr",
}} }}
> >
{/* Свет Гостиная */} {/* Свет Гостиная */}

View File

@@ -18,165 +18,124 @@ const MODES = [
{ id: "High", label: "Турбо", color: "#f59e0b" }, { id: "High", label: "Турбо", color: "#f59e0b" },
]; ];
export default function AirPurifierCard({ export default function AirPurifierCard({ entityId, state, presetMode, onUpdate }: Props) {
entityId, const [localOn, setLocalOn] = useState(state === "on");
state,
presetMode,
onUpdate,
}: Props) {
const isOn = state === "on";
const [currentMode, setCurrentMode] = useState(presetMode || "Auto"); const [currentMode, setCurrentMode] = useState(presetMode || "Auto");
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const handleToggle = useCallback(async () => { const handleToggle = useCallback(async () => {
if (pending) return; if (pending) return;
const newState = !localOn;
setLocalOn(newState);
setPending(true); setPending(true);
await toggleFan(entityId, !isOn); try {
onUpdate(); await toggleFan(entityId, newState);
setPending(false);
}, [entityId, isOn, pending, onUpdate]);
const handleMode = useCallback(
async (mode: string) => {
setCurrentMode(mode);
await setFanPreset(entityId, mode);
onUpdate(); onUpdate();
}, } catch (e) {
[entityId, onUpdate] setLocalOn(!newState);
); }
setPending(false);
}, [entityId, localOn, pending, onUpdate]);
const handleMode = useCallback(async (mode: string) => {
setCurrentMode(mode);
await setFanPreset(entityId, mode);
onUpdate();
}, [entityId, onUpdate]);
const isOn = localOn;
const activeMode = MODES.find((m) => m.id === currentMode) || MODES[0]; const activeMode = MODES.find((m) => m.id === currentMode) || MODES[0];
const accentColor = isOn ? activeMode.color : "rgba(255,255,255,0.3)"; const accentColor = isOn ? activeMode.color : "rgba(255,255,255,0.3)";
return ( return (
<motion.div <motion.div
className="glass-card p-6 h-full flex flex-col" className="glass-card p-6 flex flex-col"
style={ style={{
isOn minHeight: "140px",
? { ...(isOn ? {
background: `${activeMode.color}08`, background: `${activeMode.color}08`,
border: `1px solid ${activeMode.color}25`, border: `1px solid ${activeMode.color}25`,
boxShadow: `0 0 40px ${activeMode.color}10`, boxShadow: `0 0 40px ${activeMode.color}10`,
} } : {})
: {} }}
}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, delay: 0.14 }} transition={{ duration: 0.35, delay: 0.14 }}
whileHover={{ scale: 1.01 }}
> >
{/* Top row: icon + name + toggle */} {/* Top row */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Animated icon */} <div className="w-16 h-16 rounded-2xl flex items-center justify-center flex-shrink-0"
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center flex-shrink-0"
style={{ style={{
background: isOn ? `${activeMode.color}20` : "rgba(255,255,255,0.06)", background: isOn ? `${activeMode.color}20` : "rgba(255,255,255,0.06)",
boxShadow: isOn ? `0 0 28px ${activeMode.color}40` : "none", boxShadow: isOn ? `0 0 28px ${activeMode.color}40` : "none",
}} }}>
> <motion.div animate={isOn ? { rotate: 360 } : { rotate: 0 }}
<motion.div transition={isOn ? { duration: 4, repeat: Infinity, ease: "linear" } : {}}>
animate={isOn ? { rotate: 360 } : { rotate: 0 }} <Wind size={32} color={isOn ? activeMode.color : "rgba(255,255,255,0.3)"} strokeWidth={1.5} />
transition={
isOn ? { duration: 4, repeat: Infinity, ease: "linear" } : {}
}
>
<Wind
size={32}
color={isOn ? activeMode.color : "rgba(255,255,255,0.3)"}
strokeWidth={1.5}
/>
</motion.div> </motion.div>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div <div className="text-xl font-bold" style={{ color: "var(--text-primary)" }}>Очиститель</div>
className="text-xl font-bold truncate" <div className="text-sm mt-0.5 font-medium" style={{ color: isOn ? activeMode.color : "var(--text-secondary)" }}>
style={{ color: "var(--text-primary)" }}
>
Очиститель воздуха
</div>
<div
className="text-sm mt-0.5 font-medium"
style={{ color: isOn ? activeMode.color : "var(--text-secondary)" }}
>
{isOn ? activeMode.label : "Выключен"} {isOn ? activeMode.label : "Выключен"}
</div> </div>
</div> </div>
{/* Toggle */} {/* Toggle — native button */}
<motion.div <button
className={`toggle-track ${isOn ? "toggle-on" : ""}`}
style={{
background: isOn ? activeMode.color : "rgba(255,255,255,0.1)",
boxShadow: isOn ? `0 0 16px ${activeMode.color}60` : "none",
}}
onClick={handleToggle} onClick={handleToggle}
whileTap={{ scale: 0.88 }} style={{
width: "60px", height: "32px", borderRadius: "16px",
background: isOn ? activeMode.color : "rgba(255,255,255,0.12)",
boxShadow: isOn ? `0 0 16px ${activeMode.color}60` : "none",
border: "none", cursor: "pointer", position: "relative",
transition: "background 0.2s ease, box-shadow 0.2s ease",
flexShrink: 0,
WebkitTapHighlightColor: "transparent",
}}
> >
<div className="toggle-thumb" /> <motion.div
</motion.div> style={{
width: "24px", height: "24px", borderRadius: "12px",
background: "white", position: "absolute", top: "4px",
boxShadow: "0 2px 6px rgba(0,0,0,0.3)",
}}
animate={{ left: isOn ? "32px" : "4px" }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
/>
</button>
</div> </div>
{/* Mode buttons */} {/* Mode buttons */}
<AnimatePresence> <div className="flex gap-3 mt-4">
{isOn && ( {MODES.map((mode) => {
<motion.div const isActive = currentMode === mode.id && isOn;
className="flex gap-3 mt-auto pt-4" return (
initial={{ opacity: 0, y: 8 }} <button
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.22 }}
>
{MODES.map((mode) => {
const isActive = currentMode === mode.id;
return (
<motion.button
key={mode.id}
onClick={() => handleMode(mode.id)}
className="flex-1 py-2.5 rounded-2xl text-sm font-semibold"
style={
isActive
? {
background: `${mode.color}22`,
border: `1.5px solid ${mode.color}60`,
color: mode.color,
boxShadow: `0 0 14px ${mode.color}30`,
}
: {
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.08)",
color: "var(--text-secondary)",
}
}
whileTap={{ scale: 0.88 }}
>
{mode.label}
</motion.button>
);
})}
</motion.div>
)}
</AnimatePresence>
{/* Offline state bottom fill */}
{!isOn && (
<div className="mt-auto flex gap-3 pt-4">
{MODES.map((mode) => (
<div
key={mode.id} key={mode.id}
className="flex-1 py-2.5 rounded-2xl text-sm font-semibold text-center" onClick={() => isOn && handleMode(mode.id)}
style={{ className="flex-1 py-2.5 rounded-2xl text-sm font-semibold"
background: "rgba(255,255,255,0.03)", style={isActive ? {
border: "1px solid rgba(255,255,255,0.05)", background: `${mode.color}22`,
color: "rgba(255,255,255,0.15)", border: `1.5px solid ${mode.color}60`,
color: mode.color,
boxShadow: `0 0 14px ${mode.color}30`,
cursor: "pointer",
WebkitTapHighlightColor: "transparent",
} : {
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.08)",
color: isOn ? "var(--text-secondary)" : "rgba(255,255,255,0.15)",
cursor: isOn ? "pointer" : "default",
WebkitTapHighlightColor: "transparent",
}} }}
> >
{mode.label} {mode.label}
</div> </button>
))} );
</div> })}
)} </div>
</motion.div> </motion.div>
); );
} }

View File

@@ -15,142 +15,116 @@ interface Props {
onUpdate: () => void; onUpdate: () => void;
} }
export default function LightCard({ export default function LightCard({ entityId, name, state, brightness, showSlider = false, onUpdate }: Props) {
entityId, // Optimistic local state — не зависим от HA mock
name, const [localOn, setLocalOn] = useState(state === "on");
state,
brightness,
showSlider = false,
onUpdate,
}: Props) {
const isOn = state === "on";
const brightPct = getBrightnessPct(brightness); const brightPct = getBrightnessPct(brightness);
const [localBrightness, setLocalBrightness] = useState(brightPct || 70); const [localBrightness, setLocalBrightness] = useState(brightPct || 70);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const handleToggle = useCallback(async () => { const handleToggle = useCallback(async () => {
if (pending) return; if (pending) return;
const newState = !localOn;
setLocalOn(newState); // optimistic
setPending(true); setPending(true);
await toggleLight(entityId, !isOn); try {
onUpdate(); await toggleLight(entityId, newState);
setPending(false);
}, [entityId, isOn, pending, onUpdate]);
const handleBrightnessChange = useCallback(
async (val: number) => {
setLocalBrightness(val);
await setLightBrightness(entityId, pctToBrightness(val));
onUpdate(); onUpdate();
}, } catch (e) {
[entityId, onUpdate] setLocalOn(!newState); // rollback on real error
); }
setPending(false);
}, [entityId, localOn, pending, onUpdate]);
const handleBrightnessChange = useCallback(async (val: number) => {
setLocalBrightness(val);
await setLightBrightness(entityId, pctToBrightness(val));
onUpdate();
}, [entityId, onUpdate]);
const isOn = localOn;
return ( return (
<motion.div <motion.div
className="glass-card p-6 h-full flex flex-col justify-between" className="glass-card p-6 flex flex-col justify-between"
style={ style={{
isOn minHeight: "160px",
? { ...(isOn ? {
background: "rgba(245,158,11,0.07)", background: "rgba(245,158,11,0.07)",
border: "1px solid rgba(245,158,11,0.22)", border: "1px solid rgba(245,158,11,0.22)",
boxShadow: "0 0 40px rgba(245,158,11,0.08), inset 0 1px 0 rgba(245,158,11,0.1)", boxShadow: "0 0 40px rgba(245,158,11,0.08), inset 0 1px 0 rgba(245,158,11,0.1)",
} } : {})
: {} }}
}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35 }} transition={{ duration: 0.35 }}
whileHover={{ scale: 1.01 }}
> >
{/* Top row: icon + toggle */} {/* Top row: icon + toggle */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
{/* Icon */}
<motion.div <motion.div
className="w-16 h-16 rounded-2xl flex items-center justify-center" className="w-16 h-16 rounded-2xl flex items-center justify-center"
style={{ style={{
background: isOn background: isOn ? "rgba(245,158,11,0.18)" : "rgba(255,255,255,0.06)",
? "rgba(245,158,11,0.18)"
: "rgba(255,255,255,0.06)",
boxShadow: isOn ? "0 0 28px rgba(245,158,11,0.35)" : "none", boxShadow: isOn ? "0 0 28px rgba(245,158,11,0.35)" : "none",
}} }}
animate={{ scale: isOn ? 1 : 0.97 }} animate={{ scale: isOn ? 1 : 0.97 }}
transition={{ duration: 0.3 }}
> >
<Lightbulb <Lightbulb size={32} color={isOn ? "#f59e0b" : "rgba(255,255,255,0.35)"} fill={isOn ? "#f59e0b" : "none"} strokeWidth={isOn ? 1.5 : 2} />
size={32}
color={isOn ? "#f59e0b" : "rgba(255,255,255,0.35)"}
fill={isOn ? "#f59e0b" : "none"}
strokeWidth={isOn ? 1.5 : 2}
/>
</motion.div> </motion.div>
{/* Toggle */} {/* Toggle switch */}
<motion.div <button
className={`toggle-track ${isOn ? "toggle-on" : ""}`}
style={{
background: isOn
? "#f59e0b"
: "rgba(255,255,255,0.1)",
boxShadow: isOn ? "0 0 16px rgba(245,158,11,0.5)" : "none",
}}
onClick={handleToggle} onClick={handleToggle}
whileTap={{ scale: 0.88 }} style={{
width: "60px",
height: "32px",
borderRadius: "16px",
background: isOn ? "#f59e0b" : "rgba(255,255,255,0.12)",
boxShadow: isOn ? "0 0 16px rgba(245,158,11,0.5)" : "none",
border: "none",
cursor: "pointer",
position: "relative",
transition: "background 0.2s ease, box-shadow 0.2s ease",
flexShrink: 0,
WebkitTapHighlightColor: "transparent",
}}
> >
<div className="toggle-thumb" /> <motion.div
</motion.div> style={{
width: "24px",
height: "24px",
borderRadius: "12px",
background: "white",
position: "absolute",
top: "4px",
boxShadow: "0 2px 6px rgba(0,0,0,0.3)",
}}
animate={{ left: isOn ? "32px" : "4px" }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
/>
</button>
</div> </div>
{/* Name + state */} {/* Name + state */}
<div className="mt-auto"> <div className="mt-4">
<div <div className="text-xl font-bold leading-tight" style={{ color: isOn ? "#f59e0b" : "var(--text-primary)" }}>
className="text-xl font-bold leading-tight"
style={{ color: isOn ? "#f59e0b" : "var(--text-primary)" }}
>
{name} {name}
</div> </div>
<div <div className="text-sm mt-1 font-medium" style={{ color: isOn ? "rgba(245,158,11,0.7)" : "var(--text-secondary)" }}>
className="text-sm mt-1 font-medium"
style={{ color: isOn ? "rgba(245,158,11,0.7)" : "var(--text-secondary)" }}
>
{isOn ? (showSlider ? `Яркость ${localBrightness}%` : "Включён") : "Выключен"} {isOn ? (showSlider ? `Яркость ${localBrightness}%` : "Включён") : "Выключен"}
</div> </div>
{/* Brightness slider */}
<AnimatePresence> <AnimatePresence>
{showSlider && isOn && ( {showSlider && isOn && (
<motion.div <motion.div className="mt-4" initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }}>
className="mt-4" <input
initial={{ opacity: 0, height: 0 }} type="range" min={5} max={100} value={localBrightness}
animate={{ opacity: 1, height: "auto" }} onChange={(e) => setLocalBrightness(parseInt(e.target.value))}
exit={{ opacity: 0, height: 0 }} onMouseUp={(e) => handleBrightnessChange(parseInt((e.target as HTMLInputElement).value))}
transition={{ duration: 0.25 }} onTouchEnd={(e) => handleBrightnessChange(parseInt((e.target as HTMLInputElement).value))}
> className="w-full"
<div className="relative pt-1"> style={{ accentColor: "#f59e0b" }}
<div />
className="absolute left-0 rounded-full pointer-events-none"
style={{
width: `${localBrightness}%`,
height: "6px",
top: "calc(50% + 4px)",
background: "linear-gradient(90deg, rgba(245,158,11,0.5), rgba(245,158,11,0.9))",
}}
/>
<input
type="range"
min={5}
max={100}
value={localBrightness}
onChange={(e) => setLocalBrightness(parseInt(e.target.value))}
onMouseUp={(e) =>
handleBrightnessChange(parseInt((e.target as HTMLInputElement).value))
}
onTouchEnd={(e) =>
handleBrightnessChange(parseInt((e.target as HTMLInputElement).value))
}
className="w-full relative z-10"
style={{ background: "transparent" }}
/>
</div>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>