feat: initial smart home dashboard
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
This commit is contained in:
Cosmo
2026-04-22 10:00:41 +00:00
commit 9044869fa4
29 changed files with 2439 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
"use client";
import { useState, useCallback } from "react";
import { motion } from "framer-motion";
import { Wind } from "lucide-react";
import { toggleFan, setFanPreset } from "@/lib/api";
interface Props {
entityId: string;
state: string;
presetMode?: string;
onUpdate: () => void;
}
const MODES = [
{ id: "Auto", label: "Авто", color: "#06b6d4" },
{ id: "Night", label: "Ночь", color: "#6366f1" },
{ id: "High", label: "Макс", color: "#f43f5e" },
];
export default function AirPurifierCard({
entityId,
state,
presetMode,
onUpdate,
}: Props) {
const isOn = state === "on";
const [currentMode, setCurrentMode] = useState(presetMode || "Auto");
const [pending, setPending] = useState(false);
const handleToggle = useCallback(async () => {
if (pending) return;
setPending(true);
await toggleFan(entityId, !isOn);
onUpdate();
setPending(false);
}, [entityId, isOn, pending, onUpdate]);
const handleMode = useCallback(
async (mode: string) => {
setCurrentMode(mode);
await setFanPreset(entityId, mode);
onUpdate();
},
[entityId, onUpdate]
);
const activeColor =
MODES.find((m) => m.id === currentMode)?.color || "#06b6d4";
return (
<motion.div
className="glass-card p-5 h-full flex flex-col justify-between"
style={
isOn
? {
background: `rgba(6,182,212,0.06)`,
border: `1px solid rgba(6,182,212,0.2)`,
}
: {}
}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
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(6,182,212,0.15)"
: "rgba(255,255,255,0.06)",
}}
>
<motion.div
animate={isOn ? { rotate: 360 } : { rotate: 0 }}
transition={
isOn
? { duration: 3, repeat: Infinity, ease: "linear" }
: {}
}
>
<Wind
size={20}
color={isOn ? "#06b6d4" : "var(--text-secondary)"}
/>
</motion.div>
</div>
<div
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
Очиститель воздуха
</div>
<div
className="text-xs mt-0.5"
style={{ color: isOn ? activeColor : "var(--text-secondary)" }}
>
{isOn ? currentMode : "Выключен"}
</div>
</div>
<motion.div
className={`toggle-track ${isOn ? "toggle-on" : ""}`}
style={{ background: isOn ? "#06b6d4" : "rgba(255,255,255,0.1)" }}
onClick={handleToggle}
whileTap={{ scale: 0.9 }}
>
<div className="toggle-thumb" />
</motion.div>
</div>
{isOn && (
<motion.div
className="flex gap-2 mt-4"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
>
{MODES.map((mode) => (
<motion.button
key={mode.id}
onClick={() => handleMode(mode.id)}
className="flex-1 py-2 rounded-xl text-xs font-medium"
style={
currentMode === mode.id
? {
background: `${mode.color}25`,
border: `1px solid ${mode.color}60`,
color: mode.color,
}
: {
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.08)",
color: "var(--text-secondary)",
}
}
whileTap={{ scale: 0.9 }}
>
{mode.label}
</motion.button>
))}
</motion.div>
)}
</motion.div>
);
}