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:
108
components/AddTaskModal.tsx
Normal file
108
components/AddTaskModal.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Plus } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: (title: string) => void;
|
||||
}
|
||||
|
||||
export default function AddTaskModal({ open, onClose, onAdd }: Props) {
|
||||
const [title, setTitle] = useState("");
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!title.trim()) return;
|
||||
onAdd(title.trim());
|
||||
setTitle("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="modal-backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
className="glass-card p-6 w-96 mx-4"
|
||||
style={{ border: "1px solid rgba(99,102,241,0.3)" }}
|
||||
initial={{ opacity: 0, scale: 0.85, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.85, y: 20 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3
|
||||
className="text-lg font-semibold"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
Новая задача
|
||||
</h3>
|
||||
<motion.button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<X size={16} color="var(--text-secondary)" />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
|
||||
placeholder="Название задачи..."
|
||||
autoFocus
|
||||
className="w-full px-4 py-3 rounded-xl text-sm outline-none mb-4"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<motion.button
|
||||
onClick={onClose}
|
||||
className="flex-1 py-3 rounded-xl text-sm font-medium"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
color: "var(--text-secondary)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
}}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
Отмена
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={handleSubmit}
|
||||
className="flex-1 py-3 rounded-xl text-sm font-semibold flex items-center justify-center gap-2"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||
color: "white",
|
||||
boxShadow: "0 0 20px rgba(99,102,241,0.4)",
|
||||
}}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
disabled={!title.trim()}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Добавить
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
68
components/BottomNav.tsx
Normal file
68
components/BottomNav.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Home, Cpu, CheckSquare, Settings } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
active: string;
|
||||
onChange: (tab: string) => void;
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: "home", label: "Главная", icon: Home },
|
||||
{ id: "devices", label: "Устройства", icon: Cpu },
|
||||
{ id: "tasks", label: "Задачи", icon: CheckSquare },
|
||||
{ id: "settings", label: "Настройки", icon: Settings },
|
||||
];
|
||||
|
||||
export default function BottomNav({ active, onChange }: Props) {
|
||||
return (
|
||||
<motion.div
|
||||
className="glass-card px-4 py-2 flex items-center justify-around no-select"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = active === tab.id;
|
||||
return (
|
||||
<motion.button
|
||||
key={tab.id}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className="flex flex-col items-center gap-1 px-6 py-2 rounded-xl relative"
|
||||
whileTap={{ scale: 0.88 }}
|
||||
style={{
|
||||
background: isActive
|
||||
? "rgba(99,102,241,0.15)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-xl"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.1))",
|
||||
border: "1px solid rgba(99,102,241,0.3)",
|
||||
}}
|
||||
layoutId="navActive"
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
size={22}
|
||||
color={isActive ? "#6366f1" : "var(--text-secondary)"}
|
||||
/>
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color: isActive ? "#6366f1" : "var(--text-secondary)" }}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
43
components/ThemeToggle.tsx
Normal file
43
components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Sun, Moon } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
isDark: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export default function ThemeToggle({ isDark, onToggle }: Props) {
|
||||
return (
|
||||
<motion.button
|
||||
onClick={onToggle}
|
||||
className="relative w-14 h-7 rounded-full flex items-center cursor-pointer no-select"
|
||||
style={{
|
||||
background: isDark
|
||||
? "rgba(99,102,241,0.3)"
|
||||
: "rgba(245,158,11,0.3)",
|
||||
border: `1px solid ${isDark ? "rgba(99,102,241,0.5)" : "rgba(245,158,11,0.5)"}`,
|
||||
}}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute w-5 h-5 rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
background: isDark ? "#6366f1" : "#f59e0b",
|
||||
boxShadow: isDark
|
||||
? "0 0 8px rgba(99,102,241,0.8)"
|
||||
: "0 0 8px rgba(245,158,11,0.8)",
|
||||
}}
|
||||
animate={{ x: isDark ? 2 : 28 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
>
|
||||
{isDark ? (
|
||||
<Moon size={10} color="white" />
|
||||
) : (
|
||||
<Sun size={10} color="white" />
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
129
components/TopBar.tsx
Normal file
129
components/TopBar.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
|
||||
function getWeatherEmoji(code: string): string {
|
||||
const c = parseInt(code);
|
||||
if (c === 113) return "☀️";
|
||||
if (c === 116) return "⛅";
|
||||
if (c === 119 || c === 122) return "☁️";
|
||||
if (c >= 176 && c <= 182) return "🌦️";
|
||||
if (c >= 185 && c <= 200) return "🌧️";
|
||||
if (c >= 200 && c <= 210) return "⛈️";
|
||||
if (c >= 210 && c <= 260) return "❄️";
|
||||
if (c >= 260 && c <= 300) return "🌨️";
|
||||
if (c >= 300 && c <= 400) return "🌧️";
|
||||
return "🌤️";
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isDark: boolean;
|
||||
onToggleTheme: () => void;
|
||||
weather: any;
|
||||
}
|
||||
|
||||
export default function TopBar({ isDark, onToggleTheme, weather }: Props) {
|
||||
const [time, setTime] = useState("");
|
||||
const [date, setDate] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const now = new Date();
|
||||
setTime(
|
||||
now.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })
|
||||
);
|
||||
setDate(
|
||||
now.toLocaleDateString("ru-RU", {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})
|
||||
);
|
||||
};
|
||||
update();
|
||||
const id = setInterval(update, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="glass-card px-6 py-3 flex items-center justify-between no-select"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Time & Date */}
|
||||
<div className="flex items-baseline gap-4">
|
||||
<span
|
||||
className="text-5xl font-bold tracking-tight"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{time}
|
||||
</span>
|
||||
<span
|
||||
className="text-sm font-medium capitalize"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
{date}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Weather */}
|
||||
{weather && (
|
||||
<motion.div
|
||||
className="flex items-center gap-3 px-4 py-2 rounded-xl"
|
||||
style={{
|
||||
background: "rgba(99,102,241,0.1)",
|
||||
border: "1px solid rgba(99,102,241,0.2)",
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<span className="text-2xl">
|
||||
{getWeatherEmoji(weather.weatherCode)}
|
||||
</span>
|
||||
<div>
|
||||
<div
|
||||
className="text-xl font-bold"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{weather.temp}°C
|
||||
</div>
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Ощущается {weather.feelsLike}°
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-xs ml-2 max-w-[80px] text-center leading-tight"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
{weather.desc}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Theme toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
{weather?.demo && (
|
||||
<span
|
||||
className="text-xs px-2 py-1 rounded-full"
|
||||
style={{
|
||||
background: "rgba(245,158,11,0.15)",
|
||||
color: "#f59e0b",
|
||||
border: "1px solid rgba(245,158,11,0.3)",
|
||||
}}
|
||||
>
|
||||
Demo
|
||||
</span>
|
||||
)}
|
||||
<ThemeToggle isDark={isDark} onToggle={onToggleTheme} />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
147
components/cards/AirPurifierCard.tsx
Normal file
147
components/cards/AirPurifierCard.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Wind } from "lucide-react";
|
||||
import { toggleFan, setFanPreset } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
entityId: string;
|
||||
state: string;
|
||||
presetMode?: string;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
const MODES = [
|
||||
{ id: "Auto", label: "Авто", color: "#06b6d4" },
|
||||
{ id: "Night", label: "Ночь", color: "#6366f1" },
|
||||
{ id: "High", label: "Макс", color: "#f43f5e" },
|
||||
];
|
||||
|
||||
export default function AirPurifierCard({
|
||||
entityId,
|
||||
state,
|
||||
presetMode,
|
||||
onUpdate,
|
||||
}: Props) {
|
||||
const isOn = state === "on";
|
||||
const [currentMode, setCurrentMode] = useState(presetMode || "Auto");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const handleToggle = useCallback(async () => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
await toggleFan(entityId, !isOn);
|
||||
onUpdate();
|
||||
setPending(false);
|
||||
}, [entityId, isOn, pending, onUpdate]);
|
||||
|
||||
const handleMode = useCallback(
|
||||
async (mode: string) => {
|
||||
setCurrentMode(mode);
|
||||
await setFanPreset(entityId, mode);
|
||||
onUpdate();
|
||||
},
|
||||
[entityId, onUpdate]
|
||||
);
|
||||
|
||||
const activeColor =
|
||||
MODES.find((m) => m.id === currentMode)?.color || "#06b6d4";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="glass-card p-5 h-full flex flex-col justify-between"
|
||||
style={
|
||||
isOn
|
||||
? {
|
||||
background: `rgba(6,182,212,0.06)`,
|
||||
border: `1px solid rgba(6,182,212,0.2)`,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center mb-3"
|
||||
style={{
|
||||
background: isOn
|
||||
? "rgba(6,182,212,0.15)"
|
||||
: "rgba(255,255,255,0.06)",
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
animate={isOn ? { rotate: 360 } : { rotate: 0 }}
|
||||
transition={
|
||||
isOn
|
||||
? { duration: 3, repeat: Infinity, ease: "linear" }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<Wind
|
||||
size={20}
|
||||
color={isOn ? "#06b6d4" : "var(--text-secondary)"}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
<div
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
Очиститель воздуха
|
||||
</div>
|
||||
<div
|
||||
className="text-xs mt-0.5"
|
||||
style={{ color: isOn ? activeColor : "var(--text-secondary)" }}
|
||||
>
|
||||
{isOn ? currentMode : "Выключен"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className={`toggle-track ${isOn ? "toggle-on" : ""}`}
|
||||
style={{ background: isOn ? "#06b6d4" : "rgba(255,255,255,0.1)" }}
|
||||
onClick={handleToggle}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<div className="toggle-thumb" />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{isOn && (
|
||||
<motion.div
|
||||
className="flex gap-2 mt-4"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{MODES.map((mode) => (
|
||||
<motion.button
|
||||
key={mode.id}
|
||||
onClick={() => handleMode(mode.id)}
|
||||
className="flex-1 py-2 rounded-xl text-xs font-medium"
|
||||
style={
|
||||
currentMode === mode.id
|
||||
? {
|
||||
background: `${mode.color}25`,
|
||||
border: `1px solid ${mode.color}60`,
|
||||
color: mode.color,
|
||||
}
|
||||
: {
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
color: "var(--text-secondary)",
|
||||
}
|
||||
}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
{mode.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
150
components/cards/LightCard.tsx
Normal file
150
components/cards/LightCard.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Lightbulb } from "lucide-react";
|
||||
import { toggleLight, setLightBrightness } from "@/lib/api";
|
||||
import { getBrightnessPct, pctToBrightness } from "@/lib/ha";
|
||||
|
||||
interface Props {
|
||||
entityId: string;
|
||||
name: string;
|
||||
state: string;
|
||||
brightness?: number;
|
||||
showSlider?: boolean;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export default function LightCard({
|
||||
entityId,
|
||||
name,
|
||||
state,
|
||||
brightness,
|
||||
showSlider = false,
|
||||
onUpdate,
|
||||
}: Props) {
|
||||
const isOn = state === "on";
|
||||
const brightPct = getBrightnessPct(brightness);
|
||||
const [localBrightness, setLocalBrightness] = useState(brightPct || 70);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const handleToggle = useCallback(async () => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
await toggleLight(entityId, !isOn);
|
||||
onUpdate();
|
||||
setPending(false);
|
||||
}, [entityId, isOn, pending, onUpdate]);
|
||||
|
||||
const handleBrightnessChange = useCallback(
|
||||
async (val: number) => {
|
||||
setLocalBrightness(val);
|
||||
await setLightBrightness(entityId, pctToBrightness(val));
|
||||
onUpdate();
|
||||
},
|
||||
[entityId, onUpdate]
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="glass-card p-5 h-full flex flex-col justify-between"
|
||||
style={
|
||||
isOn
|
||||
? {
|
||||
background: "rgba(245,158,11,0.08)",
|
||||
border: "1px solid rgba(245,158,11,0.2)",
|
||||
boxShadow: "0 0 30px rgba(245,158,11,0.1)",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center mb-3"
|
||||
style={{
|
||||
background: isOn
|
||||
? "rgba(245,158,11,0.2)"
|
||||
: "rgba(255,255,255,0.06)",
|
||||
}}
|
||||
>
|
||||
<Lightbulb
|
||||
size={20}
|
||||
color={isOn ? "#f59e0b" : "var(--text-secondary)"}
|
||||
fill={isOn ? "#f59e0b" : "none"}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs mt-0.5"
|
||||
style={{ color: isOn ? "#f59e0b" : "var(--text-secondary)" }}
|
||||
>
|
||||
{isOn ? (showSlider ? `${localBrightness}%` : "Включён") : "Выключен"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<motion.div
|
||||
className={`toggle-track ${isOn ? "toggle-on" : ""}`}
|
||||
style={{
|
||||
background: isOn
|
||||
? "#f59e0b"
|
||||
: "rgba(255,255,255,0.1)",
|
||||
}}
|
||||
onClick={handleToggle}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<div className="toggle-thumb" />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{showSlider && isOn && (
|
||||
<motion.div
|
||||
className="mt-4"
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<div
|
||||
className="text-xs mb-2 flex justify-between"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
<span>Яркость</span>
|
||||
<span>{localBrightness}%</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-l-full pointer-events-none"
|
||||
style={{
|
||||
width: `${localBrightness}%`,
|
||||
background: "linear-gradient(90deg, rgba(245,158,11,0.4), rgba(245,158,11,0.8))",
|
||||
height: "6px",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={5}
|
||||
max={100}
|
||||
value={localBrightness}
|
||||
onChange={(e) => setLocalBrightness(parseInt(e.target.value))}
|
||||
onMouseUp={(e) => handleBrightnessChange(parseInt((e.target as HTMLInputElement).value))}
|
||||
onTouchEnd={(e) => handleBrightnessChange(parseInt((e.target as HTMLInputElement).value))}
|
||||
className="w-full relative z-10"
|
||||
style={{ background: "transparent" }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
119
components/cards/SavingsCard.tsx
Normal file
119
components/cards/SavingsCard.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { PiggyBank } from "lucide-react";
|
||||
|
||||
interface Saving {
|
||||
id: number;
|
||||
name: string;
|
||||
current_amount: number;
|
||||
target_amount: number;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
savings: Saving[];
|
||||
}
|
||||
|
||||
function formatAmount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export default function SavingsCard({ savings }: Props) {
|
||||
return (
|
||||
<motion.div
|
||||
className="glass-card p-5 h-full flex flex-col"
|
||||
style={{
|
||||
background: "rgba(99,102,241,0.04)",
|
||||
border: "1px solid rgba(99,102,241,0.12)",
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<PiggyBank size={18} color="#6366f1" />
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
Накопления
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4">
|
||||
{savings.map((s, i) => {
|
||||
const pct = Math.min(
|
||||
100,
|
||||
Math.round((s.current_amount / s.target_amount) * 100)
|
||||
);
|
||||
const color = s.color || "#6366f1";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={s.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{s.icon && <span className="text-base">{s.icon}</span>}
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{s.name}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-semibold"
|
||||
style={{ color }}
|
||||
>
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="progress-bar">
|
||||
<motion.div
|
||||
className="progress-fill"
|
||||
style={{
|
||||
background: `linear-gradient(90deg, ${color}aa, ${color})`,
|
||||
width: `${pct}%`,
|
||||
}}
|
||||
initial={{ width: "0%" }}
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{
|
||||
duration: 1,
|
||||
delay: i * 0.2,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-between text-xs mt-1"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
<span>{formatAmount(s.current_amount)} ₽</span>
|
||||
<span>цель: {formatAmount(s.target_amount)} ₽</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
{savings.length === 0 && (
|
||||
<div
|
||||
className="text-center py-6 text-sm"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Нет данных о накоплениях
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
154
components/cards/TasksCard.tsx
Normal file
154
components/cards/TasksCard.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { CheckSquare, Square, Plus } from "lucide-react";
|
||||
import { createTask, toggleTask } from "@/lib/api";
|
||||
import AddTaskModal from "../AddTaskModal";
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
title: string;
|
||||
done: boolean;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tasks: Task[];
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export default function TasksCard({ tasks, onUpdate }: Props) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [localTasks, setLocalTasks] = useState<Task[]>(tasks);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (task: Task) => {
|
||||
setLocalTasks((prev) =>
|
||||
prev.map((t) => (t.id === task.id ? { ...t, done: !t.done } : t))
|
||||
);
|
||||
await toggleTask(task.id, !task.done);
|
||||
onUpdate();
|
||||
},
|
||||
[onUpdate]
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
async (title: string) => {
|
||||
const newTask = { id: Date.now(), title, done: false };
|
||||
setLocalTasks((prev) => [newTask, ...prev]);
|
||||
await createTask(title);
|
||||
onUpdate();
|
||||
},
|
||||
[onUpdate]
|
||||
);
|
||||
|
||||
const pending = localTasks.filter((t) => !t.done);
|
||||
const done = localTasks.filter((t) => t.done);
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
className="glass-card p-5 h-full flex flex-col"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
whileHover={{ scale: 1.005 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckSquare size={18} color="#6366f1" />
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
Задачи сегодня
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="text-xs mt-0.5"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
{pending.length} осталось из {localTasks.length}
|
||||
</div>
|
||||
</div>
|
||||
<motion.button
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||
boxShadow: "0 0 12px rgba(99,102,241,0.4)",
|
||||
}}
|
||||
whileTap={{ scale: 0.85 }}
|
||||
>
|
||||
<Plus size={16} color="white" />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
|
||||
<AnimatePresence>
|
||||
{localTasks.length === 0 && (
|
||||
<motion.div
|
||||
className="text-center py-8"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<div className="text-3xl mb-2">🎉</div>
|
||||
<div
|
||||
className="text-sm"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Всё сделано!
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{localTasks.map((task) => (
|
||||
<motion.div
|
||||
key={task.id}
|
||||
layout
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 10, height: 0 }}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer"
|
||||
style={{
|
||||
background: task.done
|
||||
? "rgba(16,185,129,0.06)"
|
||||
: "rgba(255,255,255,0.04)",
|
||||
border: task.done
|
||||
? "1px solid rgba(16,185,129,0.15)"
|
||||
: "1px solid rgba(255,255,255,0.06)",
|
||||
}}
|
||||
onClick={() => handleToggle(task)}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
{task.done ? (
|
||||
<CheckSquare size={16} color="#10b981" />
|
||||
) : (
|
||||
<Square size={16} color="var(--text-secondary)" />
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-medium flex-1 leading-snug"
|
||||
style={{
|
||||
color: task.done
|
||||
? "var(--text-secondary)"
|
||||
: "var(--text-primary)",
|
||||
textDecoration: task.done ? "line-through" : "none",
|
||||
}}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<AddTaskModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
132
components/cards/TemperatureCard.tsx
Normal file
132
components/cards/TemperatureCard.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Thermometer, Plus, Minus } from "lucide-react";
|
||||
import { setClimateTemp } from "@/lib/api";
|
||||
|
||||
interface Props {
|
||||
entityId: string;
|
||||
currentTemp?: number;
|
||||
targetTemp?: number;
|
||||
state: string;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export default function TemperatureCard({
|
||||
entityId,
|
||||
currentTemp,
|
||||
targetTemp,
|
||||
state,
|
||||
onUpdate,
|
||||
}: Props) {
|
||||
const [target, setTarget] = useState(targetTemp || 22);
|
||||
const isHeating = state === "heat";
|
||||
|
||||
const adjust = useCallback(
|
||||
async (delta: number) => {
|
||||
const next = Math.min(30, Math.max(16, target + delta));
|
||||
setTarget(next);
|
||||
await setClimateTemp(entityId, next);
|
||||
onUpdate();
|
||||
},
|
||||
[target, entityId, onUpdate]
|
||||
);
|
||||
|
||||
const tempDiff = currentTemp ? currentTemp - target : 0;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="glass-card p-5 h-full flex flex-col justify-between"
|
||||
style={
|
||||
isHeating
|
||||
? {
|
||||
background: "rgba(244,63,94,0.06)",
|
||||
border: "1px solid rgba(244,63,94,0.15)",
|
||||
}
|
||||
: {}
|
||||
}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center mb-3"
|
||||
style={{
|
||||
background: isHeating
|
||||
? "rgba(244,63,94,0.15)"
|
||||
: "rgba(255,255,255,0.06)",
|
||||
}}
|
||||
>
|
||||
<Thermometer
|
||||
size={20}
|
||||
color={isHeating ? "#f43f5e" : "var(--text-secondary)"}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
Термостат
|
||||
</div>
|
||||
<div
|
||||
className="text-xs mt-0.5"
|
||||
style={{ color: isHeating ? "#f43f5e" : "var(--text-secondary)" }}
|
||||
>
|
||||
{isHeating ? "Нагрев" : "Ожидание"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div
|
||||
className="text-3xl font-bold"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{currentTemp?.toFixed(1) ?? "—"}°
|
||||
</div>
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
текущая
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Целевая температура
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.button
|
||||
onClick={() => adjust(-0.5)}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
style={{ background: "rgba(255,255,255,0.08)" }}
|
||||
whileTap={{ scale: 0.85 }}
|
||||
>
|
||||
<Minus size={14} color="var(--text-primary)" />
|
||||
</motion.button>
|
||||
<span
|
||||
className="text-lg font-bold min-w-[48px] text-center"
|
||||
style={{ color: "#6366f1" }}
|
||||
>
|
||||
{target}°
|
||||
</span>
|
||||
<motion.button
|
||||
onClick={() => adjust(0.5)}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
style={{ background: "rgba(99,102,241,0.2)" }}
|
||||
whileTap={{ scale: 0.85 }}
|
||||
>
|
||||
<Plus size={14} color="#6366f1" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
140
components/cards/WeatherCard.tsx
Normal file
140
components/cards/WeatherCard.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Droplets, Wind } from "lucide-react";
|
||||
|
||||
function getWeatherEmoji(code: string): string {
|
||||
const c = parseInt(code);
|
||||
if (c === 113) return "☀️";
|
||||
if (c === 116) return "⛅";
|
||||
if (c === 119 || c === 122) return "☁️";
|
||||
if (c >= 176 && c <= 182) return "🌦️";
|
||||
if (c >= 185 && c <= 200) return "🌧️";
|
||||
if (c >= 200 && c <= 210) return "⛈️";
|
||||
if (c >= 210 && c <= 260) return "❄️";
|
||||
if (c >= 260 && c <= 300) return "🌨️";
|
||||
if (c >= 300 && c <= 400) return "🌧️";
|
||||
return "🌤️";
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("ru-RU", { weekday: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
interface Props {
|
||||
weather: any;
|
||||
}
|
||||
|
||||
export default function WeatherCard({ weather }: Props) {
|
||||
if (!weather) {
|
||||
return (
|
||||
<motion.div
|
||||
className="glass-card p-5 h-full flex items-center justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<div
|
||||
className="text-sm"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
Загрузка погоды...
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="glass-card p-5 h-full flex flex-col"
|
||||
style={{
|
||||
background: "rgba(6,182,212,0.04)",
|
||||
border: "1px solid rgba(6,182,212,0.12)",
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
🌍 Санкт-Петербург
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<span className="text-3xl font-bold" style={{ color: "var(--text-primary)" }}>
|
||||
{getWeatherEmoji(weather.weatherCode)}
|
||||
</span>
|
||||
<div>
|
||||
<div
|
||||
className="text-2xl font-bold"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{weather.temp}°C
|
||||
</div>
|
||||
<div
|
||||
className="text-xs"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
{weather.desc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right space-y-1">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<Droplets size={12} color="#06b6d4" />
|
||||
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
|
||||
{weather.humidity}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<Wind size={12} color="#8b5cf6" />
|
||||
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
|
||||
{weather.windSpeed} км/ч
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Forecast */}
|
||||
<div className="flex gap-2 mt-auto">
|
||||
{(weather.forecast || []).map((day: any, i: number) => (
|
||||
<motion.div
|
||||
key={day.date}
|
||||
className="flex-1 rounded-xl p-3 text-center"
|
||||
style={{
|
||||
background: i === 0
|
||||
? "rgba(99,102,241,0.12)"
|
||||
: "rgba(255,255,255,0.04)",
|
||||
border: i === 0
|
||||
? "1px solid rgba(99,102,241,0.25)"
|
||||
: "1px solid rgba(255,255,255,0.06)",
|
||||
}}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
>
|
||||
<div
|
||||
className="text-xs mb-1 font-medium"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
{i === 0 ? "Сегодня" : formatDate(day.date)}
|
||||
</div>
|
||||
<div className="text-lg mb-1">
|
||||
{getWeatherEmoji(day.weatherCode)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{day.maxTemp}° / {day.minTemp}°
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user