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:
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user