363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useCallback, useEffect } from "react";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import TopBar from "@/components/TopBar";
|
||
import BottomNav from "@/components/BottomNav";
|
||
import LightCard from "@/components/cards/LightCard";
|
||
import TemperatureCard from "@/components/cards/TemperatureCard";
|
||
import AirPurifierCard from "@/components/cards/AirPurifierCard";
|
||
|
||
import WeatherCard from "@/components/cards/WeatherCard";
|
||
import RoomsRow from "@/components/RoomsRow";
|
||
import { useHA, useWeather } from "@/hooks/useHA";
|
||
|
||
// Stagger container variants
|
||
const containerVariants = {
|
||
hidden: {},
|
||
visible: { transition: { staggerChildren: 0.07 } },
|
||
};
|
||
|
||
const cardVariants = {
|
||
hidden: { opacity: 0, y: 20 },
|
||
visible: { opacity: 1, y: 0, transition: { duration: 0.35 } },
|
||
};
|
||
|
||
export default function Home() {
|
||
const [isDark, setIsDark] = useState(true);
|
||
const [activeTab, setActiveTab] = useState("home");
|
||
const [roomFilter, setRoomFilter] = useState<string | null>(null);
|
||
|
||
const { data: haData, loading: haLoading, refresh: refreshHA } = useHA(15000);
|
||
const weather = useWeather();
|
||
|
||
// Apply theme to html element
|
||
useEffect(() => {
|
||
const html = document.documentElement;
|
||
if (isDark) {
|
||
html.classList.add("dark");
|
||
html.classList.remove("light");
|
||
document.body.classList.remove("light");
|
||
} else {
|
||
html.classList.remove("dark");
|
||
html.classList.add("light");
|
||
document.body.classList.add("light");
|
||
}
|
||
}, [isDark]);
|
||
|
||
const states = haData?.states || {};
|
||
const isDemo = haData?.demo || false;
|
||
|
||
const handleHAUpdate = useCallback(() => {
|
||
setTimeout(refreshHA, 500);
|
||
}, [refreshHA]);
|
||
|
||
const handleRoomClick = useCallback((roomId: string) => {
|
||
setActiveTab("devices");
|
||
setRoomFilter(roomId);
|
||
}, []);
|
||
|
||
const livingRoom = states["light.living_room"];
|
||
const bedroom = states["light.bedroom"];
|
||
const thermostat = states["climate.thermostat"];
|
||
const airPurifier = states["fan.air_purifier"];
|
||
|
||
return (
|
||
<div
|
||
className="relative w-screen overflow-hidden"
|
||
style={{ height: "100dvh", background: "var(--bg)" }}
|
||
>
|
||
{/* Ambient orbs */}
|
||
<div
|
||
className="orb"
|
||
style={{
|
||
width: 480,
|
||
height: 480,
|
||
top: "-12%",
|
||
left: "-8%",
|
||
background: isDark ? "rgba(245,158,11,0.09)" : "rgba(245,158,11,0.06)",
|
||
animation: "orbMove1 22s ease-in-out infinite",
|
||
}}
|
||
/>
|
||
<div
|
||
className="orb"
|
||
style={{
|
||
width: 420,
|
||
height: 420,
|
||
bottom: "0%",
|
||
right: "-8%",
|
||
background: isDark ? "rgba(139,92,246,0.1)" : "rgba(139,92,246,0.06)",
|
||
animation: "orbMove2 28s ease-in-out infinite",
|
||
}}
|
||
/>
|
||
<div
|
||
className="orb"
|
||
style={{
|
||
width: 360,
|
||
height: 360,
|
||
top: "30%",
|
||
left: "35%",
|
||
background: isDark ? "rgba(59,130,246,0.07)" : "rgba(59,130,246,0.04)",
|
||
animation: "orbMove3 34s ease-in-out infinite",
|
||
}}
|
||
/>
|
||
|
||
{/* Main layout — flex column fills 100dvh */}
|
||
<div className="relative z-10 flex flex-col p-4 gap-3" style={{ height: "100dvh" }}>
|
||
{/* Top bar */}
|
||
<TopBar
|
||
isDark={isDark}
|
||
onToggleTheme={() => setIsDark(!isDark)}
|
||
weather={weather}
|
||
isDemo={isDemo}
|
||
/>
|
||
|
||
{/* Rooms row — only on home tab */}
|
||
<AnimatePresence>
|
||
{activeTab === "home" && (
|
||
<motion.div
|
||
key="rooms-row"
|
||
initial={{ opacity: 0, height: 0 }}
|
||
animate={{ opacity: 1, height: "auto" }}
|
||
exit={{ opacity: 0, height: 0 }}
|
||
transition={{ duration: 0.2 }}
|
||
>
|
||
<RoomsRow onRoomClick={handleRoomClick} />
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* Content area — fills remaining space */}
|
||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||
<AnimatePresence mode="wait">
|
||
|
||
{/* ═══════════════ HOME TAB ═══════════════ */}
|
||
{activeTab === "home" && (
|
||
<motion.div
|
||
key="home"
|
||
variants={containerVariants}
|
||
initial="hidden"
|
||
animate="visible"
|
||
exit={{ opacity: 0 }}
|
||
>
|
||
{/*
|
||
Row 1: [Свет Гостиная] [Свет Спальня] [Климат]
|
||
Row 2: [Очиститель ×2] [Погода]
|
||
*/}
|
||
<div
|
||
className="grid gap-3"
|
||
style={{
|
||
gridTemplateColumns: "1fr 1fr 1fr",
|
||
}}
|
||
>
|
||
{/* Свет Гостиная */}
|
||
<motion.div variants={cardVariants}>
|
||
<LightCard
|
||
entityId="light.living_room"
|
||
name="Гостиная"
|
||
state={livingRoom?.state || "off"}
|
||
brightness={livingRoom?.attributes?.brightness}
|
||
showSlider={true}
|
||
onUpdate={handleHAUpdate}
|
||
/>
|
||
</motion.div>
|
||
|
||
{/* Свет Спальня */}
|
||
<motion.div variants={cardVariants}>
|
||
<LightCard
|
||
entityId="light.bedroom"
|
||
name="Спальня"
|
||
state={bedroom?.state || "off"}
|
||
brightness={bedroom?.attributes?.brightness}
|
||
showSlider={false}
|
||
onUpdate={handleHAUpdate}
|
||
/>
|
||
</motion.div>
|
||
|
||
{/* Термостат */}
|
||
<motion.div variants={cardVariants}>
|
||
<TemperatureCard
|
||
entityId="climate.thermostat"
|
||
currentTemp={thermostat?.attributes?.current_temperature}
|
||
targetTemp={thermostat?.attributes?.temperature}
|
||
state={thermostat?.state || "off"}
|
||
onUpdate={handleHAUpdate}
|
||
/>
|
||
</motion.div>
|
||
|
||
{/* Очиститель воздуха — 2 колонки */}
|
||
<motion.div
|
||
variants={cardVariants}
|
||
style={{ gridColumn: "span 2" }}
|
||
>
|
||
<AirPurifierCard
|
||
entityId="fan.air_purifier"
|
||
state={airPurifier?.state || "off"}
|
||
presetMode={airPurifier?.attributes?.preset_mode}
|
||
onUpdate={handleHAUpdate}
|
||
/>
|
||
</motion.div>
|
||
|
||
{/* Погода — правый нижний */}
|
||
<motion.div variants={cardVariants}>
|
||
<WeatherCard weather={weather} />
|
||
</motion.div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* ═══════════════ DEVICES TAB ═══════════════ */}
|
||
{activeTab === "devices" && (
|
||
<motion.div
|
||
key="devices"
|
||
className="h-full flex flex-col gap-3"
|
||
variants={containerVariants}
|
||
initial="hidden"
|
||
animate="visible"
|
||
exit={{ opacity: 0 }}
|
||
>
|
||
{/* Room filter pills */}
|
||
{roomFilter && (
|
||
<motion.div
|
||
className="flex items-center gap-2"
|
||
initial={{ opacity: 0, y: -8 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
>
|
||
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
||
Фильтр:
|
||
</span>
|
||
<button
|
||
onClick={() => setRoomFilter(null)}
|
||
className="text-xs px-3 py-1 rounded-full font-semibold flex items-center gap-1"
|
||
style={{
|
||
background: "rgba(99,102,241,0.15)",
|
||
color: "#6366f1",
|
||
border: "1px solid rgba(99,102,241,0.3)",
|
||
}}
|
||
>
|
||
{{
|
||
living: "🛋️ Гостиная",
|
||
bedroom: "🛏️ Спальня",
|
||
kitchen: "🍳 Кухня",
|
||
bathroom: "🚿 Ванная",
|
||
}[roomFilter] || roomFilter}
|
||
<span style={{ opacity: 0.6 }}>✕</span>
|
||
</button>
|
||
</motion.div>
|
||
)}
|
||
<div
|
||
className="flex-1 grid gap-3"
|
||
style={{
|
||
gridTemplateColumns: "1fr 1fr 1fr",
|
||
gridTemplateRows: "1fr 1fr",
|
||
}}
|
||
>
|
||
<motion.div variants={cardVariants}>
|
||
<LightCard
|
||
entityId="light.living_room"
|
||
name="Гостиная"
|
||
state={livingRoom?.state || "off"}
|
||
brightness={livingRoom?.attributes?.brightness}
|
||
showSlider={true}
|
||
onUpdate={handleHAUpdate}
|
||
/>
|
||
</motion.div>
|
||
<motion.div variants={cardVariants}>
|
||
<LightCard
|
||
entityId="light.bedroom"
|
||
name="Спальня"
|
||
state={bedroom?.state || "off"}
|
||
brightness={bedroom?.attributes?.brightness}
|
||
showSlider={false}
|
||
onUpdate={handleHAUpdate}
|
||
/>
|
||
</motion.div>
|
||
<motion.div variants={cardVariants}>
|
||
<AirPurifierCard
|
||
entityId="fan.air_purifier"
|
||
state={airPurifier?.state || "off"}
|
||
presetMode={airPurifier?.attributes?.preset_mode}
|
||
onUpdate={handleHAUpdate}
|
||
/>
|
||
</motion.div>
|
||
<motion.div variants={cardVariants} style={{ gridColumn: "span 2" }}>
|
||
<TemperatureCard
|
||
entityId="climate.thermostat"
|
||
currentTemp={thermostat?.attributes?.current_temperature}
|
||
targetTemp={thermostat?.attributes?.temperature}
|
||
state={thermostat?.state || "off"}
|
||
onUpdate={handleHAUpdate}
|
||
/>
|
||
</motion.div>
|
||
<motion.div variants={cardVariants}>
|
||
<WeatherCard weather={weather} compact />
|
||
</motion.div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* ═══════════════ SETTINGS TAB ═══════════════ */}
|
||
{activeTab === "settings" && (
|
||
<motion.div
|
||
key="settings"
|
||
className="h-full flex items-center justify-center"
|
||
initial={{ opacity: 0, scale: 0.95 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
exit={{ opacity: 0, scale: 0.95 }}
|
||
transition={{ duration: 0.25 }}
|
||
>
|
||
<div className="glass-card p-8 max-w-md w-full text-center">
|
||
<div className="text-4xl mb-4">⚙️</div>
|
||
<h2
|
||
className="text-lg font-semibold mb-2"
|
||
style={{ color: "var(--text-primary)" }}
|
||
>
|
||
Настройки
|
||
</h2>
|
||
<p
|
||
className="text-sm mb-6"
|
||
style={{ color: "var(--text-secondary)" }}
|
||
>
|
||
Умный дом подключён. Когда появятся устройства — они появятся автоматически.
|
||
</p>
|
||
|
||
<div className="space-y-3 text-left">
|
||
{[
|
||
{ label: "HA URL", value: "✅ Настроен" },
|
||
{ label: "HA Token", value: isDemo ? "❌ Не настроен" : "✅ Настроен" },
|
||
{ label: "Vikunja", value: "✅ Подключён" },
|
||
{ label: "Pulse API", value: "✅ Подключён" },
|
||
{ label: "Очиститель", value: (airPurifier as any)?._mock ? "⚡ Демо" : "✅ Реальный" },
|
||
].map((item) => (
|
||
<div
|
||
key={item.label}
|
||
className="flex justify-between items-center px-4 py-3 rounded-2xl"
|
||
style={{
|
||
background: "rgba(255,255,255,0.04)",
|
||
border: "1px solid rgba(255,255,255,0.06)",
|
||
}}
|
||
>
|
||
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
||
{item.label}
|
||
</span>
|
||
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
|
||
{item.value}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
|
||
{/* Bottom nav — flex-shrink-0, always visible */}
|
||
<div className="flex-shrink-0" style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}>
|
||
<BottomNav active={activeTab} onChange={setActiveTab} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|