feat: new layout, rooms row, fix weather+HA, fix BottomNav overflow
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:
Cosmo
2026-04-22 10:33:20 +00:00
parent ecf69400f6
commit 088cd35ea6
7 changed files with 375 additions and 152 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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)" }}