From 311ae1dc4b34e4f1524036d1d9a93fbab7ff3fa1 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Wed, 22 Apr 2026 11:05:41 +0000 Subject: [PATCH] feat: full redesign - sidebar layout, room tabs, device cards --- app/globals.css | 267 ++--------- app/page.tsx | 600 +++++++++++------------- components/AddTaskModal.tsx | 108 ----- components/BottomNav.tsx | 57 --- components/DeviceCard.tsx | 141 ++++++ components/RoomTabs.tsx | 73 +++ components/RoomsRow.tsx | 77 --- components/Sidebar.tsx | 84 ++++ components/ThemeToggle.tsx | 43 -- components/TopBar.tsx | 368 ++++++++------- components/cards/AirPurifierCard.tsx | 141 ------ components/cards/LightCard.tsx | 134 ------ components/cards/SavingsCard.tsx | 140 ------ components/cards/TasksCard.tsx | 185 -------- components/cards/TemperatureCard.tsx | 158 ------- components/cards/WeatherCard.tsx | 174 ------- components/cards/WeatherSavingsCard.tsx | 85 ---- 17 files changed, 819 insertions(+), 2016 deletions(-) delete mode 100644 components/AddTaskModal.tsx delete mode 100644 components/BottomNav.tsx create mode 100644 components/DeviceCard.tsx create mode 100644 components/RoomTabs.tsx delete mode 100644 components/RoomsRow.tsx create mode 100644 components/Sidebar.tsx delete mode 100644 components/ThemeToggle.tsx delete mode 100644 components/cards/AirPurifierCard.tsx delete mode 100644 components/cards/LightCard.tsx delete mode 100644 components/cards/SavingsCard.tsx delete mode 100644 components/cards/TasksCard.tsx delete mode 100644 components/cards/TemperatureCard.tsx delete mode 100644 components/cards/WeatherCard.tsx delete mode 100644 components/cards/WeatherSavingsCard.tsx diff --git a/app/globals.css b/app/globals.css index d649b6d..a2aa989 100644 --- a/app/globals.css +++ b/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); } diff --git a/app/page.tsx b/app/page.tsx index 94ab9a3..1af7451 100644 --- a/app/page.tsx +++ b/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(null); +interface HaStates { + [key: string]: { state: string; attributes?: Record; _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 = { + 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('home') + const [activeRoom, setActiveRoom] = useState('living') + const [weather, setWeather] = useState(null) + const [sensors, setSensors] = useState(null) + const [haStates, setHaStates] = useState({}) + + // 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 (
- {/* Ambient orbs */} -
-
-
+ - {/* Main layout — flex column fills 100dvh */} -
- {/* Top bar */} - setIsDark(!isDark)} - weather={weather} - isDemo={isDemo} - /> +
+ - {/* Rooms row — only on home tab */} - - {activeTab === "home" && ( - + + +
- - - )} - - - {/* Content area — fills remaining space */} -
- - - {/* ═══════════════ HOME TAB ═══════════════ */} - {activeTab === "home" && ( - - {/* - Row 1: [Свет Гостиная] [Свет Спальня] [Климат] - Row 2: [Очиститель ×2] [Погода] - */} + {devicesInRoom.length === 0 ? (
- {/* Свет Гостиная */} - - - - - {/* Свет Спальня */} - - - - - {/* Термостат */} - - - - - {/* Очиститель воздуха — 2 колонки */} - - - - - {/* Погода — правый нижний */} - - - + 🏠 + Устройства не добавлены
-
- )} - - {/* ═══════════════ DEVICES TAB ═══════════════ */} - {activeTab === "devices" && ( - - {/* Room filter pills */} - {roomFilter && ( - - - Фильтр: - - - - )} + ) : (
- - ( + - - - - - - - - - - - - - + ))}
-
- )} + )} +
+ + )} - {/* ═══════════════ SETTINGS TAB ═══════════════ */} - {activeTab === "settings" && ( - -
-
⚙️
-

- Настройки -

-

- Умный дом подключён. Когда появятся устройства — они появятся автоматически. -

+ {tab === 'rooms' && ( +
+ 🏠 + Управление комнатами + Скоро +
+ )} -
- {[ - { label: "HA URL", value: "✅ Настроен" }, - { label: "HA Token", value: isDemo ? "❌ Не настроен" : "✅ Настроен" }, - { label: "Vikunja", value: "✅ Подключён" }, - { label: "Pulse API", value: "✅ Подключён" }, - { label: "Очиститель", value: (airPurifier as any)?._mock ? "⚡ Демо" : "✅ Реальный" }, - ].map((item) => ( -
- - {item.label} - - - {item.value} - -
- ))} -
+ {tab === 'sensors' && sensors && ( +
+

Датчики

+
+ {[ + { label: 'Температура', value: `${sensors.temperature}°C`, icon: '🌡️' }, + { label: 'Влажность', value: `${sensors.humidity}%`, icon: '💧' }, + { label: 'PM2.5', value: `${sensors.pm25} μg/m³`, icon: '💨' }, + ].map(s => ( +
+ {s.icon} +
{s.value}
+
{s.label}
- - )} - -
+ ))} +
+
+ )} - {/* Bottom nav — flex-shrink-0, always visible */} -
- -
-
+ {tab === 'sensors' && !sensors && ( +
+ Загрузка датчиков... +
+ )} + + {tab === 'settings' && ( +
+ ⚙️ + Настройки + Скоро +
+ )} +
- ); + ) } diff --git a/components/AddTaskModal.tsx b/components/AddTaskModal.tsx deleted file mode 100644 index c21cfbd..0000000 --- a/components/AddTaskModal.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { X, Plus } from "lucide-react"; - -interface Props { - open: boolean; - onClose: () => void; - onAdd: (title: string) => void; -} - -export default function AddTaskModal({ open, onClose, onAdd }: Props) { - const [title, setTitle] = useState(""); - - const handleSubmit = () => { - if (!title.trim()) return; - onAdd(title.trim()); - setTitle(""); - onClose(); - }; - - return ( - - {open && ( - - e.stopPropagation()} - > -
-

- Новая задача -

- - - -
- - setTitle(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSubmit()} - placeholder="Название задачи..." - autoFocus - className="w-full px-4 py-3 rounded-xl text-sm outline-none mb-4" - style={{ - background: "rgba(255,255,255,0.06)", - border: "1px solid rgba(255,255,255,0.1)", - color: "var(--text-primary)", - }} - /> - -
- - Отмена - - - - Добавить - -
-
-
- )} -
- ); -} diff --git a/components/BottomNav.tsx b/components/BottomNav.tsx deleted file mode 100644 index 234ec2f..0000000 --- a/components/BottomNav.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; -import { Home, Cpu, Settings } from "lucide-react"; - -interface Props { - active: string; - onChange: (tab: string) => void; -} - -const TABS = [ - { id: "home", label: "Главная", icon: Home, color: "#6366f1" }, - { id: "devices", label: "Устройства", icon: Cpu, color: "#3b82f6" }, - { id: "settings", label: "Настройки", icon: Settings, color: "#10b981" }, -]; - -export default function BottomNav({ active, onChange }: Props) { - return ( - - {TABS.map((tab) => { - const Icon = tab.icon; - const isActive = active === tab.id; - return ( - onChange(tab.id)} - className="flex flex-col items-center gap-1.5 px-10 py-2 rounded-2xl relative" - whileTap={{ scale: 0.85 }} - > - {isActive && ( - - )} - - - {tab.label} - - - ); - })} - - ); -} diff --git a/components/DeviceCard.tsx b/components/DeviceCard.tsx new file mode 100644 index 0000000..b69562e --- /dev/null +++ b/components/DeviceCard.tsx @@ -0,0 +1,141 @@ +'use client' + +import { useState } from 'react' + +interface DeviceCardProps { + id: string + name: string + icon: string + entityId?: string + domain?: string + initialState: boolean + isMock?: boolean + extraInfo?: string +} + +export default function DeviceCard({ + id, + name, + icon, + entityId, + domain, + initialState, + isMock = false, + extraInfo, +}: DeviceCardProps) { + const [isOn, setIsOn] = useState(initialState) + const [loading, setLoading] = useState(false) + + const toggle = async () => { + const next = !isOn + setIsOn(next) // optimistic update + + if (!isMock && entityId && domain) { + setLoading(true) + try { + await fetch('/api/ha', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + domain, + service: next ? 'turn_on' : 'turn_off', + entity_id: entityId, + }), + }) + } catch { + // revert on error + setIsOn(!next) + } finally { + setLoading(false) + } + } + } + + return ( +
+ {/* Top row: icon + toggle */} +
+
+ {icon} +
+ + {/* Toggle button */} + +
+ + {/* Bottom: name + status */} +
+
+ {name} +
+
+ {isOn ? 'Включён' : 'Выключен'} + {extraInfo && isOn ? ` · ${extraInfo}` : ''} +
+
+
+ ) +} diff --git a/components/RoomTabs.tsx b/components/RoomTabs.tsx new file mode 100644 index 0000000..def472b --- /dev/null +++ b/components/RoomTabs.tsx @@ -0,0 +1,73 @@ +'use client' + +interface Room { + id: string + name: string + emoji: string + deviceCount: number +} + +interface RoomTabsProps { + rooms: Room[] + active: string + onChange: (id: string) => void +} + +export default function RoomTabs({ rooms, active, onChange }: RoomTabsProps) { + return ( +
+ {rooms.map(room => { + const isActive = active === room.id + return ( + + ) + })} +
+ ) +} diff --git a/components/RoomsRow.tsx b/components/RoomsRow.tsx deleted file mode 100644 index e2ee7d6..0000000 --- a/components/RoomsRow.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; - -interface Room { - id: string; - emoji: string; - name: string; - deviceCount: number; - color: string; -} - -const ROOMS: Room[] = [ - { id: "living", emoji: "🛋️", name: "Гостиная", deviceCount: 3, color: "#6366f1" }, - { id: "bedroom", emoji: "🛏️", name: "Спальня", deviceCount: 2, color: "#8b5cf6" }, - { id: "kitchen", emoji: "🍳", name: "Кухня", deviceCount: 1, color: "#f59e0b" }, - { id: "bathroom", emoji: "🚿", name: "Ванная", deviceCount: 0, color: "#06b6d4" }, -]; - -interface Props { - onRoomClick?: (roomId: string) => void; -} - -export default function RoomsRow({ onRoomClick }: Props) { - return ( -
- {ROOMS.map((room, i) => ( - onRoomClick?.(room.id)} - className="flex-shrink-0 glass-card flex items-center gap-3 px-4 py-3 rounded-2xl cursor-pointer" - style={{ - minWidth: "140px", - border: `1px solid ${room.color}22`, - }} - initial={{ opacity: 0, x: -10 }} - animate={{ opacity: 1, x: 0 }} - transition={{ duration: 0.3, delay: i * 0.05 }} - whileTap={{ scale: 0.93 }} - whileHover={{ scale: 1.02 }} - > -
- {room.emoji} -
-
-
- {room.name} -
-
- {room.deviceCount}{" "} - {room.deviceCount === 1 - ? "устройство" - : room.deviceCount >= 2 && room.deviceCount <= 4 - ? "устройства" - : "устройств"} -
-
- {room.deviceCount > 0 && ( -
- )} - - ))} -
- ); -} diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx new file mode 100644 index 0000000..9560fde --- /dev/null +++ b/components/Sidebar.tsx @@ -0,0 +1,84 @@ +'use client' + +import { Home, LayoutGrid, Thermometer, Settings } from 'lucide-react' + +type Tab = 'home' | 'rooms' | 'sensors' | 'settings' + +interface SidebarProps { + active: Tab + onChange: (tab: Tab) => void +} + +const navItems: { id: Tab; icon: React.ComponentType<{ size?: number; color?: string }> }[] = [ + { id: 'home', icon: Home }, + { id: 'rooms', icon: LayoutGrid }, + { id: 'sensors', icon: Thermometer }, + { id: 'settings', icon: Settings }, +] + +export default function Sidebar({ active, onChange }: SidebarProps) { + return ( + + ) +} diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx deleted file mode 100644 index 1734f6e..0000000 --- a/components/ThemeToggle.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; -import { Sun, Moon } from "lucide-react"; - -interface Props { - isDark: boolean; - onToggle: () => void; -} - -export default function ThemeToggle({ isDark, onToggle }: Props) { - return ( - - - {isDark ? ( - - ) : ( - - )} - - - ); -} diff --git a/components/TopBar.tsx b/components/TopBar.tsx index ac54736..12dbe2b 100644 --- a/components/TopBar.tsx +++ b/components/TopBar.tsx @@ -1,187 +1,229 @@ -"use client"; +'use client' -import { useState, useEffect } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import ThemeToggle from "./ThemeToggle"; +import { useState, useEffect } from 'react' +import { Cloud, Droplets, Wind } from 'lucide-react' -function getWeatherEmoji(code: string): string { - const c = parseInt(code); - if (c === 113) return "☀️"; - if (c === 116) return "⛅"; - if (c === 119 || c === 122) return "☁️"; - if (c >= 176 && c <= 300) return "🌧️"; - if (c >= 200 && c <= 210) return "⛈️"; - if (c >= 210 && c <= 260) return "❄️"; - return "🌤️"; +interface WeatherData { + temp: string + desc: string + humidity: string + windSpeed: string + feelsLike: string + forecast?: { date: string; maxTemp: string; minTemp: string; desc: string }[] } -function getDayName(dateStr: string): string { - const d = new Date(dateStr + "T12:00:00"); - const today = new Date(); - const tomorrow = new Date(today); - tomorrow.setDate(today.getDate() + 1); - if (d.toDateString() === today.toDateString()) return "Сег"; - if (d.toDateString() === tomorrow.toDateString()) return "Завт"; - return d.toLocaleDateString("ru-RU", { weekday: "short" }); +interface SensorData { + temperature: number + humidity: number + pm25: number } -interface Props { - isDark: boolean; - onToggleTheme: () => void; - weather: any; - isDemo?: boolean; +interface TopBarProps { + weather: WeatherData | null + sensors: SensorData | null } -export default function TopBar({ isDark, onToggleTheme, weather, isDemo }: Props) { - const [time, setTime] = useState(""); - const [date, setDate] = useState(""); - const [showModal, setShowModal] = useState(false); +function getWeatherEmoji(desc: string): string { + const d = desc?.toLowerCase() || '' + if (d.includes('ясно') || d.includes('солнеч')) return '☀️' + if (d.includes('облач')) return '⛅' + if (d.includes('пасмурн')) return '☁️' + if (d.includes('дождь') || d.includes('морос')) return '🌧️' + if (d.includes('снег')) return '❄️' + if (d.includes('гроз')) return '⛈️' + return '🌤️' +} + +function formatTime(date: Date): string { + return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) +} + +function formatDate(date: Date): string { + return date.toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'short' }) +} + +export default function TopBar({ weather, sensors }: TopBarProps) { + const [time, setTime] = useState(() => new Date()) + const [showModal, setShowModal] = useState(false) useEffect(() => { - const update = () => { - const now = new Date(); - setTime(now.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })); - setDate(now.toLocaleDateString("ru-RU", { weekday: "short", day: "numeric", month: "long" })); - }; - update(); - const id = setInterval(update, 1000); - return () => clearInterval(id); - }, []); - - const hasWeather = weather && weather.temp && weather.temp !== "—"; + const t = setInterval(() => setTime(new Date()), 1000) + return () => clearInterval(t) + }, []) return ( <> - - {/* Time & Date */} -
- - {time} + {/* Left: time + date */} +
+ + {formatTime(time)} - - {date} + + {formatDate(time)}
- {/* Weather pill — clickable */} - {hasWeather ? ( - setShowModal(true)} - > - {getWeatherEmoji(weather.weatherCode)} -
-
{weather.temp}°C
-
{weather.desc}
-
- -
- ) : ( -
- 🌤️ {weather ? "Загрузка..." : "—"} -
- )} - - {/* Theme toggle + Demo badge */} -
- {isDemo && ( - - Demo - - )} - -
- - - {/* Weather Modal */} - - {showModal && weather && ( - setShowModal(false)} - > - e.stopPropagation()} - > - {/* Header */} -
-

