All checks were successful
Deploy to Coolify / deploy (push) Successful in 44s
- Next.js 14 + TypeScript + Tailwind CSS - Glassmorphism design with ambient orbs - Cards: Light x2, Temperature, AirPurifier, Tasks, Weather, Savings - Home Assistant integration (demo mode if no token) - Vikunja tasks API - Pulse savings API - wttr.in weather - Framer Motion animations - Dark/light theme toggle - Bottom navigation - Dockerfile for deployment
151 lines
4.4 KiB
TypeScript
151 lines
4.4 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useCallback } from "react";
|
||
import { motion } from "framer-motion";
|
||
import { Lightbulb } from "lucide-react";
|
||
import { toggleLight, setLightBrightness } from "@/lib/api";
|
||
import { getBrightnessPct, pctToBrightness } from "@/lib/ha";
|
||
|
||
interface Props {
|
||
entityId: string;
|
||
name: string;
|
||
state: string;
|
||
brightness?: number;
|
||
showSlider?: boolean;
|
||
onUpdate: () => void;
|
||
}
|
||
|
||
export default function LightCard({
|
||
entityId,
|
||
name,
|
||
state,
|
||
brightness,
|
||
showSlider = false,
|
||
onUpdate,
|
||
}: Props) {
|
||
const isOn = state === "on";
|
||
const brightPct = getBrightnessPct(brightness);
|
||
const [localBrightness, setLocalBrightness] = useState(brightPct || 70);
|
||
const [pending, setPending] = useState(false);
|
||
|
||
const handleToggle = useCallback(async () => {
|
||
if (pending) return;
|
||
setPending(true);
|
||
await toggleLight(entityId, !isOn);
|
||
onUpdate();
|
||
setPending(false);
|
||
}, [entityId, isOn, pending, onUpdate]);
|
||
|
||
const handleBrightnessChange = useCallback(
|
||
async (val: number) => {
|
||
setLocalBrightness(val);
|
||
await setLightBrightness(entityId, pctToBrightness(val));
|
||
onUpdate();
|
||
},
|
||
[entityId, onUpdate]
|
||
);
|
||
|
||
return (
|
||
<motion.div
|
||
className="glass-card p-5 h-full flex flex-col justify-between"
|
||
style={
|
||
isOn
|
||
? {
|
||
background: "rgba(245,158,11,0.08)",
|
||
border: "1px solid rgba(245,158,11,0.2)",
|
||
boxShadow: "0 0 30px rgba(245,158,11,0.1)",
|
||
}
|
||
: {}
|
||
}
|
||
initial={{ opacity: 0, scale: 0.95 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
transition={{ duration: 0.3 }}
|
||
whileHover={{ scale: 1.01 }}
|
||
>
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<div
|
||
className="w-10 h-10 rounded-xl flex items-center justify-center mb-3"
|
||
style={{
|
||
background: isOn
|
||
? "rgba(245,158,11,0.2)"
|
||
: "rgba(255,255,255,0.06)",
|
||
}}
|
||
>
|
||
<Lightbulb
|
||
size={20}
|
||
color={isOn ? "#f59e0b" : "var(--text-secondary)"}
|
||
fill={isOn ? "#f59e0b" : "none"}
|
||
/>
|
||
</div>
|
||
<div
|
||
className="text-sm font-semibold"
|
||
style={{ color: "var(--text-primary)" }}
|
||
>
|
||
{name}
|
||
</div>
|
||
<div
|
||
className="text-xs mt-0.5"
|
||
style={{ color: isOn ? "#f59e0b" : "var(--text-secondary)" }}
|
||
>
|
||
{isOn ? (showSlider ? `${localBrightness}%` : "Включён") : "Выключен"}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Toggle */}
|
||
<motion.div
|
||
className={`toggle-track ${isOn ? "toggle-on" : ""}`}
|
||
style={{
|
||
background: isOn
|
||
? "#f59e0b"
|
||
: "rgba(255,255,255,0.1)",
|
||
}}
|
||
onClick={handleToggle}
|
||
whileTap={{ scale: 0.9 }}
|
||
>
|
||
<div className="toggle-thumb" />
|
||
</motion.div>
|
||
</div>
|
||
|
||
{showSlider && isOn && (
|
||
<motion.div
|
||
className="mt-4"
|
||
initial={{ opacity: 0, y: 5 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
>
|
||
<div
|
||
className="text-xs mb-2 flex justify-between"
|
||
style={{ color: "var(--text-secondary)" }}
|
||
>
|
||
<span>Яркость</span>
|
||
<span>{localBrightness}%</span>
|
||
</div>
|
||
<div className="relative">
|
||
<div
|
||
className="absolute inset-y-0 left-0 rounded-l-full pointer-events-none"
|
||
style={{
|
||
width: `${localBrightness}%`,
|
||
background: "linear-gradient(90deg, rgba(245,158,11,0.4), rgba(245,158,11,0.8))",
|
||
height: "6px",
|
||
top: "50%",
|
||
transform: "translateY(-50%)",
|
||
}}
|
||
/>
|
||
<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>
|
||
);
|
||
}
|