Files
smart-home-tablet/app/page.tsx
Cosmo 088cd35ea6
All checks were successful
Deploy to Coolify / deploy (push) Successful in 5s
feat: new layout, rooms row, fix weather+HA, fix BottomNav overflow
- Remove TasksCard and SavingsCard from home tab
- New grid layout: lights+thermostat row 1, purifier+weather row 2
- Add RoomsRow component with room navigation
- Fix HA entity mapping: fan.zhimi_rmb1_9528_air_purifier → fan.air_purifier
- Add real entity aliases for HA route
- Fix weather route: add timeout, better error handling
- Fix BottomNav: use 100dvh + flex-shrink-0
- TopBar: accept isDemo prop, show Demo badge in header
- WeatherCard: compact prop, better loading/error states
- globals.css: add no-scrollbar utility
2026-04-22 10:33:20 +00:00

380 lines
15 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 TasksCard from "@/components/cards/TasksCard";
import WeatherCard from "@/components/cards/WeatherCard";
import RoomsRow from "@/components/RoomsRow";
import { useHA, useWeather, useTasks } 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();
const { tasks, refresh: refreshTasks } = useTasks();
// 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 no-select"
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-hidden">
<AnimatePresence mode="wait">
{/* ═══════════════ HOME TAB ═══════════════ */}
{activeTab === "home" && (
<motion.div
key="home"
className="h-full"
variants={containerVariants}
initial="hidden"
animate="visible"
exit={{ opacity: 0 }}
>
{/*
Row 1: [Свет Гостиная] [Свет Спальня] [Климат]
Row 2: [Очиститель ×2] [Погода]
*/}
<div
className="h-full 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}>
<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>
)}
{/* ═══════════════ TASKS TAB ═══════════════ */}
{activeTab === "tasks" && (
<motion.div
key="tasks"
className="h-full max-w-2xl mx-auto w-full"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.25 }}
>
<TasksCard tasks={tasks} onUpdate={refreshTasks} />
</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?._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>
);
}