Files
smart-home-tablet/app/page.tsx
Cosmo 9c01fd235f
All checks were successful
Deploy to Coolify / deploy (push) Successful in 4s
fix: native button toggles, scroll enabled, remove whileHover interference
2026-04-22 11:00:24 +00:00

363 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}