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
320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|