feat: new layout, rooms row, fix weather+HA, fix BottomNav overflow
All checks were successful
Deploy to Coolify / deploy (push) Successful in 5s
All checks were successful
Deploy to Coolify / deploy (push) Successful in 5s
- Remove TasksCard and SavingsCard from home tab - New grid layout: lights+thermostat row 1, purifier+weather row 2 - Add RoomsRow component with room navigation - Fix HA entity mapping: fan.zhimi_rmb1_9528_air_purifier → fan.air_purifier - Add real entity aliases for HA route - Fix weather route: add timeout, better error handling - Fix BottomNav: use 100dvh + flex-shrink-0 - TopBar: accept isDemo prop, show Demo badge in header - WeatherCard: compact prop, better loading/error states - globals.css: add no-scrollbar utility
This commit is contained in:
@@ -4,12 +4,17 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
const HA_URL = process.env.HA_URL || "http://192.168.31.110:8123";
|
const HA_URL = process.env.HA_URL || "http://192.168.31.110:8123";
|
||||||
const HA_TOKEN = process.env.HA_TOKEN || "";
|
const HA_TOKEN = process.env.HA_TOKEN || "";
|
||||||
|
|
||||||
// Mock data for demo mode
|
// Real entity IDs → normalized keys used in the dashboard
|
||||||
const MOCK_STATES = {
|
const REAL_ENTITY_ALIASES: Record<string, string> = {
|
||||||
|
"fan.zhimi_rmb1_9528_air_purifier": "fan.air_purifier",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock data for entities that don't exist yet (квартира строится)
|
||||||
|
const MOCK_MISSING: Record<string, any> = {
|
||||||
"light.living_room": {
|
"light.living_room": {
|
||||||
entity_id: "light.living_room",
|
entity_id: "light.living_room",
|
||||||
state: "on",
|
state: "off",
|
||||||
attributes: { brightness: 180, friendly_name: "Свет Гостиная" },
|
attributes: { brightness: 0, friendly_name: "Свет Гостиная" },
|
||||||
},
|
},
|
||||||
"light.bedroom": {
|
"light.bedroom": {
|
||||||
entity_id: "light.bedroom",
|
entity_id: "light.bedroom",
|
||||||
@@ -18,9 +23,9 @@ const MOCK_STATES = {
|
|||||||
},
|
},
|
||||||
"climate.thermostat": {
|
"climate.thermostat": {
|
||||||
entity_id: "climate.thermostat",
|
entity_id: "climate.thermostat",
|
||||||
state: "heat",
|
state: "off",
|
||||||
attributes: {
|
attributes: {
|
||||||
current_temperature: 21.5,
|
current_temperature: null,
|
||||||
temperature: 22,
|
temperature: 22,
|
||||||
friendly_name: "Термостат",
|
friendly_name: "Термостат",
|
||||||
},
|
},
|
||||||
@@ -36,9 +41,16 @@ const MOCK_STATES = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RELEVANT_KEYS = [
|
||||||
|
"light.living_room",
|
||||||
|
"light.bedroom",
|
||||||
|
"climate.thermostat",
|
||||||
|
"fan.air_purifier",
|
||||||
|
];
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
if (!HA_TOKEN) {
|
if (!HA_TOKEN) {
|
||||||
return NextResponse.json({ demo: true, states: MOCK_STATES });
|
return NextResponse.json({ demo: true, states: MOCK_MISSING });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -51,25 +63,40 @@ export async function GET(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`HA responded ${res.status}`);
|
if (!res.ok) throw new Error(`HA responded ${res.status}`);
|
||||||
const states = await res.json();
|
const states: any[] = await res.json();
|
||||||
|
|
||||||
const relevant = [
|
|
||||||
"light.living_room",
|
|
||||||
"light.bedroom",
|
|
||||||
"climate.thermostat",
|
|
||||||
"fan.air_purifier",
|
|
||||||
];
|
|
||||||
|
|
||||||
|
// Build filtered map with normalized keys
|
||||||
const filtered: Record<string, any> = {};
|
const filtered: Record<string, any> = {};
|
||||||
for (const s of states) {
|
for (const s of states) {
|
||||||
if (relevant.includes(s.entity_id)) {
|
// Direct match
|
||||||
|
if (RELEVANT_KEYS.includes(s.entity_id)) {
|
||||||
filtered[s.entity_id] = s;
|
filtered[s.entity_id] = s;
|
||||||
}
|
}
|
||||||
|
// Alias match (real entity → normalized key)
|
||||||
|
if (REAL_ENTITY_ALIASES[s.entity_id]) {
|
||||||
|
const normalizedKey = REAL_ENTITY_ALIASES[s.entity_id];
|
||||||
|
filtered[normalizedKey] = {
|
||||||
|
...s,
|
||||||
|
entity_id: normalizedKey,
|
||||||
|
_real_entity_id: s.entity_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ demo: false, states: filtered });
|
// Fill in missing entities with mock data (but mark them)
|
||||||
|
for (const key of RELEVANT_KEYS) {
|
||||||
|
if (!filtered[key]) {
|
||||||
|
filtered[key] = { ...MOCK_MISSING[key], _mock: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// demo = true only if ALL entities are mock (no real HA devices connected)
|
||||||
|
const hasAnyReal = RELEVANT_KEYS.some(k => !filtered[k]?._mock);
|
||||||
|
|
||||||
|
return NextResponse.json({ demo: !hasAnyReal, states: filtered });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return NextResponse.json({ demo: true, states: MOCK_STATES });
|
// Fallback to full mock
|
||||||
|
return NextResponse.json({ demo: true, states: MOCK_MISSING });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +107,11 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ success: true, demo: true });
|
return NextResponse.json({ success: true, demo: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve alias: if normalized key, find real entity
|
||||||
|
const realEntityId = Object.keys(REAL_ENTITY_ALIASES).find(
|
||||||
|
k => REAL_ENTITY_ALIASES[k] === entity_id
|
||||||
|
) || entity_id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${HA_URL}/api/services/${domain}/${service}`, {
|
const res = await fetch(`${HA_URL}/api/services/${domain}/${service}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -87,7 +119,7 @@ export async function POST(req: NextRequest) {
|
|||||||
Authorization: `Bearer ${HA_TOKEN}`,
|
Authorization: `Bearer ${HA_TOKEN}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ entity_id, ...serviceData }),
|
body: JSON.stringify({ entity_id: realEntityId, ...serviceData }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`HA responded ${res.status}`);
|
if (!res.ok) throw new Error(`HA responded ${res.status}`);
|
||||||
|
|||||||
@@ -1,27 +1,52 @@
|
|||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
function getWeatherCode(desc: string): string {
|
||||||
|
const d = desc.toLowerCase();
|
||||||
|
if (d.includes("overcast")) return "122";
|
||||||
|
if (d.includes("partly cloudy") || d.includes("partly") ) return "116";
|
||||||
|
if (d.includes("cloudy") || d.includes("cloud")) return "119";
|
||||||
|
if (d.includes("drizzle")) return "185";
|
||||||
|
if (d.includes("rain") || d.includes("shower")) return "305";
|
||||||
|
if (d.includes("snow") || d.includes("blizzard")) return "230";
|
||||||
|
if (d.includes("thunder") || d.includes("storm")) return "200";
|
||||||
|
if (d.includes("fog") || d.includes("mist")) return "248";
|
||||||
|
if (d.includes("sunny") || d.includes("clear")) return "113";
|
||||||
|
return "116";
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
"https://wttr.in/Saint+Petersburg?format=j1",
|
"https://wttr.in/Saint+Petersburg?format=j1",
|
||||||
{
|
{
|
||||||
next: { revalidate: 600 },
|
signal: controller.signal,
|
||||||
headers: { "User-Agent": "SmartHomeDashboard/1.0" },
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "SmartHomeDashboard/1.0",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
if (!res.ok) throw new Error("Weather fetch failed");
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`wttr responded ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
const current = data.current_condition[0];
|
const current = data.current_condition[0];
|
||||||
const days = data.weather.slice(0, 3).map((day: any) => {
|
const days = (data.weather || []).slice(0, 3).map((day: any) => {
|
||||||
const desc = day.hourly[4]?.weatherDesc?.[0]?.value || "";
|
const hourly = day.hourly?.[4] || day.hourly?.[0] || {};
|
||||||
|
const desc = hourly.weatherDesc?.[0]?.value || "";
|
||||||
|
const code = hourly.weatherCode || getWeatherCode(desc);
|
||||||
return {
|
return {
|
||||||
date: day.date,
|
date: day.date,
|
||||||
maxTemp: day.maxtempC,
|
maxTemp: day.maxtempC,
|
||||||
minTemp: day.mintempC,
|
minTemp: day.mintempC,
|
||||||
desc,
|
desc,
|
||||||
weatherCode: day.hourly[4]?.weatherCode || "113",
|
weatherCode: String(code),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,20 +55,22 @@ export async function GET() {
|
|||||||
feelsLike: current.FeelsLikeC,
|
feelsLike: current.FeelsLikeC,
|
||||||
humidity: current.humidity,
|
humidity: current.humidity,
|
||||||
desc: current.weatherDesc[0]?.value || "",
|
desc: current.weatherDesc[0]?.value || "",
|
||||||
weatherCode: current.weatherCode,
|
weatherCode: String(current.weatherCode),
|
||||||
windSpeed: current.windspeedKmph,
|
windSpeed: current.windspeedKmph,
|
||||||
forecast: days,
|
forecast: days,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error("Weather fetch error:", e);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
temp: "—",
|
temp: "—",
|
||||||
feelsLike: "—",
|
feelsLike: "—",
|
||||||
humidity: "—",
|
humidity: "—",
|
||||||
desc: "Нет данных",
|
desc: "Нет данных",
|
||||||
weatherCode: "113",
|
weatherCode: "116",
|
||||||
windSpeed: "—",
|
windSpeed: "—",
|
||||||
forecast: [],
|
forecast: [],
|
||||||
|
error: String(e),
|
||||||
},
|
},
|
||||||
{ status: 200 }
|
{ status: 200 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -226,3 +226,12 @@ input[type='range']::-moz-range-thumb {
|
|||||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||||
50% { transform: translate(-30px, -20px) scale(1.06); }
|
50% { transform: translate(-30px, -20px) scale(1.06); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar but allow scrolling */
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
143
app/page.tsx
143
app/page.tsx
@@ -9,9 +9,8 @@ import TemperatureCard from "@/components/cards/TemperatureCard";
|
|||||||
import AirPurifierCard from "@/components/cards/AirPurifierCard";
|
import AirPurifierCard from "@/components/cards/AirPurifierCard";
|
||||||
import TasksCard from "@/components/cards/TasksCard";
|
import TasksCard from "@/components/cards/TasksCard";
|
||||||
import WeatherCard from "@/components/cards/WeatherCard";
|
import WeatherCard from "@/components/cards/WeatherCard";
|
||||||
import SavingsCard from "@/components/cards/SavingsCard";
|
import RoomsRow from "@/components/RoomsRow";
|
||||||
import WeatherSavingsCard from "@/components/cards/WeatherSavingsCard";
|
import { useHA, useWeather, useTasks } from "@/hooks/useHA";
|
||||||
import { useHA, useWeather, useTasks, useSavings } from "@/hooks/useHA";
|
|
||||||
|
|
||||||
// Stagger container variants
|
// Stagger container variants
|
||||||
const containerVariants = {
|
const containerVariants = {
|
||||||
@@ -27,11 +26,11 @@ const cardVariants = {
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [isDark, setIsDark] = useState(true);
|
const [isDark, setIsDark] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState("home");
|
const [activeTab, setActiveTab] = useState("home");
|
||||||
|
const [roomFilter, setRoomFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: haData, loading: haLoading, refresh: refreshHA } = useHA(15000);
|
const { data: haData, loading: haLoading, refresh: refreshHA } = useHA(15000);
|
||||||
const weather = useWeather();
|
const weather = useWeather();
|
||||||
const { tasks, refresh: refreshTasks } = useTasks();
|
const { tasks, refresh: refreshTasks } = useTasks();
|
||||||
const { savings } = useSavings();
|
|
||||||
|
|
||||||
// Apply theme to html element
|
// Apply theme to html element
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -54,6 +53,11 @@ export default function Home() {
|
|||||||
setTimeout(refreshHA, 500);
|
setTimeout(refreshHA, 500);
|
||||||
}, [refreshHA]);
|
}, [refreshHA]);
|
||||||
|
|
||||||
|
const handleRoomClick = useCallback((roomId: string) => {
|
||||||
|
setActiveTab("devices");
|
||||||
|
setRoomFilter(roomId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const livingRoom = states["light.living_room"];
|
const livingRoom = states["light.living_room"];
|
||||||
const bedroom = states["light.bedroom"];
|
const bedroom = states["light.bedroom"];
|
||||||
const thermostat = states["climate.thermostat"];
|
const thermostat = states["climate.thermostat"];
|
||||||
@@ -61,8 +65,8 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative w-screen h-screen overflow-hidden no-select"
|
className="relative w-screen overflow-hidden no-select"
|
||||||
style={{ background: "var(--bg)" }}
|
style={{ height: "100dvh", background: "var(--bg)" }}
|
||||||
>
|
>
|
||||||
{/* Ambient orbs */}
|
{/* Ambient orbs */}
|
||||||
<div
|
<div
|
||||||
@@ -98,53 +102,36 @@ export default function Home() {
|
|||||||
animation: "orbMove3 34s ease-in-out infinite",
|
animation: "orbMove3 34s ease-in-out infinite",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
className="orb"
|
|
||||||
style={{
|
|
||||||
width: 300,
|
|
||||||
height: 300,
|
|
||||||
top: "55%",
|
|
||||||
right: "25%",
|
|
||||||
background: isDark ? "rgba(16,185,129,0.06)" : "rgba(16,185,129,0.04)",
|
|
||||||
animation: "orbMove4 26s ease-in-out infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Main layout */}
|
{/* Main layout — flex column fills 100dvh */}
|
||||||
<div className="relative z-10 h-full flex flex-col p-4 gap-3">
|
<div className="relative z-10 flex flex-col p-4 gap-3" style={{ height: "100dvh" }}>
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<TopBar
|
<TopBar
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
onToggleTheme={() => setIsDark(!isDark)}
|
onToggleTheme={() => setIsDark(!isDark)}
|
||||||
weather={weather}
|
weather={weather}
|
||||||
|
isDemo={isDemo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Demo badge */}
|
{/* Rooms row — only on home tab */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isDemo && (
|
{activeTab === "home" && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="text-center"
|
key="rooms-row"
|
||||||
initial={{ opacity: 0, y: -5 }}
|
initial={{ opacity: 0, height: 0 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<span
|
<RoomsRow onRoomClick={handleRoomClick} />
|
||||||
className="text-xs px-3 py-1 rounded-full font-semibold"
|
|
||||||
style={{
|
|
||||||
background: "rgba(245,158,11,0.12)",
|
|
||||||
color: "#f59e0b",
|
|
||||||
border: "1px solid rgba(245,158,11,0.25)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
🔌 Демо режим — HA Token не настроен
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Content area */}
|
{/* Content area — fills remaining space */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
|
|
||||||
{/* ═══════════════ HOME TAB ═══════════════ */}
|
{/* ═══════════════ HOME TAB ═══════════════ */}
|
||||||
{activeTab === "home" && (
|
{activeTab === "home" && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -155,9 +142,9 @@ export default function Home() {
|
|||||||
animate="visible"
|
animate="visible"
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
>
|
>
|
||||||
{/* 3-col grid:
|
{/*
|
||||||
Row 1: [Свет] [Климат] [Задачи]
|
Row 1: [Свет Гостиная] [Свет Спальня] [Климат]
|
||||||
Row 2: [Воздух ×2] [Погода+Накопления]
|
Row 2: [Очиститель ×2] [Погода]
|
||||||
*/}
|
*/}
|
||||||
<div
|
<div
|
||||||
className="h-full grid gap-3"
|
className="h-full grid gap-3"
|
||||||
@@ -178,6 +165,18 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Свет Спальня */}
|
||||||
|
<motion.div variants={cardVariants}>
|
||||||
|
<LightCard
|
||||||
|
entityId="light.bedroom"
|
||||||
|
name="Спальня"
|
||||||
|
state={bedroom?.state || "off"}
|
||||||
|
brightness={bedroom?.attributes?.brightness}
|
||||||
|
showSlider={false}
|
||||||
|
onUpdate={handleHAUpdate}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Термостат */}
|
{/* Термостат */}
|
||||||
<motion.div variants={cardVariants}>
|
<motion.div variants={cardVariants}>
|
||||||
<TemperatureCard
|
<TemperatureCard
|
||||||
@@ -189,11 +188,6 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Задачи */}
|
|
||||||
<motion.div variants={cardVariants}>
|
|
||||||
<TasksCard tasks={tasks} onUpdate={refreshTasks} />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Очиститель воздуха — 2 колонки */}
|
{/* Очиститель воздуха — 2 колонки */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={cardVariants}
|
variants={cardVariants}
|
||||||
@@ -207,9 +201,9 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Погода + Накопления — правый нижний */}
|
{/* Погода — правый нижний */}
|
||||||
<motion.div variants={cardVariants}>
|
<motion.div variants={cardVariants}>
|
||||||
<WeatherSavingsCard weather={weather} savings={savings} />
|
<WeatherCard weather={weather} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -219,15 +213,47 @@ export default function Home() {
|
|||||||
{activeTab === "devices" && (
|
{activeTab === "devices" && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="devices"
|
key="devices"
|
||||||
className="h-full grid gap-3"
|
className="h-full flex flex-col gap-3"
|
||||||
style={{
|
|
||||||
gridTemplateColumns: "1fr 1fr 1fr",
|
|
||||||
gridTemplateRows: "1fr 1fr",
|
|
||||||
}}
|
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
|
>
|
||||||
|
{/* Room filter pills */}
|
||||||
|
{roomFilter && (
|
||||||
|
<motion.div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
>
|
||||||
|
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Фильтр:
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setRoomFilter(null)}
|
||||||
|
className="text-xs px-3 py-1 rounded-full font-semibold flex items-center gap-1"
|
||||||
|
style={{
|
||||||
|
background: "rgba(99,102,241,0.15)",
|
||||||
|
color: "#6366f1",
|
||||||
|
border: "1px solid rgba(99,102,241,0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
living: "🛋️ Гостиная",
|
||||||
|
bedroom: "🛏️ Спальня",
|
||||||
|
kitchen: "🍳 Кухня",
|
||||||
|
bathroom: "🚿 Ванная",
|
||||||
|
}[roomFilter] || roomFilter}
|
||||||
|
<span style={{ opacity: 0.6 }}>✕</span>
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="flex-1 grid gap-3"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: "1fr 1fr 1fr",
|
||||||
|
gridTemplateRows: "1fr 1fr",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<motion.div variants={cardVariants}>
|
<motion.div variants={cardVariants}>
|
||||||
<LightCard
|
<LightCard
|
||||||
@@ -266,6 +292,10 @@ export default function Home() {
|
|||||||
onUpdate={handleHAUpdate}
|
onUpdate={handleHAUpdate}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
<motion.div variants={cardVariants}>
|
||||||
|
<WeatherCard weather={weather} compact />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -305,15 +335,16 @@ export default function Home() {
|
|||||||
className="text-sm mb-6"
|
className="text-sm mb-6"
|
||||||
style={{ color: "var(--text-secondary)" }}
|
style={{ color: "var(--text-secondary)" }}
|
||||||
>
|
>
|
||||||
Добавь HA Token в Coolify для подключения к умному дому
|
Умный дом подключён. Когда появятся устройства — они появятся автоматически.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-3 text-left">
|
<div className="space-y-3 text-left">
|
||||||
{[
|
{[
|
||||||
{ label: "HA URL", value: "Настроен" },
|
{ label: "HA URL", value: "✅ Настроен" },
|
||||||
{ label: "HA Token", value: isDemo ? "❌ Не настроен" : "✅ Настроен" },
|
{ label: "HA Token", value: isDemo ? "❌ Не настроен" : "✅ Настроен" },
|
||||||
{ label: "Vikunja", value: "✅ Подключён" },
|
{ label: "Vikunja", value: "✅ Подключён" },
|
||||||
{ label: "Pulse API", value: "✅ Подключён" },
|
{ label: "Pulse API", value: "✅ Подключён" },
|
||||||
|
{ label: "Очиститель", value: airPurifier?._mock ? "⚡ Демо" : "✅ Реальный" },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.label}
|
key={item.label}
|
||||||
@@ -338,9 +369,11 @@ export default function Home() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom nav */}
|
{/* Bottom nav — flex-shrink-0, always visible */}
|
||||||
|
<div className="flex-shrink-0" style={{ paddingBottom: "env(safe-area-inset-bottom, 0px)" }}>
|
||||||
<BottomNav active={activeTab} onChange={setActiveTab} />
|
<BottomNav active={activeTab} onChange={setActiveTab} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
77
components/RoomsRow.tsx
Normal file
77
components/RoomsRow.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
interface Room {
|
||||||
|
id: string;
|
||||||
|
emoji: string;
|
||||||
|
name: string;
|
||||||
|
deviceCount: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROOMS: Room[] = [
|
||||||
|
{ id: "living", emoji: "🛋️", name: "Гостиная", deviceCount: 3, color: "#6366f1" },
|
||||||
|
{ id: "bedroom", emoji: "🛏️", name: "Спальня", deviceCount: 2, color: "#8b5cf6" },
|
||||||
|
{ id: "kitchen", emoji: "🍳", name: "Кухня", deviceCount: 1, color: "#f59e0b" },
|
||||||
|
{ id: "bathroom", emoji: "🚿", name: "Ванная", deviceCount: 0, color: "#06b6d4" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onRoomClick?: (roomId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoomsRow({ onRoomClick }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 overflow-x-auto no-scrollbar pb-1">
|
||||||
|
{ROOMS.map((room, i) => (
|
||||||
|
<motion.button
|
||||||
|
key={room.id}
|
||||||
|
onClick={() => onRoomClick?.(room.id)}
|
||||||
|
className="flex-shrink-0 glass-card flex items-center gap-3 px-4 py-3 rounded-2xl cursor-pointer"
|
||||||
|
style={{
|
||||||
|
minWidth: "140px",
|
||||||
|
border: `1px solid ${room.color}22`,
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: i * 0.05 }}
|
||||||
|
whileTap={{ scale: 0.93 }}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 text-xl"
|
||||||
|
style={{ background: `${room.color}18` }}
|
||||||
|
>
|
||||||
|
{room.emoji}
|
||||||
|
</div>
|
||||||
|
<div className="text-left min-w-0">
|
||||||
|
<div
|
||||||
|
className="text-sm font-semibold truncate"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{room.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
{room.deviceCount}{" "}
|
||||||
|
{room.deviceCount === 1
|
||||||
|
? "устройство"
|
||||||
|
: room.deviceCount >= 2 && room.deviceCount <= 4
|
||||||
|
? "устройства"
|
||||||
|
: "устройств"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{room.deviceCount > 0 && (
|
||||||
|
<div
|
||||||
|
className="ml-auto w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ background: room.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,9 +22,10 @@ interface Props {
|
|||||||
isDark: boolean;
|
isDark: boolean;
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
weather: any;
|
weather: any;
|
||||||
|
isDemo?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TopBar({ isDark, onToggleTheme, weather }: Props) {
|
export default function TopBar({ isDark, onToggleTheme, weather, isDemo }: Props) {
|
||||||
const [time, setTime] = useState("");
|
const [time, setTime] = useState("");
|
||||||
const [date, setDate] = useState("");
|
const [date, setDate] = useState("");
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export default function TopBar({ isDark, onToggleTheme, weather }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="glass-card px-6 py-3 flex items-center justify-between no-select"
|
className="glass-card px-6 py-3 flex items-center justify-between no-select flex-shrink-0"
|
||||||
style={{ borderRadius: "20px" }}
|
style={{ borderRadius: "20px" }}
|
||||||
initial={{ opacity: 0, y: -20 }}
|
initial={{ opacity: 0, y: -20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
@@ -72,7 +73,7 @@ export default function TopBar({ isDark, onToggleTheme, weather }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Weather pill */}
|
{/* Weather pill */}
|
||||||
{weather && (
|
{weather && weather.temp !== "—" ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="flex items-center gap-3 px-5 py-2 rounded-2xl"
|
className="flex items-center gap-3 px-5 py-2 rounded-2xl"
|
||||||
style={{
|
style={{
|
||||||
@@ -101,21 +102,30 @@ export default function TopBar({ isDark, onToggleTheme, weather }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
) : weather ? (
|
||||||
|
<div
|
||||||
|
className="text-sm px-4 py-2 rounded-2xl"
|
||||||
|
style={{ color: "var(--text-secondary)", background: "rgba(255,255,255,0.04)" }}
|
||||||
|
>
|
||||||
|
🌤️ Загрузка...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Theme toggle */}
|
{/* Theme toggle + Demo badge */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{weather?.demo && (
|
{isDemo && (
|
||||||
<span
|
<motion.span
|
||||||
className="text-xs px-2.5 py-1 rounded-full font-semibold"
|
className="text-xs px-2.5 py-1 rounded-full font-semibold"
|
||||||
style={{
|
style={{
|
||||||
background: "rgba(245,158,11,0.15)",
|
background: "rgba(245,158,11,0.15)",
|
||||||
color: "#f59e0b",
|
color: "#f59e0b",
|
||||||
border: "1px solid rgba(245,158,11,0.3)",
|
border: "1px solid rgba(245,158,11,0.3)",
|
||||||
}}
|
}}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
>
|
>
|
||||||
Demo
|
Demo
|
||||||
</span>
|
</motion.span>
|
||||||
)}
|
)}
|
||||||
<ThemeToggle isDark={isDark} onToggle={onToggleTheme} />
|
<ThemeToggle isDark={isDark} onToggle={onToggleTheme} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,22 +18,28 @@ function getWeatherEmoji(code: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr + "T12:00:00");
|
||||||
return d.toLocaleDateString("ru-RU", { weekday: "short", day: "numeric" });
|
return d.toLocaleDateString("ru-RU", { weekday: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
weather: any;
|
weather: any;
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WeatherCard({ weather }: Props) {
|
export default function WeatherCard({ weather, compact }: Props) {
|
||||||
if (!weather) {
|
if (!weather) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="glass-card p-6 h-full flex items-center justify-center"
|
className="glass-card p-6 h-full flex flex-col items-center justify-center gap-3"
|
||||||
|
style={{
|
||||||
|
background: "rgba(59,130,246,0.03)",
|
||||||
|
border: "1px solid rgba(59,130,246,0.1)",
|
||||||
|
}}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
>
|
>
|
||||||
|
<div className="text-3xl animate-pulse">🌤️</div>
|
||||||
<div className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
<div className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||||
Загрузка погоды...
|
Загрузка погоды...
|
||||||
</div>
|
</div>
|
||||||
@@ -41,9 +47,30 @@ export default function WeatherCard({ weather }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasData = weather.temp && weather.temp !== "—";
|
||||||
|
|
||||||
|
if (!hasData) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="glass-card p-6 h-full flex flex-col"
|
className="glass-card p-6 h-full flex flex-col items-center justify-center gap-2"
|
||||||
|
style={{
|
||||||
|
background: "rgba(59,130,246,0.03)",
|
||||||
|
border: "1px solid rgba(59,130,246,0.1)",
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
>
|
||||||
|
<div className="text-3xl">🌧️</div>
|
||||||
|
<div className="text-sm text-center" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Нет данных о погоде
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="glass-card p-5 h-full flex flex-col"
|
||||||
style={{
|
style={{
|
||||||
background: "rgba(59,130,246,0.05)",
|
background: "rgba(59,130,246,0.05)",
|
||||||
border: "1px solid rgba(59,130,246,0.15)",
|
border: "1px solid rgba(59,130,246,0.15)",
|
||||||
@@ -55,26 +82,29 @@ export default function WeatherCard({ weather }: Props) {
|
|||||||
>
|
>
|
||||||
{/* Location */}
|
{/* Location */}
|
||||||
<div
|
<div
|
||||||
className="text-sm font-medium mb-3"
|
className="text-xs font-medium mb-2"
|
||||||
style={{ color: "var(--text-secondary)" }}
|
style={{ color: "var(--text-secondary)" }}
|
||||||
>
|
>
|
||||||
📍 Санкт-Петербург
|
📍 Санкт-Петербург
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current weather */}
|
{/* Current weather */}
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<div className="text-5xl leading-none">
|
<div className={compact ? "text-3xl leading-none" : "text-4xl leading-none"}>
|
||||||
{getWeatherEmoji(weather.weatherCode)}
|
{getWeatherEmoji(weather.weatherCode)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
className="font-black leading-none"
|
className="font-black leading-none"
|
||||||
style={{ fontSize: "44px", color: "var(--text-primary)" }}
|
style={{
|
||||||
|
fontSize: compact ? "36px" : "42px",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{weather.temp}°
|
{weather.temp}°
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-sm mt-1"
|
className="text-xs mt-0.5"
|
||||||
style={{ color: "var(--text-secondary)" }}
|
style={{ color: "var(--text-secondary)" }}
|
||||||
>
|
>
|
||||||
{weather.desc}
|
{weather.desc}
|
||||||
@@ -83,24 +113,21 @@ export default function WeatherCard({ weather }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="flex gap-4 mb-4">
|
<div className="flex gap-3 mb-3 flex-wrap">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1">
|
||||||
<Droplets size={14} color="#3b82f6" />
|
<Droplets size={12} color="#3b82f6" />
|
||||||
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
|
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
|
||||||
{weather.humidity}%
|
{weather.humidity}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1">
|
||||||
<Wind size={14} color="#8b5cf6" />
|
<Wind size={12} color="#8b5cf6" />
|
||||||
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
|
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
|
||||||
{weather.windSpeed} км/ч
|
{weather.windSpeed} км/ч
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="text-xs" style={{ color: "var(--text-secondary)" }}>
|
||||||
className="text-xs"
|
Ощущ. {weather.feelsLike}°
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
Ощущается {weather.feelsLike}°
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -109,22 +136,30 @@ export default function WeatherCard({ weather }: Props) {
|
|||||||
{(weather.forecast || []).slice(0, 3).map((day: any, i: number) => (
|
{(weather.forecast || []).slice(0, 3).map((day: any, i: number) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={day.date}
|
key={day.date}
|
||||||
className="flex-1 rounded-2xl p-2.5 text-center"
|
className="flex-1 rounded-xl p-2 text-center"
|
||||||
style={{
|
style={{
|
||||||
background: i === 0 ? "rgba(59,130,246,0.14)" : "rgba(255,255,255,0.04)",
|
background:
|
||||||
border: i === 0 ? "1px solid rgba(59,130,246,0.28)" : "1px solid rgba(255,255,255,0.06)",
|
i === 0
|
||||||
|
? "rgba(59,130,246,0.14)"
|
||||||
|
: "rgba(255,255,255,0.04)",
|
||||||
|
border:
|
||||||
|
i === 0
|
||||||
|
? "1px solid rgba(59,130,246,0.28)"
|
||||||
|
: "1px solid rgba(255,255,255,0.06)",
|
||||||
}}
|
}}
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.3 + i * 0.08 }}
|
transition={{ delay: 0.3 + i * 0.08 }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="text-xs mb-1 font-medium"
|
className="text-xs mb-0.5 font-medium"
|
||||||
style={{ color: "var(--text-secondary)" }}
|
style={{ color: "var(--text-secondary)" }}
|
||||||
>
|
>
|
||||||
{i === 0 ? "Сег." : formatDate(day.date)}
|
{i === 0 ? "Сег." : formatDate(day.date)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg mb-1">{getWeatherEmoji(day.weatherCode)}</div>
|
<div className="text-base mb-0.5">
|
||||||
|
{getWeatherEmoji(day.weatherCode)}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-xs font-bold"
|
className="text-xs font-bold"
|
||||||
style={{ color: "var(--text-primary)" }}
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
|||||||
Reference in New Issue
Block a user