feat: initial smart home dashboard
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:
Cosmo
2026-04-22 10:00:41 +00:00
commit 9044869fa4
29 changed files with 2439 additions and 0 deletions

97
app/api/ha/route.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}