commit 9044869fa4f5ad27cc7360f1c4b1a49dd6405acc Author: Cosmo Date: Wed Apr 22 10:00:41 2026 +0000 feat: initial smart home dashboard - 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 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..634dd6e --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72e1136 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..96f0853 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/api/ha/route.ts b/app/api/ha/route.ts new file mode 100644 index 0000000..927a4d7 --- /dev/null +++ b/app/api/ha/route.ts @@ -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 = {}; + 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 }); + } +} diff --git a/app/api/savings/route.ts b/app/api/savings/route.ts new file mode 100644 index 0000000..5c0dacd --- /dev/null +++ b/app/api/savings/route.ts @@ -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 { + 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 }); + } +} diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts new file mode 100644 index 0000000..9d41225 --- /dev/null +++ b/app/api/tasks/route.ts @@ -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 }); + } +} diff --git a/app/api/weather/route.ts b/app/api/weather/route.ts new file mode 100644 index 0000000..9d7b556 --- /dev/null +++ b/app/api/weather/route.ts @@ -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 } + ); + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..0c7f81f --- /dev/null +++ b/app/globals.css @@ -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; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..3fe4599 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + + + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..2ee4489 --- /dev/null +++ b/app/page.tsx @@ -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 ( +
+ {/* Ambient orbs */} +
+
+
+ + {/* Main layout */} +
+ {/* Top bar */} + setIsDark(!isDark)} + weather={weather} + /> + + {/* Demo badge */} + + {isDemo && ( + + + 🔌 Демо режим — HA Token не настроен + + + )} + + + {/* Content area */} +
+ + {activeTab === "home" && ( + + {/* Row 1 */} + {/* Свет Гостиная */} + + + {/* Свет Спальня */} + + + {/* Температура */} + + + {/* Очиститель воздуха */} + + + {/* Row 2 */} + {/* Задачи — 2 колонки */} +
+ +
+ + {/* Погода */} + + + {/* Накопления */} + +
+ )} + + {activeTab === "devices" && ( + + + + +
+ +
+
+ )} + + {activeTab === "tasks" && ( + + + + )} + + {activeTab === "settings" && ( + +
+
⚙️
+

+ Настройки +

+

+ Добавь HA Token в Coolify для подключения к умному дому +

+ +
+ {[ + { + 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) => ( +
+ + {item.label} + + + {item.value} + +
+ ))} +
+
+
+ )} +
+
+ + {/* Bottom nav */} + +
+
+ ); +} diff --git a/components/AddTaskModal.tsx b/components/AddTaskModal.tsx new file mode 100644 index 0000000..c21cfbd --- /dev/null +++ b/components/AddTaskModal.tsx @@ -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 ( + + {open && ( + + e.stopPropagation()} + > +
+

+ Новая задача +

+ + + +
+ + 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)", + }} + /> + +
+ + Отмена + + + + Добавить + +
+
+
+ )} +
+ ); +} diff --git a/components/BottomNav.tsx b/components/BottomNav.tsx new file mode 100644 index 0000000..3c7d66e --- /dev/null +++ b/components/BottomNav.tsx @@ -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 ( + + {TABS.map((tab) => { + const Icon = tab.icon; + const isActive = active === tab.id; + return ( + 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 && ( + + )} + + + {tab.label} + + + ); + })} + + ); +} diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx new file mode 100644 index 0000000..1734f6e --- /dev/null +++ b/components/ThemeToggle.tsx @@ -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 ( + + + {isDark ? ( + + ) : ( + + )} + + + ); +} diff --git a/components/TopBar.tsx b/components/TopBar.tsx new file mode 100644 index 0000000..d524ba0 --- /dev/null +++ b/components/TopBar.tsx @@ -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 ( + + {/* Time & Date */} +
+ + {time} + + + {date} + +
+ + {/* Weather */} + {weather && ( + + + {getWeatherEmoji(weather.weatherCode)} + +
+
+ {weather.temp}°C +
+
+ Ощущается {weather.feelsLike}° +
+
+
+ {weather.desc} +
+
+ )} + + {/* Theme toggle */} +
+ {weather?.demo && ( + + Demo + + )} + +
+
+ ); +} diff --git a/components/cards/AirPurifierCard.tsx b/components/cards/AirPurifierCard.tsx new file mode 100644 index 0000000..5026993 --- /dev/null +++ b/components/cards/AirPurifierCard.tsx @@ -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 ( + +
+
+
+ + + +
+
+ Очиститель воздуха +
+
+ {isOn ? currentMode : "Выключен"} +
+
+ + +
+ +
+ + {isOn && ( + + {MODES.map((mode) => ( + 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} + + ))} + + )} +
+ ); +} diff --git a/components/cards/LightCard.tsx b/components/cards/LightCard.tsx new file mode 100644 index 0000000..f117d94 --- /dev/null +++ b/components/cards/LightCard.tsx @@ -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 ( + +
+
+
+ +
+
+ {name} +
+
+ {isOn ? (showSlider ? `${localBrightness}%` : "Включён") : "Выключен"} +
+
+ + {/* Toggle */} + +
+ +
+ + {showSlider && isOn && ( + +
+ Яркость + {localBrightness}% +
+
+
+ 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" }} + /> +
+ + )} + + ); +} diff --git a/components/cards/SavingsCard.tsx b/components/cards/SavingsCard.tsx new file mode 100644 index 0000000..08b8e29 --- /dev/null +++ b/components/cards/SavingsCard.tsx @@ -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 ( + +
+ + + Накопления + +
+ +
+ {savings.map((s, i) => { + const pct = Math.min( + 100, + Math.round((s.current_amount / s.target_amount) * 100) + ); + const color = s.color || "#6366f1"; + + return ( + +
+
+ {s.icon && {s.icon}} + + {s.name} + +
+ + {pct}% + +
+ +
+ +
+ +
+ {formatAmount(s.current_amount)} ₽ + цель: {formatAmount(s.target_amount)} ₽ +
+
+ ); + })} + + {savings.length === 0 && ( +
+ Нет данных о накоплениях +
+ )} +
+
+ ); +} diff --git a/components/cards/TasksCard.tsx b/components/cards/TasksCard.tsx new file mode 100644 index 0000000..546cbcd --- /dev/null +++ b/components/cards/TasksCard.tsx @@ -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(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 ( + <> + +
+
+
+ + + Задачи сегодня + +
+
+ {pending.length} осталось из {localTasks.length} +
+
+ 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 }} + > + + +
+ +
+ + {localTasks.length === 0 && ( + +
🎉
+
+ Всё сделано! +
+
+ )} + + {localTasks.map((task) => ( + handleToggle(task)} + whileTap={{ scale: 0.97 }} + > + {task.done ? ( + + ) : ( + + )} + + {task.title} + + + ))} +
+
+
+ + setModalOpen(false)} + onAdd={handleAdd} + /> + + ); +} diff --git a/components/cards/TemperatureCard.tsx b/components/cards/TemperatureCard.tsx new file mode 100644 index 0000000..d54cf09 --- /dev/null +++ b/components/cards/TemperatureCard.tsx @@ -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 ( + +
+
+
+ +
+
+ Термостат +
+
+ {isHeating ? "Нагрев" : "Ожидание"} +
+
+ +
+
+ {currentTemp?.toFixed(1) ?? "—"}° +
+
+ текущая +
+
+
+ +
+
+ Целевая температура +
+
+ 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 }} + > + + + + {target}° + + 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 }} + > + + +
+
+
+ ); +} diff --git a/components/cards/WeatherCard.tsx b/components/cards/WeatherCard.tsx new file mode 100644 index 0000000..fc4bc10 --- /dev/null +++ b/components/cards/WeatherCard.tsx @@ -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 ( + +
+ Загрузка погоды... +
+
+ ); + } + + return ( + +
+
+
+ 🌍 Санкт-Петербург +
+
+ + {getWeatherEmoji(weather.weatherCode)} + +
+
+ {weather.temp}°C +
+
+ {weather.desc} +
+
+
+
+
+
+ + + {weather.humidity}% + +
+
+ + + {weather.windSpeed} км/ч + +
+
+
+ + {/* Forecast */} +
+ {(weather.forecast || []).map((day: any, i: number) => ( + +
+ {i === 0 ? "Сегодня" : formatDate(day.date)} +
+
+ {getWeatherEmoji(day.weatherCode)} +
+
+ {day.maxTemp}° / {day.minTemp}° +
+
+ ))} +
+
+ ); +} diff --git a/hooks/useHA.ts b/hooks/useHA.ts new file mode 100644 index 0000000..1971063 --- /dev/null +++ b/hooks/useHA.ts @@ -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(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(null); + + useEffect(() => { + fetch("/api/weather") + .then((r) => r.json()) + .then(setWeather) + .catch(() => {}); + }, []); + + return weather; +} + +export function useTasks() { + const [tasks, setTasks] = useState([]); + 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([]); + 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 }; +} diff --git a/lib/api.ts b/lib/api.ts new file mode 100644 index 0000000..27fad8c --- /dev/null +++ b/lib/api.ts @@ -0,0 +1,66 @@ +export async function callHA( + domain: string, + service: string, + entity_id: string, + extra?: Record +) { + 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(); +} diff --git a/lib/ha.ts b/lib/ha.ts new file mode 100644 index 0000000..630380b --- /dev/null +++ b/lib/ha.ts @@ -0,0 +1,19 @@ +export interface HAState { + entity_id: string; + state: string; + attributes: Record; +} + +export interface HAStates { + demo: boolean; + states: Record; +} + +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); +} diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..60ba6fd --- /dev/null +++ b/next.config.mjs @@ -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; diff --git a/package.json b/package.json new file mode 100644 index 0000000..1254462 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..73a0f54 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('postcss').Config} */ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +export default config; diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..4fc9c4b --- /dev/null +++ b/public/manifest.json @@ -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" + } + ] +} diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..6a12b6d --- /dev/null +++ b/tailwind.config.ts @@ -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; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e7ff90f --- /dev/null +++ b/tsconfig.json @@ -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"] +}