diff --git a/app/api/ha/route.ts b/app/api/ha/route.ts index d9363e7..91ac7c3 100644 --- a/app/api/ha/route.ts +++ b/app/api/ha/route.ts @@ -4,12 +4,17 @@ import { NextRequest, NextResponse } from "next/server"; const HA_URL = process.env.HA_URL || "http://192.168.31.110:8123"; const HA_TOKEN = process.env.HA_TOKEN || ""; -// Mock data for demo mode -const MOCK_STATES = { +// Real entity IDs → normalized keys used in the dashboard +const REAL_ENTITY_ALIASES: Record = { + "fan.zhimi_rmb1_9528_air_purifier": "fan.air_purifier", +}; + +// Mock data for entities that don't exist yet (квартира строится) +const MOCK_MISSING: Record = { "light.living_room": { entity_id: "light.living_room", - state: "on", - attributes: { brightness: 180, friendly_name: "Свет Гостиная" }, + state: "off", + attributes: { brightness: 0, friendly_name: "Свет Гостиная" }, }, "light.bedroom": { entity_id: "light.bedroom", @@ -18,9 +23,9 @@ const MOCK_STATES = { }, "climate.thermostat": { entity_id: "climate.thermostat", - state: "heat", + state: "off", attributes: { - current_temperature: 21.5, + current_temperature: null, temperature: 22, friendly_name: "Термостат", }, @@ -36,9 +41,16 @@ const MOCK_STATES = { }, }; +const RELEVANT_KEYS = [ + "light.living_room", + "light.bedroom", + "climate.thermostat", + "fan.air_purifier", +]; + export async function GET(req: NextRequest) { if (!HA_TOKEN) { - return NextResponse.json({ demo: true, states: MOCK_STATES }); + return NextResponse.json({ demo: true, states: MOCK_MISSING }); } try { @@ -51,25 +63,40 @@ export async function GET(req: NextRequest) { }); if (!res.ok) throw new Error(`HA responded ${res.status}`); - const states = await res.json(); - - const relevant = [ - "light.living_room", - "light.bedroom", - "climate.thermostat", - "fan.air_purifier", - ]; + const states: any[] = await res.json(); + // Build filtered map with normalized keys const filtered: Record = {}; for (const s of states) { - if (relevant.includes(s.entity_id)) { + // Direct match + if (RELEVANT_KEYS.includes(s.entity_id)) { filtered[s.entity_id] = s; } + // Alias match (real entity → normalized key) + if (REAL_ENTITY_ALIASES[s.entity_id]) { + const normalizedKey = REAL_ENTITY_ALIASES[s.entity_id]; + filtered[normalizedKey] = { + ...s, + entity_id: normalizedKey, + _real_entity_id: s.entity_id, + }; + } } - return NextResponse.json({ demo: false, states: filtered }); + // Fill in missing entities with mock data (but mark them) + for (const key of RELEVANT_KEYS) { + if (!filtered[key]) { + filtered[key] = { ...MOCK_MISSING[key], _mock: true }; + } + } + + // demo = true only if ALL entities are mock (no real HA devices connected) + const hasAnyReal = RELEVANT_KEYS.some(k => !filtered[k]?._mock); + + return NextResponse.json({ demo: !hasAnyReal, states: filtered }); } catch (e) { - return NextResponse.json({ demo: true, states: MOCK_STATES }); + // Fallback to full mock + return NextResponse.json({ demo: true, states: MOCK_MISSING }); } } @@ -80,6 +107,11 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: true, demo: true }); } + // Resolve alias: if normalized key, find real entity + const realEntityId = Object.keys(REAL_ENTITY_ALIASES).find( + k => REAL_ENTITY_ALIASES[k] === entity_id + ) || entity_id; + try { const res = await fetch(`${HA_URL}/api/services/${domain}/${service}`, { method: "POST", @@ -87,7 +119,7 @@ export async function POST(req: NextRequest) { Authorization: `Bearer ${HA_TOKEN}`, "Content-Type": "application/json", }, - body: JSON.stringify({ entity_id, ...serviceData }), + body: JSON.stringify({ entity_id: realEntityId, ...serviceData }), }); if (!res.ok) throw new Error(`HA responded ${res.status}`); diff --git a/app/api/weather/route.ts b/app/api/weather/route.ts index 9816944..8debf10 100644 --- a/app/api/weather/route.ts +++ b/app/api/weather/route.ts @@ -1,27 +1,52 @@ export const dynamic = 'force-dynamic'; import { NextResponse } from "next/server"; +function getWeatherCode(desc: string): string { + const d = desc.toLowerCase(); + if (d.includes("overcast")) return "122"; + if (d.includes("partly cloudy") || d.includes("partly") ) return "116"; + if (d.includes("cloudy") || d.includes("cloud")) return "119"; + if (d.includes("drizzle")) return "185"; + if (d.includes("rain") || d.includes("shower")) return "305"; + if (d.includes("snow") || d.includes("blizzard")) return "230"; + if (d.includes("thunder") || d.includes("storm")) return "200"; + if (d.includes("fog") || d.includes("mist")) return "248"; + if (d.includes("sunny") || d.includes("clear")) return "113"; + return "116"; +} + export async function GET() { try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + const res = await fetch( "https://wttr.in/Saint+Petersburg?format=j1", { - next: { revalidate: 600 }, - headers: { "User-Agent": "SmartHomeDashboard/1.0" }, + signal: controller.signal, + cache: "no-store", + headers: { + "User-Agent": "SmartHomeDashboard/1.0", + "Accept": "application/json", + }, } ); - if (!res.ok) throw new Error("Weather fetch failed"); + clearTimeout(timeout); + + if (!res.ok) throw new Error(`wttr responded ${res.status}`); const data = await res.json(); const current = data.current_condition[0]; - const days = data.weather.slice(0, 3).map((day: any) => { - const desc = day.hourly[4]?.weatherDesc?.[0]?.value || ""; + const days = (data.weather || []).slice(0, 3).map((day: any) => { + const hourly = day.hourly?.[4] || day.hourly?.[0] || {}; + const desc = hourly.weatherDesc?.[0]?.value || ""; + const code = hourly.weatherCode || getWeatherCode(desc); return { date: day.date, maxTemp: day.maxtempC, minTemp: day.mintempC, desc, - weatherCode: day.hourly[4]?.weatherCode || "113", + weatherCode: String(code), }; }); @@ -30,20 +55,22 @@ export async function GET() { feelsLike: current.FeelsLikeC, humidity: current.humidity, desc: current.weatherDesc[0]?.value || "", - weatherCode: current.weatherCode, + weatherCode: String(current.weatherCode), windSpeed: current.windspeedKmph, forecast: days, }); } catch (e) { + console.error("Weather fetch error:", e); return NextResponse.json( { temp: "—", feelsLike: "—", humidity: "—", desc: "Нет данных", - weatherCode: "113", + weatherCode: "116", windSpeed: "—", forecast: [], + error: String(e), }, { status: 200 } ); diff --git a/app/globals.css b/app/globals.css index 9529074..c901ec9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -226,3 +226,12 @@ input[type='range']::-moz-range-thumb { 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; +} diff --git a/app/page.tsx b/app/page.tsx index 8c36e7f..c0d7b9d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -9,9 +9,8 @@ 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 SavingsCard from "@/components/cards/SavingsCard"; -import WeatherSavingsCard from "@/components/cards/WeatherSavingsCard"; -import { useHA, useWeather, useTasks, useSavings } from "@/hooks/useHA"; +import RoomsRow from "@/components/RoomsRow"; +import { useHA, useWeather, useTasks } from "@/hooks/useHA"; // Stagger container variants const containerVariants = { @@ -27,11 +26,11 @@ const cardVariants = { export default function Home() { const [isDark, setIsDark] = useState(true); const [activeTab, setActiveTab] = useState("home"); + const [roomFilter, setRoomFilter] = useState(null); const { data: haData, loading: haLoading, refresh: refreshHA } = useHA(15000); const weather = useWeather(); const { tasks, refresh: refreshTasks } = useTasks(); - const { savings } = useSavings(); // Apply theme to html element useEffect(() => { @@ -54,6 +53,11 @@ export default function Home() { 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"]; @@ -61,8 +65,8 @@ export default function Home() { return (
{/* Ambient orbs */}
-
- {/* Main layout */} -
+ {/* Main layout — flex column fills 100dvh */} +
{/* Top bar */} setIsDark(!isDark)} weather={weather} + isDemo={isDemo} /> - {/* Demo badge */} + {/* Rooms row — only on home tab */} - {isDemo && ( + {activeTab === "home" && ( - - 🔌 Демо режим — HA Token не настроен - + )} - {/* Content area */} -
+ {/* Content area — fills remaining space */} +
+ {/* ═══════════════ HOME TAB ═══════════════ */} {activeTab === "home" && ( - {/* 3-col grid: - Row 1: [Свет] [Климат] [Задачи] - Row 2: [Воздух ×2] [Погода+Накопления] + {/* + Row 1: [Свет Гостиная] [Свет Спальня] [Климат] + Row 2: [Очиститель ×2] [Погода] */}
+ {/* Свет Спальня */} + + + + {/* Термостат */} - {/* Задачи */} - - - - {/* Очиститель воздуха — 2 колонки */} - {/* Погода + Накопления — правый нижний */} + {/* Погода — правый нижний */} - +
@@ -219,53 +213,89 @@ export default function Home() { {activeTab === "devices" && ( - - - - - - - - - - - - + {/* Room filter pills */} + {roomFilter && ( + + + Фильтр: + + + + )} +
+ + + + + + + + + + + + + + + +
)} @@ -305,15 +335,16 @@ export default function Home() { className="text-sm mb-6" style={{ color: "var(--text-secondary)" }} > - Добавь HA Token в Coolify для подключения к умному дому + Умный дом подключён. Когда появятся устройства — они появятся автоматически.

{[ - { label: "HA URL", value: "Настроен" }, + { label: "HA URL", value: "✅ Настроен" }, { label: "HA Token", value: isDemo ? "❌ Не настроен" : "✅ Настроен" }, { label: "Vikunja", value: "✅ Подключён" }, { label: "Pulse API", value: "✅ Подключён" }, + { label: "Очиститель", value: airPurifier?._mock ? "⚡ Демо" : "✅ Реальный" }, ].map((item) => (
- {/* Bottom nav */} - + {/* Bottom nav — flex-shrink-0, always visible */} +
+ +
); diff --git a/components/RoomsRow.tsx b/components/RoomsRow.tsx new file mode 100644 index 0000000..e2ee7d6 --- /dev/null +++ b/components/RoomsRow.tsx @@ -0,0 +1,77 @@ +"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/TopBar.tsx b/components/TopBar.tsx index 64d85c7..f0b63b2 100644 --- a/components/TopBar.tsx +++ b/components/TopBar.tsx @@ -22,9 +22,10 @@ interface Props { isDark: boolean; onToggleTheme: () => void; weather: any; + isDemo?: boolean; } -export default function TopBar({ isDark, onToggleTheme, weather }: Props) { +export default function TopBar({ isDark, onToggleTheme, weather, isDemo }: Props) { const [time, setTime] = useState(""); const [date, setDate] = useState(""); @@ -49,7 +50,7 @@ export default function TopBar({ isDark, onToggleTheme, weather }: Props) { return ( {/* Weather pill */} - {weather && ( + {weather && weather.temp !== "—" ? (
- )} + ) : weather ? ( +
+ 🌤️ Загрузка... +
+ ) : null} - {/* Theme toggle */} + {/* Theme toggle + Demo badge */}
- {weather?.demo && ( - Demo - + )}
diff --git a/components/cards/WeatherCard.tsx b/components/cards/WeatherCard.tsx index 8c3287e..684f315 100644 --- a/components/cards/WeatherCard.tsx +++ b/components/cards/WeatherCard.tsx @@ -18,22 +18,28 @@ function getWeatherEmoji(code: string): string { } function formatDate(dateStr: string): string { - const d = new Date(dateStr); + 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 }: Props) { +export default function WeatherCard({ weather, compact }: Props) { if (!weather) { return ( +
🌤️
Загрузка погоды...
@@ -41,9 +47,30 @@ export default function WeatherCard({ weather }: Props) { ); } + const hasData = weather.temp && weather.temp !== "—"; + + if (!hasData) { + return ( + +
🌧️
+
+ Нет данных о погоде +
+
+ ); + } + return ( {/* Location */}
📍 Санкт-Петербург
{/* Current weather */} -
-
+
+
{getWeatherEmoji(weather.weatherCode)}
{weather.temp}°
{weather.desc} @@ -83,24 +113,21 @@ export default function WeatherCard({ weather }: Props) {
{/* Stats */} -
-
- +
+
+ {weather.humidity}%
-
- +
+ {weather.windSpeed} км/ч
-
- Ощущается {weather.feelsLike}° +
+ Ощущ. {weather.feelsLike}°
@@ -109,22 +136,30 @@ export default function WeatherCard({ weather }: Props) { {(weather.forecast || []).slice(0, 3).map((day: any, i: number) => (
{i === 0 ? "Сег." : formatDate(day.date)}
-
{getWeatherEmoji(day.weatherCode)}
+
+ {getWeatherEmoji(day.weatherCode)} +