feat: full redesign - sidebar layout, room tabs, device cards
All checks were successful
Deploy to Coolify / deploy (push) Successful in 3s

This commit is contained in:
Cosmo
2026-04-22 11:05:41 +00:00
parent 9c01fd235f
commit 311ae1dc4b
17 changed files with 819 additions and 2016 deletions

View File

@@ -1,362 +1,312 @@
"use client";
'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 { useState, useEffect, useCallback } from 'react'
import Sidebar from '@/components/Sidebar'
import TopBar from '@/components/TopBar'
import RoomTabs from '@/components/RoomTabs'
import DeviceCard from '@/components/DeviceCard'
import WeatherCard from "@/components/cards/WeatherCard";
import RoomsRow from "@/components/RoomsRow";
import { useHA, useWeather } from "@/hooks/useHA";
type Tab = 'home' | 'rooms' | 'sensors' | 'settings'
// Stagger container variants
const containerVariants = {
hidden: {},
visible: { transition: { staggerChildren: 0.07 } },
};
interface WeatherData {
temp: string
desc: string
humidity: string
windSpeed: string
feelsLike: string
forecast?: { date: string; maxTemp: string; minTemp: string; desc: string }[]
}
const cardVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.35 } },
};
interface SensorData {
temperature: number
humidity: number
pm25: number
}
export default function Home() {
const [isDark, setIsDark] = useState(true);
const [activeTab, setActiveTab] = useState("home");
const [roomFilter, setRoomFilter] = useState<string | null>(null);
interface HaStates {
[key: string]: { state: string; attributes?: Record<string, any>; _mock?: boolean }
}
const { data: haData, loading: haLoading, refresh: refreshHA } = useHA(15000);
const weather = useWeather();
const ROOMS = [
{ id: 'living', name: 'Гостиная', emoji: '🛋️', deviceCount: 3 },
{ id: 'bedroom', name: 'Спальня', emoji: '🛏️', deviceCount: 2 },
{ id: 'kitchen', name: 'Кухня', emoji: '🍳', deviceCount: 0 },
{ id: 'bathroom', name: 'Ванная', emoji: '🚿', deviceCount: 0 },
]
// Apply theme to html element
const DEVICES_BY_ROOM: Record<string, {
id: string
name: string
icon: string
entityId?: string
domain?: string
haKey?: string
isMock?: boolean
}[]> = {
living: [
{
id: 'air_purifier',
name: 'Очиститель воздуха',
icon: '💨',
entityId: 'fan.zhimi_rmb1_9528_air_purifier',
domain: 'fan',
haKey: 'fan.air_purifier',
isMock: false,
},
{
id: 'light_living',
name: 'Свет',
icon: '💡',
entityId: 'light.living_room',
domain: 'light',
haKey: 'light.living_room',
isMock: true,
},
{
id: 'tv',
name: 'Телевизор',
icon: '📺',
isMock: true,
},
],
bedroom: [
{
id: 'light_bedroom',
name: 'Свет',
icon: '💡',
entityId: 'light.bedroom',
domain: 'light',
haKey: 'light.bedroom',
isMock: true,
},
{
id: 'ac',
name: 'Кондиционер',
icon: '❄️',
isMock: true,
},
],
kitchen: [],
bathroom: [],
}
export default function HomePage() {
const [tab, setTab] = useState<Tab>('home')
const [activeRoom, setActiveRoom] = useState('living')
const [weather, setWeather] = useState<WeatherData | null>(null)
const [sensors, setSensors] = useState<SensorData | null>(null)
const [haStates, setHaStates] = useState<HaStates>({})
// Load weather
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");
const load = async () => {
try {
const r = await fetch('/api/weather')
const d = await r.json()
if (d.temp && d.temp !== '—') setWeather(d)
} catch {}
}
}, [isDark]);
load()
const t = setInterval(load, 600_000)
return () => clearInterval(t)
}, [])
const states = haData?.states || {};
const isDemo = haData?.demo || false;
// Load HA states + sensors
const loadHA = useCallback(async () => {
try {
const r = await fetch('/api/ha')
const d = await r.json()
if (d.states) setHaStates(d.states)
if (d.sensors) setSensors(d.sensors)
} catch {}
}, [])
const handleHAUpdate = useCallback(() => {
setTimeout(refreshHA, 500);
}, [refreshHA]);
useEffect(() => {
loadHA()
const t = setInterval(loadHA, 30_000)
return () => clearInterval(t)
}, [loadHA])
const handleRoomClick = useCallback((roomId: string) => {
setActiveTab("devices");
setRoomFilter(roomId);
}, []);
const devicesInRoom = DEVICES_BY_ROOM[activeRoom] || []
const livingRoom = states["light.living_room"];
const bedroom = states["light.bedroom"];
const thermostat = states["climate.thermostat"];
const airPurifier = states["fan.air_purifier"];
const getDeviceState = (haKey?: string): boolean => {
if (!haKey || !haStates[haKey]) return false
return haStates[haKey].state === 'on'
}
const getDeviceExtra = (id: string): string | undefined => {
if (id === 'air_purifier' && sensors) {
return `PM2.5: ${sensors.pm25}`
}
return undefined
}
return (
<div
className="relative w-screen overflow-hidden"
style={{ height: "100dvh", background: "var(--bg)" }}
style={{
display: 'flex',
height: '100dvh',
width: '100%',
background: 'var(--bg)',
overflow: 'hidden',
}}
>
{/* 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",
}}
/>
<Sidebar active={tab} onChange={setTab} />
{/* 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}
/>
<main
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
minWidth: 0,
}}
>
<TopBar weather={weather} sensors={sensors} />
{/* 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 }}
{tab === 'home' && (
<>
<RoomTabs rooms={ROOMS} active={activeRoom} onChange={setActiveRoom} />
<div
style={{
flex: 1,
overflowY: 'auto',
WebkitOverflowScrolling: 'touch' as any,
padding: '16px 20px 24px',
}}
>
<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] [Погода]
*/}
{devicesInRoom.length === 0 ? (
<div
className="grid gap-3"
style={{
gridTemplateColumns: "1fr 1fr 1fr",
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: 200,
color: 'var(--text-secondary)',
gap: 8,
}}
>
{/* Свет Гостиная */}
<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>
<span style={{ fontSize: 40 }}>🏠</span>
<span style={{ fontSize: 15 }}>Устройства не добавлены</span>
</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",
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 12,
}}
>
<motion.div variants={cardVariants}>
<LightCard
entityId="light.living_room"
name="Гостиная"
state={livingRoom?.state || "off"}
brightness={livingRoom?.attributes?.brightness}
showSlider={true}
onUpdate={handleHAUpdate}
{devicesInRoom.map(device => (
<DeviceCard
key={device.id}
id={device.id}
name={device.name}
icon={device.icon}
entityId={device.entityId}
domain={device.domain}
initialState={getDeviceState(device.haKey)}
isMock={device.isMock}
extraInfo={getDeviceExtra(device.id)}
/>
</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>
)}
)}
</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>
{tab === 'rooms' && (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
color: 'var(--text-secondary)',
}}
>
<span style={{ fontSize: 48 }}>🏠</span>
<span style={{ fontSize: 16 }}>Управление комнатами</span>
<span style={{ fontSize: 13 }}>Скоро</span>
</div>
)}
<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>
{tab === 'sensors' && sensors && (
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '20px',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
<h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>Датчики</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12 }}>
{[
{ label: 'Температура', value: `${sensors.temperature}°C`, icon: '🌡️' },
{ label: 'Влажность', value: `${sensors.humidity}%`, icon: '💧' },
{ label: 'PM2.5', value: `${sensors.pm25} μg/m³`, icon: '💨' },
].map(s => (
<div
key={s.label}
style={{
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
borderRadius: 18,
padding: 20,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<span style={{ fontSize: 28 }}>{s.icon}</span>
<div style={{ fontSize: 22, fontWeight: 700 }}>{s.value}</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{s.label}</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
</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>
{tab === 'sensors' && !sensors && (
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-secondary)',
}}
>
Загрузка датчиков...
</div>
)}
{tab === 'settings' && (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
color: 'var(--text-secondary)',
}}
>
<span style={{ fontSize: 48 }}></span>
<span style={{ fontSize: 16 }}>Настройки</span>
<span style={{ fontSize: 13 }}>Скоро</span>
</div>
)}
</main>
</div>
);
)
}