feat: full redesign - sidebar layout, room tabs, device cards
All checks were successful
Deploy to Coolify / deploy (push) Successful in 3s
All checks were successful
Deploy to Coolify / deploy (push) Successful in 3s
This commit is contained in:
267
app/globals.css
267
app/globals.css
@@ -1,250 +1,65 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--bg: #090912;
|
||||
--card-bg: rgba(255, 255, 255, 0.04);
|
||||
--card-border: rgba(255, 255, 255, 0.08);
|
||||
--text-primary: rgba(255, 255, 255, 0.95);
|
||||
--text-secondary: rgba(255, 255, 255, 0.45);
|
||||
--accent: #6366f1;
|
||||
--accent-2: #8b5cf6;
|
||||
}
|
||||
|
||||
.light {
|
||||
--bg: #f0f0f8;
|
||||
--card-bg: rgba(255, 255, 255, 0.75);
|
||||
--card-border: rgba(0, 0, 0, 0.07);
|
||||
--text-primary: rgba(15, 15, 30, 0.95);
|
||||
--text-secondary: rgba(15, 15, 30, 0.45);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
:root {
|
||||
--bg: #0a0a14;
|
||||
--sidebar-bg: rgba(255, 255, 255, 0.02);
|
||||
--card-bg: rgba(255, 255, 255, 0.05);
|
||||
--card-border: rgba(255, 255, 255, 0.08);
|
||||
--text-primary: rgba(255, 255, 255, 0.92);
|
||||
--text-secondary: rgba(255, 255, 255, 0.45);
|
||||
--accent: #00d4ff;
|
||||
--accent-glow: rgba(0, 212, 255, 0.15);
|
||||
--on-color: #00d4ff;
|
||||
--off-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: var(--bg);
|
||||
color: var(--text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', system-ui, sans-serif;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
touch-action: manipulation;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#__next, main {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Glassmorphism card */
|
||||
.glass-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-radius: 24px;
|
||||
transition: background 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(99, 102, 241, 0.35);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Big toggle switch (60×32) */
|
||||
.toggle-track {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.toggle-on .toggle-thumb {
|
||||
transform: translateX(28px);
|
||||
}
|
||||
|
||||
/* Custom range slider */
|
||||
input[type='range'] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.light input[type='range'] {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 10px rgba(245, 158, 11, 0.7);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
input[type='range']::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
input[type='range']::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 0 10px rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
|
||||
/* Ambient orbs */
|
||||
.orb {
|
||||
position: fixed;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* No select on interactive elements in tablet mode */
|
||||
.no-select {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar {
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.light .progress-bar {
|
||||
background: rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||
transition: width 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/* Glow effects */
|
||||
.glow-amber {
|
||||
box-shadow: 0 0 24px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.glow-blue {
|
||||
box-shadow: 0 0 24px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.glow-green {
|
||||
box-shadow: 0 0 24px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.glow-purple {
|
||||
box-shadow: 0 0 24px rgba(139, 92, 246, 0.4);
|
||||
}
|
||||
|
||||
/* Modal backdrop */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Spin animation for air purifier */
|
||||
@keyframes spin-slow {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spin-slow {
|
||||
animation: spin-slow 4s linear infinite;
|
||||
}
|
||||
|
||||
/* Orb animations */
|
||||
@keyframes orbMove1 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(40px, -30px) scale(1.05); }
|
||||
66% { transform: translate(-20px, 20px) scale(0.97); }
|
||||
}
|
||||
@keyframes orbMove2 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
33% { transform: translate(-50px, 30px) scale(1.08); }
|
||||
66% { transform: translate(30px, -20px) scale(0.95); }
|
||||
}
|
||||
@keyframes orbMove3 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(20px, 40px) scale(1.04); }
|
||||
}
|
||||
@keyframes orbMove4 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(-30px, -20px) scale(1.06); }
|
||||
}
|
||||
|
||||
/* Hide scrollbar but allow scrolling */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Safe area support for tablets/mobile */
|
||||
@supports (padding: max(0px)) {
|
||||
body {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure 100dvh works properly */
|
||||
.h-dvh {
|
||||
height: 100dvh;
|
||||
height: 100svh;
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
600
app/page.tsx
600
app/page.tsx
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user