Initial commit: Digital Home dashboard
This commit is contained in:
79
src/app/(dashboard)/bookmarks/page.tsx
Normal file
79
src/app/(dashboard)/bookmarks/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
export default function BookmarksPage() {
|
||||
const categories = [
|
||||
{
|
||||
label: "Статьи",
|
||||
emoji: "📰",
|
||||
links: [
|
||||
{ name: "Habr", url: "https://habr.com/ru/feed/", desc: "Технические статьи" },
|
||||
{ name: "VC.ru", url: "https://vc.ru/", desc: "Бизнес и технологии" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Dev",
|
||||
emoji: "💻",
|
||||
links: [
|
||||
{ name: "GitHub", url: "https://github.com/", desc: "Репозитории" },
|
||||
{ name: "Go Playground", url: "https://go.dev/play/", desc: "Тест Go кода" },
|
||||
{ name: "pkg.go.dev", url: "https://pkg.go.dev/", desc: "Go пакеты" },
|
||||
{ name: "Flutter Docs", url: "https://docs.flutter.dev/", desc: "Документация Flutter" },
|
||||
{ name: ".NET Docs", url: "https://docs.microsoft.com/dotnet/", desc: "Документация .NET" },
|
||||
{ name: "Docker Hub", url: "https://hub.docker.com/", desc: "Docker образы" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "AI",
|
||||
emoji: "🤖",
|
||||
links: [
|
||||
{ name: "OpenRouter", url: "https://openrouter.ai/", desc: "AI роутер" },
|
||||
{ name: "Hugging Face", url: "https://huggingface.co/", desc: "ML модели" },
|
||||
{ name: "Groq Console", url: "https://console.groq.com/", desc: "Groq API" },
|
||||
{ name: "Together AI", url: "https://api.together.xyz/", desc: "Together AI" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Инфраструктура",
|
||||
emoji: "🏗️",
|
||||
links: [
|
||||
{ name: "Proxmox", url: "http://192.168.31.100:8006", desc: "Виртуализация" },
|
||||
{ name: "Tailscale", url: "https://login.tailscale.com/admin/", desc: "VPN сеть" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Bookmarks</h1>
|
||||
<p className="text-slate-400 text-sm">Избранные ссылки</p>
|
||||
</div>
|
||||
|
||||
{categories.map((cat) => (
|
||||
<div key={cat.label}>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-400 uppercase tracking-wider mb-3">
|
||||
<span>{cat.emoji}</span>
|
||||
{cat.label}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
{cat.links.map((link) => (
|
||||
<a
|
||||
key={link.url}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="glass-card p-4 hover:scale-[1.02] transition-transform group"
|
||||
>
|
||||
<div className="text-sm font-medium text-white group-hover:text-indigo-300 transition-colors truncate">
|
||||
{link.name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1 truncate">{link.desc}</div>
|
||||
<div className="text-xs text-slate-700 mt-2 truncate text-right group-hover:text-slate-500 transition-colors">
|
||||
{new URL(link.url).hostname}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/app/(dashboard)/layout.tsx
Normal file
23
src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar userName={session.user?.name} />
|
||||
<main className="flex-1 ml-64 p-6 min-h-screen">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/app/(dashboard)/page.tsx
Normal file
34
src/app/(dashboard)/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { WeatherWidget } from "@/components/widgets/WeatherWidget";
|
||||
import { TasksWidget } from "@/components/widgets/TasksWidget";
|
||||
import { CalendarWidget } from "@/components/widgets/CalendarWidget";
|
||||
import { ClaudeUsageWidget } from "@/components/widgets/ClaudeUsageWidget";
|
||||
import { ClaudeApiWidget } from "@/components/widgets/ClaudeApiWidget";
|
||||
import { ServicesGrid } from "@/components/widgets/ServicesGrid";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||
<p className="text-slate-400 text-sm">Добро пожаловать домой</p>
|
||||
</div>
|
||||
|
||||
{/* Top row: weather, calendar, tasks */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<WeatherWidget />
|
||||
<CalendarWidget />
|
||||
<TasksWidget />
|
||||
</div>
|
||||
|
||||
{/* Claude row */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ClaudeUsageWidget />
|
||||
<ClaudeApiWidget />
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<ServicesGrid />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
src/app/(dashboard)/system/page.tsx
Normal file
145
src/app/(dashboard)/system/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Cpu, MemoryStick, HardDrive, Clock } from "lucide-react";
|
||||
|
||||
const TABS = ["Моя машина", "Сервисы", "Рига"] as const;
|
||||
|
||||
const MOCK_DATA = {
|
||||
"Моя машина": { cpu: 23, ram: { used: 8.2, total: 16 }, disk: { used: 124, total: 512 }, uptime: "3д 14ч" },
|
||||
"Сервисы": { cpu: 41, ram: { used: 6.8, total: 9.7 }, disk: { used: 47, total: 97 }, uptime: "12д 2ч" },
|
||||
"Рига": { cpu: 8, ram: { used: 1.1, total: 2 }, disk: { used: 18, total: 40 }, uptime: "45д 7ч" },
|
||||
};
|
||||
|
||||
function Sparkline({ values }: { values: number[] }) {
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const range = max - min || 1;
|
||||
const h = 32;
|
||||
const w = 80;
|
||||
const pts = values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * w;
|
||||
const y = h - ((v - min) / range) * h;
|
||||
return `${x},${y}`;
|
||||
}).join(" ");
|
||||
return (
|
||||
<svg width={w} height={h} className="opacity-60">
|
||||
<polyline points={pts} fill="none" stroke="rgb(99,102,241)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, sub, sparkData, color }: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
sparkData?: number[];
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-card p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
<span className="text-sm text-slate-400">{label}</span>
|
||||
</div>
|
||||
{sparkData && <Sparkline values={sparkData} />}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{value}</div>
|
||||
{sub && <div className="text-xs text-slate-500 mt-1">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SystemPage() {
|
||||
const [activeTab, setActiveTab] = useState<typeof TABS[number]>("Моя машина");
|
||||
const data = MOCK_DATA[activeTab];
|
||||
|
||||
const cpuHistory = Array.from({ length: 12 }, () => Math.floor(Math.random() * 40 + data.cpu - 10));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">System</h1>
|
||||
<p className="text-slate-400 text-sm">Мониторинг систем (mock данные)</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-slate-800/50 p-1 rounded-lg w-fit">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
activeTab === tab
|
||||
? "bg-indigo-600 text-white shadow"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon={Cpu}
|
||||
label="CPU"
|
||||
value={`${data.cpu}%`}
|
||||
sub="Загрузка процессора"
|
||||
sparkData={cpuHistory}
|
||||
color="text-blue-400"
|
||||
/>
|
||||
<StatCard
|
||||
icon={MemoryStick}
|
||||
label="RAM"
|
||||
value={`${data.ram.used} GB`}
|
||||
sub={`из ${data.ram.total} GB`}
|
||||
color="text-violet-400"
|
||||
/>
|
||||
<StatCard
|
||||
icon={HardDrive}
|
||||
label="Disk"
|
||||
value={`${data.disk.used} GB`}
|
||||
sub={`из ${data.disk.total} GB`}
|
||||
color="text-emerald-400"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label="Uptime"
|
||||
value={data.uptime}
|
||||
sub="Время работы"
|
||||
color="text-amber-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Usage bars */}
|
||||
<div className="glass-card p-5 space-y-4">
|
||||
<h3 className="text-sm font-medium text-slate-300">Использование ресурсов</h3>
|
||||
{[
|
||||
{ label: "CPU", value: data.cpu, color: "bg-blue-500" },
|
||||
{ label: "RAM", value: (data.ram.used / data.ram.total) * 100, color: "bg-violet-500" },
|
||||
{ label: "Disk", value: (data.disk.used / data.disk.total) * 100, color: "bg-emerald-500" },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-slate-400">{item.label}</span>
|
||||
<span className="text-slate-300">{item.value.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${item.color} rounded-full transition-all duration-500`}
|
||||
style={{ width: `${item.value}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-600 text-center">
|
||||
⚠️ Данные примерные. Реальный мониторинг будет добавлен позже.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
src/app/api/auth/[...nextauth]/route.ts
Normal file
2
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { handlers } from "@/auth";
|
||||
export const { GET, POST } = handlers;
|
||||
6
src/app/api/calendar/route.ts
Normal file
6
src/app/api/calendar/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ events: [] });
|
||||
}
|
||||
6
src/app/api/claude-api/route.ts
Normal file
6
src/app/api/claude-api/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ cost: "—", requests: "—", tokens: "—" });
|
||||
}
|
||||
6
src/app/api/claude-usage/route.ts
Normal file
6
src/app/api/claude-usage/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ used: "—", limit: "—", reset: "—" });
|
||||
}
|
||||
22
src/app/api/ping/route.ts
Normal file
22
src/app/api/ping/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = request.nextUrl.searchParams.get("url");
|
||||
if (!url) {
|
||||
return NextResponse.json({ status: "offline" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
const res = await fetch(url, {
|
||||
method: "HEAD",
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
return NextResponse.json({ status: res.ok ? "online" : "offline" });
|
||||
} catch {
|
||||
return NextResponse.json({ status: "offline" });
|
||||
}
|
||||
}
|
||||
33
src/app/api/tasks/route.ts
Normal file
33
src/app/api/tasks/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const PULSE_API = "https://api.digital-home.site";
|
||||
const REFRESH_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE4MDIwMjY5NDgsImlhdCI6MTc3MDQ5MDk0OCwidHlwZSI6InJlZnJlc2giLCJ1c2VyX2lkIjoxfQ.zPJJB7o9vtnfIBFl7rNygEEXd9h-5YZeAxRIvWcRlXY";
|
||||
|
||||
async function getAccessToken(): Promise<string> {
|
||||
const res = await fetch(`${PULSE_API}/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: REFRESH_TOKEN }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to refresh token");
|
||||
const data = await res.json();
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
const res = await fetch(`${PULSE_API}/tasks?due_today=true`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ tasks: [] });
|
||||
}
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (e) {
|
||||
return NextResponse.json({ tasks: [] });
|
||||
}
|
||||
}
|
||||
19
src/app/api/weather/route.ts
Normal file
19
src/app/api/weather/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
"https://wttr.in/Saint+Petersburg?format=j1",
|
||||
{
|
||||
headers: { "User-Agent": "digital-home-dashboard/1.0" },
|
||||
next: { revalidate: 600 },
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error("Weather API error");
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: "Failed to fetch weather" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
109
src/app/auth/signin/page.tsx
Normal file
109
src/app/auth/signin/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Home, Lock, User, AlertCircle, Loader2 } from "lucide-react";
|
||||
|
||||
export default function SignInPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const result = await signIn("credentials", {
|
||||
username,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
setLoading(false);
|
||||
if (result?.error) {
|
||||
setError("Неверный логин или пароль");
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background relative overflow-hidden">
|
||||
{/* Background decorations */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -left-40 w-80 h-80 rounded-full bg-indigo-500/10 blur-3xl" />
|
||||
<div className="absolute -bottom-40 -right-40 w-80 h-80 rounded-full bg-violet-500/8 blur-3xl" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 rounded-full bg-blue-500/5 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md px-4 relative z-10">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-indigo-500/20 border border-indigo-500/30 mb-4">
|
||||
<Home className="w-8 h-8 text-indigo-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Digital Home</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">Домашний дашборд</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="glass-card p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-slate-300 font-medium">Пользователь</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="daniil"
|
||||
required
|
||||
className="w-full bg-slate-800/50 border border-slate-700 rounded-lg pl-10 pr-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-slate-300 font-medium">Пароль</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="w-full bg-slate-800/50 border border-slate-700 rounded-lg pl-10 pr-4 py-2.5 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-800 text-white font-medium py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Вход...
|
||||
</>
|
||||
) : (
|
||||
"Войти"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/app/globals.css
Normal file
85
src/app/globals.css
Normal file
@@ -0,0 +1,85 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 213 31% 91%;
|
||||
--card: 224 71% 4%;
|
||||
--card-foreground: 213 31% 91%;
|
||||
--border: 216 34% 17%;
|
||||
--input: 216 34% 17%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 1.2%;
|
||||
--secondary: 222.2 47.4% 11.2%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 223 47% 11%;
|
||||
--muted-foreground: 215.4 16.3% 56.9%;
|
||||
--accent: 216 34% 17%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--ring: 216 34% 17%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
background: radial-gradient(ellipse at top left, rgba(99,102,241,0.08) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at bottom right, rgba(139,92,246,0.05) 0%, transparent 50%),
|
||||
hsl(224, 71%, 4%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(99, 102, 241, 0.15);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: rgba(10, 17, 32, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
border-right: 1px solid rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
.status-online {
|
||||
@apply bg-green-500;
|
||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
@apply bg-red-500;
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.status-checking {
|
||||
@apply bg-yellow-500;
|
||||
}
|
||||
19
src/app/layout.tsx
Normal file
19
src/app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Digital Home",
|
||||
description: "Personal home dashboard",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ru" className="dark">
|
||||
<body style={{fontFamily: 'system-ui, -apple-system, sans-serif'}}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
49
src/auth.ts
Normal file
49
src/auth.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
providers: [
|
||||
Credentials({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
username: { label: "Username", type: "text" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (
|
||||
credentials?.username === "daniil" &&
|
||||
credentials?.password === "DigitalHome2026!"
|
||||
) {
|
||||
return {
|
||||
id: "1",
|
||||
name: "Daniil",
|
||||
email: "daniilklimov25@gmail.com",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
pages: {
|
||||
signIn: "/auth/signin",
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token && session.user) {
|
||||
session.user.id = token.id as string;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
78
src/components/layout/Sidebar.tsx
Normal file
78
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { Home, Server, Bookmark, LogOut, LayoutDashboard } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/system", label: "System", icon: Server },
|
||||
{ href: "/bookmarks", label: "Bookmarks", icon: Bookmark },
|
||||
];
|
||||
|
||||
export function Sidebar({ userName }: { userName?: string | null }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="sidebar w-64 min-h-screen flex flex-col fixed left-0 top-0 z-50">
|
||||
{/* Logo */}
|
||||
<div className="p-6 border-b border-indigo-500/10">
|
||||
<Link href="/" className="flex items-center gap-3 group">
|
||||
<div className="w-9 h-9 rounded-xl bg-indigo-500/20 border border-indigo-500/30 flex items-center justify-center group-hover:bg-indigo-500/30 transition-colors">
|
||||
<Home className="w-5 h-5 text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-white">Digital Home</div>
|
||||
<div className="text-xs text-slate-500">Dashboard</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = item.href === "/" ? pathname === "/" : pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all",
|
||||
isActive
|
||||
? "bg-indigo-500/20 text-indigo-300 border border-indigo-500/20"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-slate-800/50"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User */}
|
||||
<div className="p-4 border-t border-slate-800">
|
||||
<div className="flex items-center gap-3 px-2 mb-2">
|
||||
<div className="w-8 h-8 rounded-full bg-indigo-500/20 border border-indigo-500/30 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-indigo-400">
|
||||
{userName?.[0]?.toUpperCase() ?? "D"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white truncate">{userName ?? "Daniil"}</div>
|
||||
<div className="text-xs text-slate-500">Administrator</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/auth/signin" })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-slate-400 hover:text-red-400 hover:bg-red-500/10 transition-all"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
71
src/components/widgets/CalendarWidget.tsx
Normal file
71
src/components/widgets/CalendarWidget.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
import { Calendar, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface CalEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
time?: string;
|
||||
}
|
||||
|
||||
export function CalendarWidget() {
|
||||
const [events, setEvents] = useState<CalEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/calendar");
|
||||
const data = await res.json();
|
||||
setEvents(data.events ?? []);
|
||||
} catch {
|
||||
setEvents([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchEvents(); }, []);
|
||||
|
||||
const today = new Date().toLocaleDateString("ru-RU", { day: "numeric", month: "long" });
|
||||
|
||||
return (
|
||||
<div className="glass-card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-violet-400" />
|
||||
<span className="text-sm font-medium text-slate-300">Календарь</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">{today}</span>
|
||||
<button onClick={fetchEvents} className="text-slate-500 hover:text-slate-300 transition-colors">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
{[1,2].map(i => <div key={i} className="h-10 bg-slate-700/50 rounded" />)}
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-4 text-slate-500">
|
||||
<Calendar className="w-8 h-8 mb-2 opacity-30" />
|
||||
<span className="text-sm">Нет событий</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{events.map((ev) => (
|
||||
<div key={ev.id} className="flex items-center gap-3 p-2 rounded-lg bg-slate-800/40">
|
||||
<div className="w-1 h-8 rounded-full bg-violet-500" />
|
||||
<div>
|
||||
<div className="text-sm text-white">{ev.title}</div>
|
||||
{ev.time && <div className="text-xs text-slate-400">{ev.time}</div>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/widgets/ClaudeApiWidget.tsx
Normal file
54
src/components/widgets/ClaudeApiWidget.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
import { RefreshCw, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function ClaudeApiWidget() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
setTimeout(() => setRefreshing(false), 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-purple-500/20 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-purple-400">A</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-300">Claude API Usage & Cost</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="text-slate-500 hover:text-slate-300 transition-colors"
|
||||
title="Обновить"
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Стоимость", value: "—" },
|
||||
{ label: "Запросы", value: "—" },
|
||||
{ label: "Токены", value: "—" },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="bg-slate-800/40 rounded-lg p-3 text-center">
|
||||
<div className="text-xl font-bold text-white">{item.value}</div>
|
||||
<div className="text-xs text-slate-400 mt-1">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-slate-500">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-yellow-500" />
|
||||
<span>Автоматическое получение данных недоступно</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/widgets/ClaudeUsageWidget.tsx
Normal file
54
src/components/widgets/ClaudeUsageWidget.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
import { RefreshCw, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function ClaudeUsageWidget() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
setTimeout(() => setRefreshing(false), 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded bg-orange-500/20 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-orange-400">C</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-300">Claude Subscription Usage</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="text-slate-500 hover:text-slate-300 transition-colors"
|
||||
title="Обновить"
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Использовано", value: "—" },
|
||||
{ label: "Лимит", value: "—" },
|
||||
{ label: "Сброс", value: "—" },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="bg-slate-800/40 rounded-lg p-3 text-center">
|
||||
<div className="text-xl font-bold text-white">{item.value}</div>
|
||||
<div className="text-xs text-slate-400 mt-1">{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-slate-500">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-yellow-500" />
|
||||
<span>Автоматическое получение данных недоступно</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/components/widgets/ServicesGrid.tsx
Normal file
131
src/components/widgets/ServicesGrid.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
interface Service {
|
||||
name: string;
|
||||
url: string;
|
||||
icon?: string;
|
||||
desc: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
interface ServiceCategory {
|
||||
label: string;
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
const SERVICE_CATEGORIES: ServiceCategory[] = [
|
||||
{
|
||||
label: "Productivity",
|
||||
services: [
|
||||
{ name: "Pulse", url: "https://pulse.digital-home.site", icon: "https://pulse.digital-home.site/favicon.svg", desc: "Привычки и задачи", emoji: "💓" },
|
||||
{ name: "Gitea", url: "https://git.digital-home.site", desc: "Git репозитории", emoji: "🐙" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Storage",
|
||||
services: [
|
||||
{ name: "Nextcloud", url: "https://cloud.digital-home.site", desc: "Облачное хранилище", emoji: "☁️" },
|
||||
{ name: "Immich", url: "https://photo.digital-home.site", desc: "Фото галерея", emoji: "📸" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Tools",
|
||||
services: [
|
||||
{ name: "Vaultwarden", url: "https://vault.digital-home.site", desc: "Менеджер паролей", emoji: "🔐" },
|
||||
{ name: "IT-Tools", url: "https://tools.digital-home.site", desc: "Утилиты разработчика", emoji: "🛠️" },
|
||||
{ name: "Uptime Kuma", url: "https://uptime.digital-home.site", desc: "Мониторинг", emoji: "📊" },
|
||||
{ name: "VPN Configs", url: "https://vpn.digital-home.site/admin?key=mysecret2026", desc: "Конфиги VPN", emoji: "🔒" },
|
||||
{ name: "Marzban", url: "https://daniilvds.duckdns.org:2083/dashboard", desc: "Marzban панель", emoji: "🌐" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "AI Subscribe",
|
||||
services: [
|
||||
{ name: "OpenAI Usage", url: "https://chatgpt.com/codex/cloud/settings/usage", desc: "Лимиты OpenAI", emoji: "🤖" },
|
||||
{ name: "Claude Usage", url: "https://claude.ai/settings/usage", desc: "Лимиты Claude", emoji: "✨" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "AI API",
|
||||
services: [
|
||||
{ name: "Moonshot AI", url: "https://platform.moonshot.ai/console/account", desc: "Баланс и API ключи", emoji: "🌙" },
|
||||
{ name: "OpenAI API", url: "https://platform.openai.com/settings/organization/usage", desc: "OpenAI API статистика", emoji: "📈" },
|
||||
{ name: "Claude API", url: "https://platform.claude.com/workspaces/default/cost", desc: "Claude API статистика", emoji: "💜" },
|
||||
{ name: "ElevenLabs API", url: "https://elevenlabs.io/app/api", desc: "ElevenLabs API статистика", emoji: "🎙️" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function ServiceCard({ service }: { service: Service }) {
|
||||
const [status, setStatus] = useState<"checking" | "online" | "offline">("checking");
|
||||
|
||||
useEffect(() => {
|
||||
const check = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/ping?url=${encodeURIComponent(service.url)}`);
|
||||
const data = await res.json();
|
||||
setStatus(data.status === "online" ? "online" : "offline");
|
||||
} catch {
|
||||
setStatus("offline");
|
||||
}
|
||||
};
|
||||
check();
|
||||
}, [service.url]);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="glass-card p-4 flex items-center gap-3 hover:scale-[1.02] transition-transform group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-slate-800/80 border border-slate-700/50 flex items-center justify-center shrink-0 text-xl">
|
||||
{service.icon ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={service.icon} alt={service.name} className="w-6 h-6 object-contain" onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
(e.target as HTMLImageElement).parentElement!.textContent = service.emoji ?? "🔗";
|
||||
}} />
|
||||
) : (
|
||||
<span>{service.emoji ?? "🔗"}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white truncate">{service.name}</span>
|
||||
<ExternalLink className="w-3 h-3 text-slate-600 shrink-0 group-hover:text-slate-400 transition-colors" />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 truncate">{service.desc}</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
status === "online" ? "status-online" :
|
||||
status === "offline" ? "status-offline" :
|
||||
"status-checking animate-pulse"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServicesGrid() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-white">Сервисы</h2>
|
||||
{SERVICE_CATEGORIES.map((cat) => (
|
||||
<div key={cat.label}>
|
||||
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">{cat.label}</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
{cat.services.map((svc) => (
|
||||
<ServiceCard key={svc.url} service={svc} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/components/widgets/TasksWidget.tsx
Normal file
77
src/components/widgets/TasksWidget.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
import { CheckSquare, RefreshCw, Circle, CheckCircle2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
title: string;
|
||||
done?: boolean;
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
export function TasksWidget() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/tasks");
|
||||
const data = await res.json();
|
||||
const list = data.tasks ?? data ?? [];
|
||||
setTasks(Array.isArray(list) ? list : []);
|
||||
} catch {
|
||||
setTasks([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchTasks(); }, []);
|
||||
|
||||
const priorityColor: Record<string, string> = {
|
||||
high: "text-red-400",
|
||||
medium: "text-yellow-400",
|
||||
low: "text-green-400",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckSquare className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-sm font-medium text-slate-300">Задачи сегодня</span>
|
||||
</div>
|
||||
<button onClick={fetchTasks} className="text-slate-500 hover:text-slate-300 transition-colors">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
{[1,2,3].map(i => <div key={i} className="h-8 bg-slate-700/50 rounded" />)}
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-4 text-slate-500">
|
||||
<CheckSquare className="w-8 h-8 mb-2 opacity-30" />
|
||||
<span className="text-sm">Задач нет</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-36 overflow-y-auto">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="flex items-start gap-2 p-2 rounded-lg hover:bg-slate-800/40 transition-colors">
|
||||
{task.done ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<Circle className="w-4 h-4 text-slate-500 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<span className={"text-sm " + (task.done ? "text-slate-500 line-through" : "text-slate-200")}>
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/widgets/WeatherWidget.tsx
Normal file
84
src/components/widgets/WeatherWidget.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Cloud, Thermometer, Wind, Droplets, RefreshCw } from "lucide-react";
|
||||
|
||||
interface WeatherData {
|
||||
current_condition: Array<{
|
||||
temp_C: string;
|
||||
FeelsLikeC: string;
|
||||
humidity: string;
|
||||
windspeedKmph: string;
|
||||
weatherDesc: Array<{ value: string }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function WeatherWidget() {
|
||||
const [data, setData] = useState<WeatherData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const fetchWeather = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const res = await fetch("/api/weather");
|
||||
if (!res.ok) throw new Error();
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchWeather(); }, []);
|
||||
|
||||
const current = data?.current_condition?.[0];
|
||||
|
||||
return (
|
||||
<div className="glass-card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm font-medium text-slate-300">Погода</span>
|
||||
</div>
|
||||
<button onClick={fetchWeather} className="text-slate-500 hover:text-slate-300 transition-colors">
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
<div className="h-8 bg-slate-700/50 rounded w-24" />
|
||||
<div className="h-4 bg-slate-700/50 rounded w-32" />
|
||||
<div className="h-4 bg-slate-700/50 rounded w-40" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-slate-500 text-sm">Не удалось получить данные</div>
|
||||
) : current ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold text-white">{current.temp_C}°</span>
|
||||
<span className="text-slate-400 text-sm">Санкт-Петербург</span>
|
||||
</div>
|
||||
<div className="text-slate-300 text-sm">{current.weatherDesc?.[0]?.value}</div>
|
||||
<div className="grid grid-cols-3 gap-2 pt-1">
|
||||
<div className="flex items-center gap-1 text-xs text-slate-400">
|
||||
<Thermometer className="w-3 h-3 text-orange-400" />
|
||||
<span>Ощущ. {current.FeelsLikeC}°</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-slate-400">
|
||||
<Droplets className="w-3 h-3 text-blue-400" />
|
||||
<span>{current.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-slate-400">
|
||||
<Wind className="w-3 h-3 text-teal-400" />
|
||||
<span>{current.windspeedKmph} км/ч</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
5
src/middleware.ts
Normal file
5
src/middleware.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { auth as middleware } from "./auth";
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico|auth/signin).*)"],
|
||||
};
|
||||
Reference in New Issue
Block a user