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:
17
.gitea/workflows/deploy.yml
Normal file
17
.gitea/workflows/deploy.yml
Normal 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
35
.gitignore
vendored
Normal 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
30
Dockerfile
Normal 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
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>
|
||||
);
|
||||
}
|
||||
108
components/AddTaskModal.tsx
Normal file
108
components/AddTaskModal.tsx
Normal 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
68
components/BottomNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
components/ThemeToggle.tsx
Normal file
43
components/ThemeToggle.tsx
Normal 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
129
components/TopBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
components/cards/AirPurifierCard.tsx
Normal file
147
components/cards/AirPurifierCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
components/cards/LightCard.tsx
Normal file
150
components/cards/LightCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
components/cards/SavingsCard.tsx
Normal file
119
components/cards/SavingsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
components/cards/TasksCard.tsx
Normal file
154
components/cards/TasksCard.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
132
components/cards/TemperatureCard.tsx
Normal file
132
components/cards/TemperatureCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
components/cards/WeatherCard.tsx
Normal file
140
components/cards/WeatherCard.tsx
Normal 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
79
hooks/useHA.ts
Normal 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
66
lib/api.ts
Normal 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
19
lib/ha.ts
Normal 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
27
next.config.mjs
Normal 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
28
package.json
Normal 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
9
postcss.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
17
public/manifest.json
Normal file
17
public/manifest.json
Normal 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
64
tailwind.config.ts
Normal 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
26
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user