🌍 Санкт-Петербург

- + {/* Right: sensors + weather */} +
+ {/* Room sensors */} + {sensors && ( +
+
+ 🌡️ + + {sensors.temperature}° +
- - {/* Current */} -
- {getWeatherEmoji(weather.weatherCode)} -
-
- {weather.temp}° -
-
{weather.desc}
-
- {weather.feelsLike && weather.feelsLike !== "—" && Ощущается {weather.feelsLike}°} - {weather.humidity && weather.humidity !== "—" && 💧 {weather.humidity}%} - {weather.windSpeed && weather.windSpeed !== "—" && 💨 {weather.windSpeed} км/ч} -
-
+
+ + + {sensors.humidity}% +
- - {/* Forecast */} - {weather.forecast && weather.forecast.length > 0 && ( -
-
- Прогноз -
-
- {weather.forecast.map((day: any, i: number) => ( -
- - {getDayName(day.date)} - - {getWeatherEmoji(day.weatherCode)} - {day.desc} - - {day.maxTemp}° / {day.minTemp}° - -
- ))} -
+ {sensors.pm25 !== undefined && ( +
+ + + PM2.5: {sensors.pm25} +
)} - - - )} - +
+ )} + + {/* Divider */} + {sensors && weather && ( +
+ )} + + {/* Weather widget */} + {weather && ( + + )} +
+ + + {/* Weather Modal */} + {showModal && weather && ( +
setShowModal(false)} + style={{ + position: 'fixed', + inset: 0, + background: 'rgba(0,0,0,0.7)', + backdropFilter: 'blur(8px)', + zIndex: 100, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > +
e.stopPropagation()} + style={{ + background: '#13131f', + border: '1px solid rgba(255,255,255,0.1)', + borderRadius: 20, + padding: 28, + minWidth: 320, + maxWidth: 400, + }} + > +
+ {getWeatherEmoji(weather.desc)} +
+
{weather.temp}°C
+
{weather.desc}
+
+
+ +
+
+ + Влажность: {weather.humidity}% +
+
+ + Ветер: {weather.windSpeed} км/ч +
+
+ Ощущается: {weather.feelsLike}° +
+
+ + {weather.forecast && weather.forecast.length > 0 && ( +
+
+ Прогноз +
+
+ {weather.forecast.map(day => { + const d = new Date(day.date) + const label = d.toLocaleDateString('ru-RU', { weekday: 'short', day: 'numeric', month: 'short' }) + return ( +
+ {label} + {getWeatherEmoji(day.desc)} + {day.desc} + + {day.maxTemp}° / {day.minTemp}° + +
+ ) + })} +
+
+ )} + + +
+
+ )} - ); + ) } diff --git a/components/cards/AirPurifierCard.tsx b/components/cards/AirPurifierCard.tsx deleted file mode 100644 index b46c3f3..0000000 --- a/components/cards/AirPurifierCard.tsx +++ /dev/null @@ -1,141 +0,0 @@ -"use client"; - -import { useState, useCallback } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { Wind } from "lucide-react"; -import { toggleFan, setFanPreset } from "@/lib/api"; - -interface Props { - entityId: string; - state: string; - presetMode?: string; - onUpdate: () => void; -} - -const MODES = [ - { id: "Auto", label: "Авто", color: "#10b981" }, - { id: "Night", label: "Ночь", color: "#6366f1" }, - { id: "High", label: "Турбо", color: "#f59e0b" }, -]; - -export default function AirPurifierCard({ entityId, state, presetMode, onUpdate }: Props) { - const [localOn, setLocalOn] = useState(state === "on"); - const [currentMode, setCurrentMode] = useState(presetMode || "Auto"); - const [pending, setPending] = useState(false); - - const handleToggle = useCallback(async () => { - if (pending) return; - const newState = !localOn; - setLocalOn(newState); - setPending(true); - try { - await toggleFan(entityId, newState); - onUpdate(); - } catch (e) { - setLocalOn(!newState); - } - setPending(false); - }, [entityId, localOn, pending, onUpdate]); - - const handleMode = useCallback(async (mode: string) => { - setCurrentMode(mode); - await setFanPreset(entityId, mode); - onUpdate(); - }, [entityId, onUpdate]); - - const isOn = localOn; - const activeMode = MODES.find((m) => m.id === currentMode) || MODES[0]; - const accentColor = isOn ? activeMode.color : "rgba(255,255,255,0.3)"; - - return ( - - {/* Top row */} -
-
- - - -
- -
-
Очиститель
-
- {isOn ? activeMode.label : "Выключен"} -
-
- - {/* Toggle — native button */} - -
- - {/* Mode buttons */} -
- {MODES.map((mode) => { - const isActive = currentMode === mode.id && isOn; - return ( - - ); - })} -
-
- ); -} diff --git a/components/cards/LightCard.tsx b/components/cards/LightCard.tsx deleted file mode 100644 index 73fe924..0000000 --- a/components/cards/LightCard.tsx +++ /dev/null @@ -1,134 +0,0 @@ -"use client"; - -import { useState, useCallback } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { Lightbulb } from "lucide-react"; -import { toggleLight, setLightBrightness } from "@/lib/api"; -import { getBrightnessPct, pctToBrightness } from "@/lib/ha"; - -interface Props { - entityId: string; - name: string; - state: string; - brightness?: number; - showSlider?: boolean; - onUpdate: () => void; -} - -export default function LightCard({ entityId, name, state, brightness, showSlider = false, onUpdate }: Props) { - // Optimistic local state — не зависим от HA mock - const [localOn, setLocalOn] = useState(state === "on"); - const brightPct = getBrightnessPct(brightness); - const [localBrightness, setLocalBrightness] = useState(brightPct || 70); - const [pending, setPending] = useState(false); - - const handleToggle = useCallback(async () => { - if (pending) return; - const newState = !localOn; - setLocalOn(newState); // optimistic - setPending(true); - try { - await toggleLight(entityId, newState); - onUpdate(); - } catch (e) { - setLocalOn(!newState); // rollback on real error - } - setPending(false); - }, [entityId, localOn, pending, onUpdate]); - - const handleBrightnessChange = useCallback(async (val: number) => { - setLocalBrightness(val); - await setLightBrightness(entityId, pctToBrightness(val)); - onUpdate(); - }, [entityId, onUpdate]); - - const isOn = localOn; - - return ( - - {/* Top row: icon + toggle */} -
- - - - - {/* Toggle switch */} - -
- - {/* Name + state */} -
-
- {name} -
-
- {isOn ? (showSlider ? `Яркость ${localBrightness}%` : "Включён") : "Выключен"} -
- - - {showSlider && isOn && ( - - setLocalBrightness(parseInt(e.target.value))} - onMouseUp={(e) => handleBrightnessChange(parseInt((e.target as HTMLInputElement).value))} - onTouchEnd={(e) => handleBrightnessChange(parseInt((e.target as HTMLInputElement).value))} - className="w-full" - style={{ accentColor: "#f59e0b" }} - /> - - )} - -
-
- ); -} diff --git a/components/cards/SavingsCard.tsx b/components/cards/SavingsCard.tsx deleted file mode 100644 index ce17025..0000000 --- a/components/cards/SavingsCard.tsx +++ /dev/null @@ -1,140 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; -import { Target } from "lucide-react"; - -interface Saving { - id: number; - name: string; - current_amount: number; - target_amount: number; - color?: string; - icon?: string; -} - -interface Props { - savings: Saving[]; -} - -function formatAmount(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}М`; - if (n >= 1_000) return `${Math.round(n / 1_000)}К`; - return String(n); -} - -const COLORS = ["#f59e0b", "#3b82f6", "#10b981", "#8b5cf6", "#f43f5e"]; - -export default function SavingsCard({ savings }: Props) { - return ( - - {/* Header */} -
-
- -
-
-
- Накопления -
-
- {savings.length} {savings.length === 1 ? "цель" : "целей"} -
-
-
- -
- {savings.map((s, i) => { - const pct = Math.min( - 100, - Math.round((s.current_amount / s.target_amount) * 100) - ); - const color = s.color || COLORS[i % COLORS.length]; - - return ( - -
-
- {s.icon && {s.icon}} - - {s.name} - -
- - {pct}% - -
- - {/* Progress bar with glow */} -
- -
- -
- {formatAmount(s.current_amount)} ₽ - из {formatAmount(s.target_amount)} ₽ -
-
- ); - })} - - {savings.length === 0 && ( -
- Нет данных о накоплениях -
- )} -
-
- ); -} diff --git a/components/cards/TasksCard.tsx b/components/cards/TasksCard.tsx deleted file mode 100644 index e7f2831..0000000 --- a/components/cards/TasksCard.tsx +++ /dev/null @@ -1,185 +0,0 @@ -"use client"; - -import { useState, useCallback } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { CheckCircle2, Circle, Plus, ListTodo } from "lucide-react"; -import { createTask, toggleTask } from "@/lib/api"; -import AddTaskModal from "../AddTaskModal"; - -interface Task { - id: number; - title: string; - done: boolean; - priority?: number; -} - -interface Props { - tasks: Task[]; - onUpdate: () => void; -} - -export default function TasksCard({ tasks, onUpdate }: Props) { - const [modalOpen, setModalOpen] = useState(false); - const [localTasks, setLocalTasks] = useState(tasks); - - const handleToggle = useCallback( - async (task: Task) => { - setLocalTasks((prev) => - prev.map((t) => (t.id === task.id ? { ...t, done: !t.done } : t)) - ); - await toggleTask(task.id, !task.done); - onUpdate(); - }, - [onUpdate] - ); - - const handleAdd = useCallback( - async (title: string) => { - const newTask = { id: Date.now(), title, done: false }; - setLocalTasks((prev) => [newTask, ...prev]); - await createTask(title); - onUpdate(); - }, - [onUpdate] - ); - - const pending = localTasks.filter((t) => !t.done); - const done = localTasks.filter((t) => t.done); - - return ( - <> - - {/* Header */} -
-
-
- -
-
-
- Задачи -
-
- {pending.length > 0 - ? `${pending.length} из ${localTasks.length} осталось` - : "Всё готово!"} -
-
-
- - {/* Add button */} - setModalOpen(true)} - className="w-10 h-10 rounded-xl flex items-center justify-center" - style={{ - background: "linear-gradient(135deg, #8b5cf6, #6366f1)", - boxShadow: "0 0 18px rgba(139,92,246,0.45)", - }} - whileTap={{ scale: 0.82 }} - > - - -
- - {/* Task list */} -
- - {localTasks.length === 0 && ( - -
🎉
-
- Всё сделано на сегодня! -
- setModalOpen(true)} - className="mt-2 px-5 py-2.5 rounded-xl text-sm font-semibold" - style={{ - background: "linear-gradient(135deg, rgba(139,92,246,0.25), rgba(99,102,241,0.2))", - border: "1px solid rgba(139,92,246,0.35)", - color: "#8b5cf6", - }} - whileTap={{ scale: 0.9 }} - > - + Добавить задачу - -
- )} - - {localTasks.map((task) => ( - handleToggle(task)} - whileTap={{ scale: 0.97 }} - > - - {task.done ? ( - - ) : ( - - )} - - - {task.title} - - - ))} -
-
-
- - setModalOpen(false)} - onAdd={handleAdd} - /> - - ); -} diff --git a/components/cards/TemperatureCard.tsx b/components/cards/TemperatureCard.tsx deleted file mode 100644 index b73fe0b..0000000 --- a/components/cards/TemperatureCard.tsx +++ /dev/null @@ -1,158 +0,0 @@ -"use client"; - -import { useState, useCallback } from "react"; -import { motion } from "framer-motion"; -import { Thermometer, Plus, Minus } from "lucide-react"; -import { setClimateTemp } from "@/lib/api"; - -interface Props { - entityId: string; - currentTemp?: number; - targetTemp?: number; - state: string; - onUpdate: () => void; -} - -export default function TemperatureCard({ - entityId, - currentTemp, - targetTemp, - state, - onUpdate, -}: Props) { - const [target, setTarget] = useState(targetTemp || 22); - const isHeating = state === "heat"; - const isActive = state !== "off"; - - const adjust = useCallback( - async (delta: number) => { - const next = Math.min(30, Math.max(16, target + delta)); - setTarget(next); - await setClimateTemp(entityId, next); - onUpdate(); - }, - [target, entityId, onUpdate] - ); - - const accentColor = isHeating ? "#f43f5e" : "#3b82f6"; - const accentBg = isHeating ? "rgba(244,63,94,0.08)" : "rgba(59,130,246,0.08)"; - const accentBorder = isHeating ? "rgba(244,63,94,0.2)" : "rgba(59,130,246,0.2)"; - const accentGlow = isHeating ? "rgba(244,63,94,0.25)" : "rgba(59,130,246,0.2)"; - - return ( - - {/* Icon row */} -
- - - - - {/* Status badge */} -
- {isHeating ? "Нагрев" : isActive ? "Охлаждение" : "Выкл"} -
-
- - {/* Current temperature — BIG */} -
-
- {currentTemp?.toFixed(1) ?? "—"}° -
-
- текущая температура -
-
- - {/* Target temperature controls */} -
- - Цель - - -
- adjust(-0.5)} - className="w-10 h-10 rounded-xl flex items-center justify-center" - style={{ - background: "rgba(255,255,255,0.08)", - border: "1px solid rgba(255,255,255,0.1)", - }} - whileTap={{ scale: 0.82 }} - > - - - - - {target}° - - - adjust(0.5)} - className="w-10 h-10 rounded-xl flex items-center justify-center" - style={{ - background: `${accentColor}22`, - border: `1px solid ${accentColor}50`, - }} - whileTap={{ scale: 0.82 }} - > - - -
-
-
- ); -} diff --git a/components/cards/WeatherCard.tsx b/components/cards/WeatherCard.tsx deleted file mode 100644 index 684f315..0000000 --- a/components/cards/WeatherCard.tsx +++ /dev/null @@ -1,174 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; -import { Droplets, Wind } from "lucide-react"; - -function getWeatherEmoji(code: string): string { - const c = parseInt(code); - if (c === 113) return "☀️"; - if (c === 116) return "⛅"; - if (c === 119 || c === 122) return "☁️"; - if (c >= 176 && c <= 182) return "🌦️"; - if (c >= 185 && c <= 200) return "🌧️"; - if (c >= 200 && c <= 210) return "⛈️"; - if (c >= 210 && c <= 260) return "❄️"; - if (c >= 260 && c <= 300) return "🌨️"; - if (c >= 300 && c <= 400) return "🌧️"; - return "🌤️"; -} - -function formatDate(dateStr: string): string { - const d = new Date(dateStr + "T12:00:00"); - return d.toLocaleDateString("ru-RU", { weekday: "short", day: "numeric" }); -} - -interface Props { - weather: any; - compact?: boolean; -} - -export default function WeatherCard({ weather, compact }: Props) { - if (!weather) { - return ( - -
🌤️
-
- Загрузка погоды... -
-
- ); - } - - const hasData = weather.temp && weather.temp !== "—"; - - if (!hasData) { - return ( - -
🌧️
-
- Нет данных о погоде -
-
- ); - } - - return ( - - {/* Location */} -
- 📍 Санкт-Петербург -
- - {/* Current weather */} -
-
- {getWeatherEmoji(weather.weatherCode)} -
-
-
- {weather.temp}° -
-
- {weather.desc} -
-
-
- - {/* Stats */} -
-
- - - {weather.humidity}% - -
-
- - - {weather.windSpeed} км/ч - -
-
- Ощущ. {weather.feelsLike}° -
-
- - {/* Forecast */} -
- {(weather.forecast || []).slice(0, 3).map((day: any, i: number) => ( - -
- {i === 0 ? "Сег." : formatDate(day.date)} -
-
- {getWeatherEmoji(day.weatherCode)} -
-
- {day.maxTemp}°/{day.minTemp}° -
-
- ))} -
-
- ); -} diff --git a/components/cards/WeatherSavingsCard.tsx b/components/cards/WeatherSavingsCard.tsx deleted file mode 100644 index a6a6458..0000000 --- a/components/cards/WeatherSavingsCard.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import WeatherCard from "./WeatherCard"; -import SavingsCard from "./SavingsCard"; - -interface Props { - weather: any; - savings: any[]; -} - -export default function WeatherSavingsCard({ weather, savings }: Props) { - const [view, setView] = useState<"weather" | "savings">("weather"); - - return ( -
- {/* Toggle pills */} -
- {[ - { id: "weather", label: "🌤 Погода" }, - { id: "savings", label: "💰 Цели" }, - ].map((tab) => ( - setView(tab.id as "weather" | "savings")} - className="flex-1 py-1.5 rounded-xl text-xs font-semibold relative" - style={{ - color: view === tab.id ? "var(--text-primary)" : "var(--text-secondary)", - }} - whileTap={{ scale: 0.95 }} - > - {view === tab.id && ( - - )} - {tab.label} - - ))} -
- - {/* Content */} -
- - {view === "weather" ? ( - - - - ) : ( - - - - )} - -
-
- ); -}