Initial commit: Digital Home dashboard

This commit is contained in:
Cosmo
2026-04-15 20:31:28 +00:00
commit c5c4603903
33 changed files with 1384 additions and 0 deletions

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

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

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

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

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

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

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