feat: initial smart home dashboard
All checks were successful
Deploy to Coolify / deploy (push) Successful in 44s
All checks were successful
Deploy to Coolify / deploy (push) Successful in 44s
- Next.js 14 + TypeScript + Tailwind CSS - Glassmorphism design with ambient orbs - Cards: Light x2, Temperature, AirPurifier, Tasks, Weather, Savings - Home Assistant integration (demo mode if no token) - Vikunja tasks API - Pulse savings API - wttr.in weather - Framer Motion animations - Dark/light theme toggle - Bottom navigation - Dockerfile for deployment
This commit is contained in:
97
app/api/ha/route.ts
Normal file
97
app/api/ha/route.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
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 = {
|
||||
"light.living_room": {
|
||||
entity_id: "light.living_room",
|
||||
state: "on",
|
||||
attributes: { brightness: 180, friendly_name: "Свет Гостиная" },
|
||||
},
|
||||
"light.bedroom": {
|
||||
entity_id: "light.bedroom",
|
||||
state: "off",
|
||||
attributes: { friendly_name: "Свет Спальня" },
|
||||
},
|
||||
"climate.thermostat": {
|
||||
entity_id: "climate.thermostat",
|
||||
state: "heat",
|
||||
attributes: {
|
||||
current_temperature: 21.5,
|
||||
temperature: 22,
|
||||
friendly_name: "Термостат",
|
||||
},
|
||||
},
|
||||
"fan.air_purifier": {
|
||||
entity_id: "fan.air_purifier",
|
||||
state: "on",
|
||||
attributes: {
|
||||
preset_mode: "Auto",
|
||||
friendly_name: "Очиститель воздуха",
|
||||
preset_modes: ["Auto", "Night", "High"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!HA_TOKEN) {
|
||||
return NextResponse.json({ demo: true, states: MOCK_STATES });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${HA_URL}/api/states`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${HA_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
next: { revalidate: 0 },
|
||||
});
|
||||
|
||||
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 filtered: Record<string, any> = {};
|
||||
for (const s of states) {
|
||||
if (relevant.includes(s.entity_id)) {
|
||||
filtered[s.entity_id] = s;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ demo: false, states: filtered });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ demo: true, states: MOCK_STATES });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { domain, service, entity_id, ...serviceData } = await req.json();
|
||||
|
||||
if (!HA_TOKEN) {
|
||||
return NextResponse.json({ success: true, demo: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${HA_URL}/api/services/${domain}/${service}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${HA_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ entity_id, ...serviceData }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HA responded ${res.status}`);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ success: false, error: String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
54
app/api/savings/route.ts
Normal file
54
app/api/savings/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const PULSE_API = process.env.PULSE_API_URL || "https://api.digital-home.site";
|
||||
const PULSE_REFRESH = process.env.PULSE_REFRESH_TOKEN || "";
|
||||
|
||||
const MOCK_SAVINGS = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Квартира Pulse Premier",
|
||||
current_amount: 450000,
|
||||
target_amount: 800000,
|
||||
color: "#6366f1",
|
||||
icon: "🏠",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Отпуск",
|
||||
current_amount: 95000,
|
||||
target_amount: 200000,
|
||||
color: "#8b5cf6",
|
||||
icon: "✈️",
|
||||
},
|
||||
];
|
||||
|
||||
async function getAccessToken(): Promise<string> {
|
||||
const res = await fetch(`${PULSE_API}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: PULSE_REFRESH }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Refresh failed");
|
||||
const data = await res.json();
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (!PULSE_REFRESH) {
|
||||
return NextResponse.json({ savings: MOCK_SAVINGS, demo: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
const res = await fetch(`${PULSE_API}/savings`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Pulse responded ${res.status}`);
|
||||
const data = await res.json();
|
||||
return NextResponse.json({ savings: Array.isArray(data) ? data : [] });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ savings: MOCK_SAVINGS, demo: true });
|
||||
}
|
||||
}
|
||||
82
app/api/tasks/route.ts
Normal file
82
app/api/tasks/route.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const VIKUNJA_URL = process.env.VIKUNJA_URL || "https://tasks.digital-home.site";
|
||||
const VIKUNJA_TOKEN = process.env.VIKUNJA_TOKEN || "tk_03787e3778789fd5bfaff0542a8dd9390aae0f82";
|
||||
|
||||
const MOCK_TASKS = [
|
||||
{ id: 1, title: "Записаться на ТО", done: false, priority: 2 },
|
||||
{ id: 2, title: "Оплатить аренду", done: true, priority: 3 },
|
||||
{ id: 3, title: "Купить продукты", done: false, priority: 1 },
|
||||
];
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const res = await fetch(
|
||||
`${VIKUNJA_URL}/api/v1/tasks/all?filter_by=due_date&filter_value=${today}&filter_comparator=equals&per_page=20`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${VIKUNJA_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
next: { revalidate: 0 },
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) throw new Error(`Vikunja responded ${res.status}`);
|
||||
const data = await res.json();
|
||||
return NextResponse.json({ tasks: Array.isArray(data) ? data : [] });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ tasks: MOCK_TASKS, demo: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { title } = await req.json();
|
||||
if (!title) return NextResponse.json({ error: "Title required" }, { status: 400 });
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 0);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${VIKUNJA_URL}/api/v1/projects/3/tasks`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${VIKUNJA_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
due_date: today.toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Vikunja responded ${res.status}`);
|
||||
const task = await res.json();
|
||||
return NextResponse.json({ task });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ task: { id: Date.now(), title, done: false }, demo: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const { id, done } = await req.json();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${VIKUNJA_URL}/api/v1/tasks/${id}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${VIKUNJA_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ done }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Vikunja responded ${res.status}`);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ success: true, demo: true });
|
||||
}
|
||||
}
|
||||
50
app/api/weather/route.ts
Normal file
50
app/api/weather/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
"https://wttr.in/Saint+Petersburg?format=j1",
|
||||
{
|
||||
next: { revalidate: 600 },
|
||||
headers: { "User-Agent": "SmartHomeDashboard/1.0" },
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error("Weather fetch failed");
|
||||
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 || "";
|
||||
return {
|
||||
date: day.date,
|
||||
maxTemp: day.maxtempC,
|
||||
minTemp: day.mintempC,
|
||||
desc,
|
||||
weatherCode: day.hourly[4]?.weatherCode || "113",
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
temp: current.temp_C,
|
||||
feelsLike: current.FeelsLikeC,
|
||||
humidity: current.humidity,
|
||||
desc: current.weatherDesc[0]?.value || "",
|
||||
weatherCode: current.weatherCode,
|
||||
windSpeed: current.windspeedKmph,
|
||||
forecast: days,
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
temp: "—",
|
||||
feelsLike: "—",
|
||||
humidity: "—",
|
||||
desc: "Нет данных",
|
||||
weatherCode: "113",
|
||||
windSpeed: "—",
|
||||
forecast: [],
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}
|
||||
197
app/globals.css
Normal file
197
app/globals.css
Normal file
@@ -0,0 +1,197 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--bg: #0a0a0f;
|
||||
--card-bg: rgba(255, 255, 255, 0.05);
|
||||
--card-border: rgba(255, 255, 255, 0.08);
|
||||
--text-primary: rgba(255, 255, 255, 0.95);
|
||||
--text-secondary: rgba(255, 255, 255, 0.5);
|
||||
--accent: #6366f1;
|
||||
--accent-2: #8b5cf6;
|
||||
}
|
||||
|
||||
.light {
|
||||
--bg: #f0f0f8;
|
||||
--card-bg: rgba(255, 255, 255, 0.8);
|
||||
--card-border: rgba(0, 0, 0, 0.08);
|
||||
--text-primary: rgba(15, 15, 30, 0.95);
|
||||
--text-secondary: rgba(15, 15, 30, 0.5);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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: 20px;
|
||||
transition: background 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(99, 102, 241, 0.4);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Custom toggle switch */
|
||||
.toggle-track {
|
||||
position: relative;
|
||||
width: 52px;
|
||||
height: 28px;
|
||||
border-radius: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.toggle-on .toggle-thumb {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
/* 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: #6366f1;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 8px rgba(99, 102, 241, 0.6);
|
||||
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: #6366f1;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 0 8px rgba(99, 102, 241, 0.6);
|
||||
}
|
||||
|
||||
/* 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: 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.light .progress-bar {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||
transition: width 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/* Glow effects */
|
||||
.glow-indigo {
|
||||
box-shadow: 0 0 20px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.glow-emerald {
|
||||
box-shadow: 0 0 20px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.glow-rose {
|
||||
box-shadow: 0 0 20px rgba(244, 63, 94, 0.3);
|
||||
}
|
||||
|
||||
.glow-amber {
|
||||
box-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
/* Modal backdrop */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
33
app/layout.tsx
Normal file
33
app/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Smart Home Dashboard",
|
||||
description: "Smart Home Tablet Dashboard — управление умным домом",
|
||||
manifest: "/manifest.json",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ru" className="dark">
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
</head>
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
319
app/page.tsx
Normal file
319
app/page.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import TopBar from "@/components/TopBar";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
import LightCard from "@/components/cards/LightCard";
|
||||
import TemperatureCard from "@/components/cards/TemperatureCard";
|
||||
import AirPurifierCard from "@/components/cards/AirPurifierCard";
|
||||
import TasksCard from "@/components/cards/TasksCard";
|
||||
import WeatherCard from "@/components/cards/WeatherCard";
|
||||
import SavingsCard from "@/components/cards/SavingsCard";
|
||||
import { useHA, useWeather, useTasks, useSavings } from "@/hooks/useHA";
|
||||
|
||||
export default function Home() {
|
||||
const [isDark, setIsDark] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState("home");
|
||||
|
||||
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(() => {
|
||||
const html = document.documentElement;
|
||||
if (isDark) {
|
||||
html.classList.add("dark");
|
||||
html.classList.remove("light");
|
||||
document.body.classList.remove("light");
|
||||
} else {
|
||||
html.classList.remove("dark");
|
||||
html.classList.add("light");
|
||||
document.body.classList.add("light");
|
||||
}
|
||||
}, [isDark]);
|
||||
|
||||
const states = haData?.states || {};
|
||||
const isDemo = haData?.demo || false;
|
||||
|
||||
const handleHAUpdate = useCallback(() => {
|
||||
setTimeout(refreshHA, 500);
|
||||
}, [refreshHA]);
|
||||
|
||||
const livingRoom = states["light.living_room"];
|
||||
const bedroom = states["light.bedroom"];
|
||||
const thermostat = states["climate.thermostat"];
|
||||
const airPurifier = states["fan.air_purifier"];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-screen h-screen overflow-hidden no-select"
|
||||
style={{ background: "var(--bg)" }}
|
||||
>
|
||||
{/* Ambient orbs */}
|
||||
<div
|
||||
className="orb animate-[orbMove1_20s_ease-in-out_infinite]"
|
||||
style={{
|
||||
width: 400,
|
||||
height: 400,
|
||||
top: "-10%",
|
||||
left: "-5%",
|
||||
background: isDark
|
||||
? "rgba(99,102,241,0.12)"
|
||||
: "rgba(99,102,241,0.08)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="orb animate-[orbMove2_25s_ease-in-out_infinite]"
|
||||
style={{
|
||||
width: 350,
|
||||
height: 350,
|
||||
bottom: "5%",
|
||||
right: "-5%",
|
||||
background: isDark
|
||||
? "rgba(139,92,246,0.1)"
|
||||
: "rgba(139,92,246,0.06)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="orb animate-[orbMove3_30s_ease-in-out_infinite]"
|
||||
style={{
|
||||
width: 280,
|
||||
height: 280,
|
||||
top: "40%",
|
||||
left: "40%",
|
||||
background: isDark
|
||||
? "rgba(6,182,212,0.06)"
|
||||
: "rgba(6,182,212,0.04)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main layout */}
|
||||
<div className="relative z-10 h-full flex flex-col p-4 gap-3">
|
||||
{/* Top bar */}
|
||||
<TopBar
|
||||
isDark={isDark}
|
||||
onToggleTheme={() => setIsDark(!isDark)}
|
||||
weather={weather}
|
||||
/>
|
||||
|
||||
{/* Demo badge */}
|
||||
<AnimatePresence>
|
||||
{isDemo && (
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<span
|
||||
className="text-xs px-3 py-1 rounded-full"
|
||||
style={{
|
||||
background: "rgba(245,158,11,0.12)",
|
||||
color: "#f59e0b",
|
||||
border: "1px solid rgba(245,158,11,0.25)",
|
||||
}}
|
||||
>
|
||||
🔌 Демо режим — HA Token не настроен
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === "home" && (
|
||||
<motion.div
|
||||
key="home"
|
||||
className="h-full grid grid-cols-4 grid-rows-2 gap-3"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
{/* Row 1 */}
|
||||
{/* Свет Гостиная */}
|
||||
<LightCard
|
||||
entityId="light.living_room"
|
||||
name="Свет Гостиная"
|
||||
state={livingRoom?.state || "off"}
|
||||
brightness={livingRoom?.attributes?.brightness}
|
||||
showSlider={true}
|
||||
onUpdate={handleHAUpdate}
|
||||
/>
|
||||
|
||||
{/* Свет Спальня */}
|
||||
<LightCard
|
||||
entityId="light.bedroom"
|
||||
name="Свет Спальня"
|
||||
state={bedroom?.state || "off"}
|
||||
brightness={bedroom?.attributes?.brightness}
|
||||
showSlider={false}
|
||||
onUpdate={handleHAUpdate}
|
||||
/>
|
||||
|
||||
{/* Температура */}
|
||||
<TemperatureCard
|
||||
entityId="climate.thermostat"
|
||||
currentTemp={thermostat?.attributes?.current_temperature}
|
||||
targetTemp={thermostat?.attributes?.temperature}
|
||||
state={thermostat?.state || "off"}
|
||||
onUpdate={handleHAUpdate}
|
||||
/>
|
||||
|
||||
{/* Очиститель воздуха */}
|
||||
<AirPurifierCard
|
||||
entityId="fan.air_purifier"
|
||||
state={airPurifier?.state || "off"}
|
||||
presetMode={airPurifier?.attributes?.preset_mode}
|
||||
onUpdate={handleHAUpdate}
|
||||
/>
|
||||
|
||||
{/* Row 2 */}
|
||||
{/* Задачи — 2 колонки */}
|
||||
<div className="col-span-2">
|
||||
<TasksCard
|
||||
tasks={tasks}
|
||||
onUpdate={refreshTasks}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Погода */}
|
||||
<WeatherCard weather={weather} />
|
||||
|
||||
{/* Накопления */}
|
||||
<SavingsCard savings={savings} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === "devices" && (
|
||||
<motion.div
|
||||
key="devices"
|
||||
className="h-full grid grid-cols-3 gap-3"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<LightCard
|
||||
entityId="light.living_room"
|
||||
name="Свет Гостиная"
|
||||
state={livingRoom?.state || "off"}
|
||||
brightness={livingRoom?.attributes?.brightness}
|
||||
showSlider={true}
|
||||
onUpdate={handleHAUpdate}
|
||||
/>
|
||||
<LightCard
|
||||
entityId="light.bedroom"
|
||||
name="Свет Спальня"
|
||||
state={bedroom?.state || "off"}
|
||||
brightness={bedroom?.attributes?.brightness}
|
||||
showSlider={false}
|
||||
onUpdate={handleHAUpdate}
|
||||
/>
|
||||
<AirPurifierCard
|
||||
entityId="fan.air_purifier"
|
||||
state={airPurifier?.state || "off"}
|
||||
presetMode={airPurifier?.attributes?.preset_mode}
|
||||
onUpdate={handleHAUpdate}
|
||||
/>
|
||||
<div className="col-span-2">
|
||||
<TemperatureCard
|
||||
entityId="climate.thermostat"
|
||||
currentTemp={thermostat?.attributes?.current_temperature}
|
||||
targetTemp={thermostat?.attributes?.temperature}
|
||||
state={thermostat?.state || "off"}
|
||||
onUpdate={handleHAUpdate}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === "tasks" && (
|
||||
<motion.div
|
||||
key="tasks"
|
||||
className="h-full max-w-2xl mx-auto w-full"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<TasksCard tasks={tasks} onUpdate={refreshTasks} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{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)" }}
|
||||
>
|
||||
Добавь HA Token в Coolify для подключения к умному дому
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 text-left">
|
||||
{[
|
||||
{
|
||||
label: "HA URL",
|
||||
value:
|
||||
process.env.NEXT_PUBLIC_APP_URL
|
||||
? "Настроен"
|
||||
: "http://192.168.31.110:8123",
|
||||
},
|
||||
{ label: "HA Token", value: isDemo ? "❌ Не настроен" : "✅ Настроен" },
|
||||
{ label: "Vikunja", value: "✅ Подключён" },
|
||||
{ label: "Pulse API", value: "✅ Подключён" },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex justify-between items-center px-4 py-3 rounded-xl"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.04)",
|
||||
border: "1px solid rgba(255,255,255,0.06)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Bottom nav */}
|
||||
<BottomNav active={activeTab} onChange={setActiveTab} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user