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

View File

@@ -0,0 +1,17 @@
name: Deploy to Coolify
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Trigger Coolify Deploy
run: |
curl -X POST "https://coolify.digital-home.site/api/v1/deploy?uuid=${{ secrets.COOLIFY_APP_UUID }}&force=false" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}" \
-H "Content-Type: application/json"

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --prefer-offline || npm install
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

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>
);
}

108
components/AddTaskModal.tsx Normal file
View File

@@ -0,0 +1,108 @@
"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 (
<AnimatePresence>
{open && (
<motion.div
className="modal-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.div
className="glass-card p-6 w-96 mx-4"
style={{ border: "1px solid rgba(99,102,241,0.3)" }}
initial={{ opacity: 0, scale: 0.85, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.85, y: 20 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-5">
<h3
className="text-lg font-semibold"
style={{ color: "var(--text-primary)" }}
>
Новая задача
</h3>
<motion.button
onClick={onClose}
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{ background: "rgba(255,255,255,0.08)" }}
whileTap={{ scale: 0.9 }}
>
<X size={16} color="var(--text-secondary)" />
</motion.button>
</div>
<input
type="text"
value={title}
onChange={(e) => 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)",
}}
/>
<div className="flex gap-3">
<motion.button
onClick={onClose}
className="flex-1 py-3 rounded-xl text-sm font-medium"
style={{
background: "rgba(255,255,255,0.06)",
color: "var(--text-secondary)",
border: "1px solid rgba(255,255,255,0.08)",
}}
whileTap={{ scale: 0.95 }}
>
Отмена
</motion.button>
<motion.button
onClick={handleSubmit}
className="flex-1 py-3 rounded-xl text-sm font-semibold flex items-center justify-center gap-2"
style={{
background:
"linear-gradient(135deg, #6366f1, #8b5cf6)",
color: "white",
boxShadow: "0 0 20px rgba(99,102,241,0.4)",
}}
whileTap={{ scale: 0.95 }}
disabled={!title.trim()}
>
<Plus size={16} />
Добавить
</motion.button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

68
components/BottomNav.tsx Normal file
View File

@@ -0,0 +1,68 @@
"use client";
import { motion } from "framer-motion";
import { Home, Cpu, CheckSquare, Settings } from "lucide-react";
interface Props {
active: string;
onChange: (tab: string) => void;
}
const TABS = [
{ id: "home", label: "Главная", icon: Home },
{ id: "devices", label: "Устройства", icon: Cpu },
{ id: "tasks", label: "Задачи", icon: CheckSquare },
{ id: "settings", label: "Настройки", icon: Settings },
];
export default function BottomNav({ active, onChange }: Props) {
return (
<motion.div
className="glass-card px-4 py-2 flex items-center justify-around no-select"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{TABS.map((tab) => {
const Icon = tab.icon;
const isActive = active === tab.id;
return (
<motion.button
key={tab.id}
onClick={() => onChange(tab.id)}
className="flex flex-col items-center gap-1 px-6 py-2 rounded-xl relative"
whileTap={{ scale: 0.88 }}
style={{
background: isActive
? "rgba(99,102,241,0.15)"
: "transparent",
}}
>
{isActive && (
<motion.div
className="absolute inset-0 rounded-xl"
style={{
background:
"linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.1))",
border: "1px solid rgba(99,102,241,0.3)",
}}
layoutId="navActive"
transition={{ type: "spring", stiffness: 400, damping: 30 }}
/>
)}
<Icon
size={22}
color={isActive ? "#6366f1" : "var(--text-secondary)"}
/>
<span
className="text-xs font-medium"
style={{ color: isActive ? "#6366f1" : "var(--text-secondary)" }}
>
{tab.label}
</span>
</motion.button>
);
})}
</motion.div>
);
}

View File

@@ -0,0 +1,43 @@
"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 (
<motion.button
onClick={onToggle}
className="relative w-14 h-7 rounded-full flex items-center cursor-pointer no-select"
style={{
background: isDark
? "rgba(99,102,241,0.3)"
: "rgba(245,158,11,0.3)",
border: `1px solid ${isDark ? "rgba(99,102,241,0.5)" : "rgba(245,158,11,0.5)"}`,
}}
whileTap={{ scale: 0.9 }}
>
<motion.div
className="absolute w-5 h-5 rounded-full flex items-center justify-center"
style={{
background: isDark ? "#6366f1" : "#f59e0b",
boxShadow: isDark
? "0 0 8px rgba(99,102,241,0.8)"
: "0 0 8px rgba(245,158,11,0.8)",
}}
animate={{ x: isDark ? 2 : 28 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
>
{isDark ? (
<Moon size={10} color="white" />
) : (
<Sun size={10} color="white" />
)}
</motion.div>
</motion.button>
);
}

129
components/TopBar.tsx Normal file
View File

@@ -0,0 +1,129 @@
"use client";
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import ThemeToggle from "./ThemeToggle";
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 "🌤️";
}
interface Props {
isDark: boolean;
onToggleTheme: () => void;
weather: any;
}
export default function TopBar({ isDark, onToggleTheme, weather }: Props) {
const [time, setTime] = useState("");
const [date, setDate] = useState("");
useEffect(() => {
const update = () => {
const now = new Date();
setTime(
now.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })
);
setDate(
now.toLocaleDateString("ru-RU", {
weekday: "long",
day: "numeric",
month: "long",
})
);
};
update();
const id = setInterval(update, 1000);
return () => clearInterval(id);
}, []);
return (
<motion.div
className="glass-card px-6 py-3 flex items-center justify-between no-select"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* Time & Date */}
<div className="flex items-baseline gap-4">
<span
className="text-5xl font-bold tracking-tight"
style={{ color: "var(--text-primary)" }}
>
{time}
</span>
<span
className="text-sm font-medium capitalize"
style={{ color: "var(--text-secondary)" }}
>
{date}
</span>
</div>
{/* Weather */}
{weather && (
<motion.div
className="flex items-center gap-3 px-4 py-2 rounded-xl"
style={{
background: "rgba(99,102,241,0.1)",
border: "1px solid rgba(99,102,241,0.2)",
}}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 }}
>
<span className="text-2xl">
{getWeatherEmoji(weather.weatherCode)}
</span>
<div>
<div
className="text-xl font-bold"
style={{ color: "var(--text-primary)" }}
>
{weather.temp}°C
</div>
<div
className="text-xs"
style={{ color: "var(--text-secondary)" }}
>
Ощущается {weather.feelsLike}°
</div>
</div>
<div
className="text-xs ml-2 max-w-[80px] text-center leading-tight"
style={{ color: "var(--text-secondary)" }}
>
{weather.desc}
</div>
</motion.div>
)}
{/* Theme toggle */}
<div className="flex items-center gap-3">
{weather?.demo && (
<span
className="text-xs px-2 py-1 rounded-full"
style={{
background: "rgba(245,158,11,0.15)",
color: "#f59e0b",
border: "1px solid rgba(245,158,11,0.3)",
}}
>
Demo
</span>
)}
<ThemeToggle isDark={isDark} onToggle={onToggleTheme} />
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,147 @@
"use client";
import { useState, useCallback } from "react";
import { motion } 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: "#06b6d4" },
{ id: "Night", label: "Ночь", color: "#6366f1" },
{ id: "High", label: "Макс", color: "#f43f5e" },
];
export default function AirPurifierCard({
entityId,
state,
presetMode,
onUpdate,
}: Props) {
const isOn = state === "on";
const [currentMode, setCurrentMode] = useState(presetMode || "Auto");
const [pending, setPending] = useState(false);
const handleToggle = useCallback(async () => {
if (pending) return;
setPending(true);
await toggleFan(entityId, !isOn);
onUpdate();
setPending(false);
}, [entityId, isOn, pending, onUpdate]);
const handleMode = useCallback(
async (mode: string) => {
setCurrentMode(mode);
await setFanPreset(entityId, mode);
onUpdate();
},
[entityId, onUpdate]
);
const activeColor =
MODES.find((m) => m.id === currentMode)?.color || "#06b6d4";
return (
<motion.div
className="glass-card p-5 h-full flex flex-col justify-between"
style={
isOn
? {
background: `rgba(6,182,212,0.06)`,
border: `1px solid rgba(6,182,212,0.2)`,
}
: {}
}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
whileHover={{ scale: 1.01 }}
>
<div className="flex items-start justify-between">
<div>
<div
className="w-10 h-10 rounded-xl flex items-center justify-center mb-3"
style={{
background: isOn
? "rgba(6,182,212,0.15)"
: "rgba(255,255,255,0.06)",
}}
>
<motion.div
animate={isOn ? { rotate: 360 } : { rotate: 0 }}
transition={
isOn
? { duration: 3, repeat: Infinity, ease: "linear" }
: {}
}
>
<Wind
size={20}
color={isOn ? "#06b6d4" : "var(--text-secondary)"}
/>
</motion.div>
</div>
<div
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
Очиститель воздуха
</div>
<div
className="text-xs mt-0.5"
style={{ color: isOn ? activeColor : "var(--text-secondary)" }}
>
{isOn ? currentMode : "Выключен"}
</div>
</div>
<motion.div
className={`toggle-track ${isOn ? "toggle-on" : ""}`}
style={{ background: isOn ? "#06b6d4" : "rgba(255,255,255,0.1)" }}
onClick={handleToggle}
whileTap={{ scale: 0.9 }}
>
<div className="toggle-thumb" />
</motion.div>
</div>
{isOn && (
<motion.div
className="flex gap-2 mt-4"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
>
{MODES.map((mode) => (
<motion.button
key={mode.id}
onClick={() => handleMode(mode.id)}
className="flex-1 py-2 rounded-xl text-xs font-medium"
style={
currentMode === mode.id
? {
background: `${mode.color}25`,
border: `1px solid ${mode.color}60`,
color: mode.color,
}
: {
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.08)",
color: "var(--text-secondary)",
}
}
whileTap={{ scale: 0.9 }}
>
{mode.label}
</motion.button>
))}
</motion.div>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,150 @@
"use client";
import { useState, useCallback } from "react";
import { motion } 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) {
const isOn = state === "on";
const brightPct = getBrightnessPct(brightness);
const [localBrightness, setLocalBrightness] = useState(brightPct || 70);
const [pending, setPending] = useState(false);
const handleToggle = useCallback(async () => {
if (pending) return;
setPending(true);
await toggleLight(entityId, !isOn);
onUpdate();
setPending(false);
}, [entityId, isOn, pending, onUpdate]);
const handleBrightnessChange = useCallback(
async (val: number) => {
setLocalBrightness(val);
await setLightBrightness(entityId, pctToBrightness(val));
onUpdate();
},
[entityId, onUpdate]
);
return (
<motion.div
className="glass-card p-5 h-full flex flex-col justify-between"
style={
isOn
? {
background: "rgba(245,158,11,0.08)",
border: "1px solid rgba(245,158,11,0.2)",
boxShadow: "0 0 30px rgba(245,158,11,0.1)",
}
: {}
}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
whileHover={{ scale: 1.01 }}
>
<div className="flex items-start justify-between">
<div>
<div
className="w-10 h-10 rounded-xl flex items-center justify-center mb-3"
style={{
background: isOn
? "rgba(245,158,11,0.2)"
: "rgba(255,255,255,0.06)",
}}
>
<Lightbulb
size={20}
color={isOn ? "#f59e0b" : "var(--text-secondary)"}
fill={isOn ? "#f59e0b" : "none"}
/>
</div>
<div
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
{name}
</div>
<div
className="text-xs mt-0.5"
style={{ color: isOn ? "#f59e0b" : "var(--text-secondary)" }}
>
{isOn ? (showSlider ? `${localBrightness}%` : "Включён") : "Выключен"}
</div>
</div>
{/* Toggle */}
<motion.div
className={`toggle-track ${isOn ? "toggle-on" : ""}`}
style={{
background: isOn
? "#f59e0b"
: "rgba(255,255,255,0.1)",
}}
onClick={handleToggle}
whileTap={{ scale: 0.9 }}
>
<div className="toggle-thumb" />
</motion.div>
</div>
{showSlider && isOn && (
<motion.div
className="mt-4"
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
>
<div
className="text-xs mb-2 flex justify-between"
style={{ color: "var(--text-secondary)" }}
>
<span>Яркость</span>
<span>{localBrightness}%</span>
</div>
<div className="relative">
<div
className="absolute inset-y-0 left-0 rounded-l-full pointer-events-none"
style={{
width: `${localBrightness}%`,
background: "linear-gradient(90deg, rgba(245,158,11,0.4), rgba(245,158,11,0.8))",
height: "6px",
top: "50%",
transform: "translateY(-50%)",
}}
/>
<input
type="range"
min={5}
max={100}
value={localBrightness}
onChange={(e) => 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 relative z-10"
style={{ background: "transparent" }}
/>
</div>
</motion.div>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { motion } from "framer-motion";
import { PiggyBank } 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)}M`;
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
return String(n);
}
export default function SavingsCard({ savings }: Props) {
return (
<motion.div
className="glass-card p-5 h-full flex flex-col"
style={{
background: "rgba(99,102,241,0.04)",
border: "1px solid rgba(99,102,241,0.12)",
}}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
whileHover={{ scale: 1.01 }}
>
<div className="flex items-center gap-2 mb-4">
<PiggyBank size={18} color="#6366f1" />
<span
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
Накопления
</span>
</div>
<div className="flex-1 space-y-4">
{savings.map((s, i) => {
const pct = Math.min(
100,
Math.round((s.current_amount / s.target_amount) * 100)
);
const color = s.color || "#6366f1";
return (
<motion.div
key={s.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{s.icon && <span className="text-base">{s.icon}</span>}
<span
className="text-xs font-medium"
style={{ color: "var(--text-primary)" }}
>
{s.name}
</span>
</div>
<span
className="text-xs font-semibold"
style={{ color }}
>
{pct}%
</span>
</div>
<div className="progress-bar">
<motion.div
className="progress-fill"
style={{
background: `linear-gradient(90deg, ${color}aa, ${color})`,
width: `${pct}%`,
}}
initial={{ width: "0%" }}
animate={{ width: `${pct}%` }}
transition={{
duration: 1,
delay: i * 0.2,
ease: "easeOut",
}}
/>
</div>
<div
className="flex justify-between text-xs mt-1"
style={{ color: "var(--text-secondary)" }}
>
<span>{formatAmount(s.current_amount)} </span>
<span>цель: {formatAmount(s.target_amount)} </span>
</div>
</motion.div>
);
})}
{savings.length === 0 && (
<div
className="text-center py-6 text-sm"
style={{ color: "var(--text-secondary)" }}
>
Нет данных о накоплениях
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,154 @@
"use client";
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { CheckSquare, Square, Plus } 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<Task[]>(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 (
<>
<motion.div
className="glass-card p-5 h-full flex flex-col"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
whileHover={{ scale: 1.005 }}
>
<div className="flex items-center justify-between mb-4">
<div>
<div className="flex items-center gap-2">
<CheckSquare size={18} color="#6366f1" />
<span
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
Задачи сегодня
</span>
</div>
<div
className="text-xs mt-0.5"
style={{ color: "var(--text-secondary)" }}
>
{pending.length} осталось из {localTasks.length}
</div>
</div>
<motion.button
onClick={() => setModalOpen(true)}
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
boxShadow: "0 0 12px rgba(99,102,241,0.4)",
}}
whileTap={{ scale: 0.85 }}
>
<Plus size={16} color="white" />
</motion.button>
</div>
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
<AnimatePresence>
{localTasks.length === 0 && (
<motion.div
className="text-center py-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div className="text-3xl mb-2">🎉</div>
<div
className="text-sm"
style={{ color: "var(--text-secondary)" }}
>
Всё сделано!
</div>
</motion.div>
)}
{localTasks.map((task) => (
<motion.div
key={task.id}
layout
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10, height: 0 }}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer"
style={{
background: task.done
? "rgba(16,185,129,0.06)"
: "rgba(255,255,255,0.04)",
border: task.done
? "1px solid rgba(16,185,129,0.15)"
: "1px solid rgba(255,255,255,0.06)",
}}
onClick={() => handleToggle(task)}
whileTap={{ scale: 0.97 }}
>
{task.done ? (
<CheckSquare size={16} color="#10b981" />
) : (
<Square size={16} color="var(--text-secondary)" />
)}
<span
className="text-xs font-medium flex-1 leading-snug"
style={{
color: task.done
? "var(--text-secondary)"
: "var(--text-primary)",
textDecoration: task.done ? "line-through" : "none",
}}
>
{task.title}
</span>
</motion.div>
))}
</AnimatePresence>
</div>
</motion.div>
<AddTaskModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onAdd={handleAdd}
/>
</>
);
}

View File

@@ -0,0 +1,132 @@
"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 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 tempDiff = currentTemp ? currentTemp - target : 0;
return (
<motion.div
className="glass-card p-5 h-full flex flex-col justify-between"
style={
isHeating
? {
background: "rgba(244,63,94,0.06)",
border: "1px solid rgba(244,63,94,0.15)",
}
: {}
}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
whileHover={{ scale: 1.01 }}
>
<div className="flex items-start justify-between">
<div>
<div
className="w-10 h-10 rounded-xl flex items-center justify-center mb-3"
style={{
background: isHeating
? "rgba(244,63,94,0.15)"
: "rgba(255,255,255,0.06)",
}}
>
<Thermometer
size={20}
color={isHeating ? "#f43f5e" : "var(--text-secondary)"}
/>
</div>
<div
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
Термостат
</div>
<div
className="text-xs mt-0.5"
style={{ color: isHeating ? "#f43f5e" : "var(--text-secondary)" }}
>
{isHeating ? "Нагрев" : "Ожидание"}
</div>
</div>
<div className="text-right">
<div
className="text-3xl font-bold"
style={{ color: "var(--text-primary)" }}
>
{currentTemp?.toFixed(1) ?? "—"}°
</div>
<div
className="text-xs"
style={{ color: "var(--text-secondary)" }}
>
текущая
</div>
</div>
</div>
<div className="flex items-center justify-between mt-4">
<div
className="text-xs"
style={{ color: "var(--text-secondary)" }}
>
Целевая температура
</div>
<div className="flex items-center gap-3">
<motion.button
onClick={() => adjust(-0.5)}
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{ background: "rgba(255,255,255,0.08)" }}
whileTap={{ scale: 0.85 }}
>
<Minus size={14} color="var(--text-primary)" />
</motion.button>
<span
className="text-lg font-bold min-w-[48px] text-center"
style={{ color: "#6366f1" }}
>
{target}°
</span>
<motion.button
onClick={() => adjust(0.5)}
className="w-8 h-8 rounded-lg flex items-center justify-center"
style={{ background: "rgba(99,102,241,0.2)" }}
whileTap={{ scale: 0.85 }}
>
<Plus size={14} color="#6366f1" />
</motion.button>
</div>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,140 @@
"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);
return d.toLocaleDateString("ru-RU", { weekday: "short", day: "numeric" });
}
interface Props {
weather: any;
}
export default function WeatherCard({ weather }: Props) {
if (!weather) {
return (
<motion.div
className="glass-card p-5 h-full flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<div
className="text-sm"
style={{ color: "var(--text-secondary)" }}
>
Загрузка погоды...
</div>
</motion.div>
);
}
return (
<motion.div
className="glass-card p-5 h-full flex flex-col"
style={{
background: "rgba(6,182,212,0.04)",
border: "1px solid rgba(6,182,212,0.12)",
}}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
whileHover={{ scale: 1.01 }}
>
<div className="flex items-start justify-between mb-4">
<div>
<div
className="text-sm font-semibold"
style={{ color: "var(--text-primary)" }}
>
🌍 Санкт-Петербург
</div>
<div className="flex items-center gap-3 mt-2">
<span className="text-3xl font-bold" style={{ color: "var(--text-primary)" }}>
{getWeatherEmoji(weather.weatherCode)}
</span>
<div>
<div
className="text-2xl font-bold"
style={{ color: "var(--text-primary)" }}
>
{weather.temp}°C
</div>
<div
className="text-xs"
style={{ color: "var(--text-secondary)" }}
>
{weather.desc}
</div>
</div>
</div>
</div>
<div className="text-right space-y-1">
<div className="flex items-center gap-1 justify-end">
<Droplets size={12} color="#06b6d4" />
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
{weather.humidity}%
</span>
</div>
<div className="flex items-center gap-1 justify-end">
<Wind size={12} color="#8b5cf6" />
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
{weather.windSpeed} км/ч
</span>
</div>
</div>
</div>
{/* Forecast */}
<div className="flex gap-2 mt-auto">
{(weather.forecast || []).map((day: any, i: number) => (
<motion.div
key={day.date}
className="flex-1 rounded-xl p-3 text-center"
style={{
background: i === 0
? "rgba(99,102,241,0.12)"
: "rgba(255,255,255,0.04)",
border: i === 0
? "1px solid rgba(99,102,241,0.25)"
: "1px solid rgba(255,255,255,0.06)",
}}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
>
<div
className="text-xs mb-1 font-medium"
style={{ color: "var(--text-secondary)" }}
>
{i === 0 ? "Сегодня" : formatDate(day.date)}
</div>
<div className="text-lg mb-1">
{getWeatherEmoji(day.weatherCode)}
</div>
<div
className="text-xs font-semibold"
style={{ color: "var(--text-primary)" }}
>
{day.maxTemp}° / {day.minTemp}°
</div>
</motion.div>
))}
</div>
</motion.div>
);
}

79
hooks/useHA.ts Normal file
View File

@@ -0,0 +1,79 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { HAStates } from "@/lib/ha";
export function useHA(refreshInterval = 10000) {
const [data, setData] = useState<HAStates | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const res = await fetch("/api/ha", { cache: "no-store" });
const json = await res.json();
setData(json);
} catch (e) {
console.error("HA fetch failed", e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
const id = setInterval(refresh, refreshInterval);
return () => clearInterval(id);
}, [refresh, refreshInterval]);
return { data, loading, refresh };
}
export function useWeather() {
const [weather, setWeather] = useState<any>(null);
useEffect(() => {
fetch("/api/weather")
.then((r) => r.json())
.then(setWeather)
.catch(() => {});
}, []);
return weather;
}
export function useTasks() {
const [tasks, setTasks] = useState<any[]>([]);
const [demo, setDemo] = useState(false);
const refresh = useCallback(async () => {
try {
const res = await fetch("/api/tasks", { cache: "no-store" });
const json = await res.json();
setTasks(json.tasks || []);
setDemo(!!json.demo);
} catch (e) {}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
return { tasks, setTasks, demo, refresh };
}
export function useSavings() {
const [savings, setSavings] = useState<any[]>([]);
const [demo, setDemo] = useState(false);
useEffect(() => {
fetch("/api/savings")
.then((r) => r.json())
.then((d) => {
setSavings(d.savings || []);
setDemo(!!d.demo);
})
.catch(() => {});
}, []);
return { savings, demo };
}

66
lib/api.ts Normal file
View File

@@ -0,0 +1,66 @@
export async function callHA(
domain: string,
service: string,
entity_id: string,
extra?: Record<string, any>
) {
const res = await fetch("/api/ha", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain, service, entity_id, ...extra }),
});
return res.json();
}
export async function toggleLight(entity_id: string, on: boolean) {
return callHA("light", on ? "turn_on" : "turn_off", entity_id);
}
export async function setLightBrightness(entity_id: string, brightness: number) {
return callHA("light", "turn_on", entity_id, { brightness });
}
export async function toggleFan(entity_id: string, on: boolean) {
return callHA("fan", on ? "turn_on" : "turn_off", entity_id);
}
export async function setFanPreset(entity_id: string, preset_mode: string) {
return callHA("fan", "set_preset_mode", entity_id, { preset_mode });
}
export async function setClimateTemp(entity_id: string, temperature: number) {
return callHA("climate", "set_temperature", entity_id, { temperature });
}
export async function fetchWeather() {
const res = await fetch("/api/weather", { next: { revalidate: 600 } });
return res.json();
}
export async function fetchTasks() {
const res = await fetch("/api/tasks", { cache: "no-store" });
return res.json();
}
export async function createTask(title: string) {
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
return res.json();
}
export async function toggleTask(id: number, done: boolean) {
const res = await fetch("/api/tasks", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, done }),
});
return res.json();
}
export async function fetchSavings() {
const res = await fetch("/api/savings", { next: { revalidate: 300 } });
return res.json();
}

19
lib/ha.ts Normal file
View File

@@ -0,0 +1,19 @@
export interface HAState {
entity_id: string;
state: string;
attributes: Record<string, any>;
}
export interface HAStates {
demo: boolean;
states: Record<string, HAState>;
}
export function getBrightnessPct(brightness?: number): number {
if (!brightness) return 0;
return Math.round((brightness / 255) * 100);
}
export function pctToBrightness(pct: number): number {
return Math.round((pct / 100) * 255);
}

27
next.config.mjs Normal file
View File

@@ -0,0 +1,27 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "wttr.in",
},
],
},
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "X-Content-Type-Options",
value: "nosniff",
},
],
},
];
},
};
export default nextConfig;

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "smart-home-tablet",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.3",
"react": "^18",
"react-dom": "^18",
"framer-motion": "^11.1.7",
"lucide-react": "^0.376.0",
"clsx": "^2.1.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

9
postcss.config.mjs Normal file
View File

@@ -0,0 +1,9 @@
/** @type {import('postcss').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

17
public/manifest.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "Smart Home Dashboard",
"short_name": "SmartHome",
"description": "Smart Home Tablet Dashboard",
"start_url": "/",
"display": "fullscreen",
"background_color": "#0a0a0f",
"theme_color": "#6366f1",
"orientation": "landscape",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
}
]
}

64
tailwind.config.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
dark: {
bg: "#0a0a0f",
card: "rgba(255,255,255,0.05)",
border: "rgba(255,255,255,0.08)",
},
light: {
bg: "#f0f0f8",
card: "rgba(255,255,255,0.8)",
border: "rgba(0,0,0,0.08)",
},
accent: {
indigo: "#6366f1",
violet: "#8b5cf6",
cyan: "#06b6d4",
emerald: "#10b981",
rose: "#f43f5e",
amber: "#f59e0b",
},
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
},
backdropBlur: {
xs: "2px",
},
animation: {
"orb-1": "orbMove1 20s ease-in-out infinite",
"orb-2": "orbMove2 25s ease-in-out infinite",
"orb-3": "orbMove3 30s ease-in-out infinite",
"pulse-slow": "pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite",
},
keyframes: {
orbMove1: {
"0%, 100%": { transform: "translate(0, 0) scale(1)" },
"33%": { transform: "translate(30px, -50px) scale(1.1)" },
"66%": { transform: "translate(-20px, 20px) scale(0.9)" },
},
orbMove2: {
"0%, 100%": { transform: "translate(0, 0) scale(1)" },
"33%": { transform: "translate(-40px, 30px) scale(1.05)" },
"66%": { transform: "translate(20px, -30px) scale(0.95)" },
},
orbMove3: {
"0%, 100%": { transform: "translate(0, 0) scale(1)" },
"50%": { transform: "translate(25px, 25px) scale(1.08)" },
},
},
},
},
plugins: [],
};
export default config;

26
tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}