From c5c4603903d2b25c32a767957e4c52e325e04762 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Wed, 15 Apr 2026 20:31:28 +0000 Subject: [PATCH] Initial commit: Digital Home dashboard --- .dockerignore | 6 + .gitignore | 3 + Dockerfile | 17 +++ docker-compose.yml | 24 +++ next.config.js | 11 ++ package.json | 38 +++++ postcss.config.js | 6 + src/app/(dashboard)/bookmarks/page.tsx | 79 ++++++++++ src/app/(dashboard)/layout.tsx | 23 +++ src/app/(dashboard)/page.tsx | 34 +++++ src/app/(dashboard)/system/page.tsx | 145 +++++++++++++++++++ src/app/api/auth/[...nextauth]/route.ts | 2 + src/app/api/calendar/route.ts | 6 + src/app/api/claude-api/route.ts | 6 + src/app/api/claude-usage/route.ts | 6 + src/app/api/ping/route.ts | 22 +++ src/app/api/tasks/route.ts | 33 +++++ src/app/api/weather/route.ts | 19 +++ src/app/auth/signin/page.tsx | 109 ++++++++++++++ src/app/globals.css | 85 +++++++++++ src/app/layout.tsx | 19 +++ src/auth.ts | 49 +++++++ src/components/layout/Sidebar.tsx | 78 ++++++++++ src/components/widgets/CalendarWidget.tsx | 71 +++++++++ src/components/widgets/ClaudeApiWidget.tsx | 54 +++++++ src/components/widgets/ClaudeUsageWidget.tsx | 54 +++++++ src/components/widgets/ServicesGrid.tsx | 131 +++++++++++++++++ src/components/widgets/TasksWidget.tsx | 77 ++++++++++ src/components/widgets/WeatherWidget.tsx | 84 +++++++++++ src/lib/utils.ts | 6 + src/middleware.ts | 5 + tailwind.config.ts | 62 ++++++++ tsconfig.json | 20 +++ 33 files changed, 1384 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 next.config.js create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 src/app/(dashboard)/bookmarks/page.tsx create mode 100644 src/app/(dashboard)/layout.tsx create mode 100644 src/app/(dashboard)/page.tsx create mode 100644 src/app/(dashboard)/system/page.tsx create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/api/calendar/route.ts create mode 100644 src/app/api/claude-api/route.ts create mode 100644 src/app/api/claude-usage/route.ts create mode 100644 src/app/api/ping/route.ts create mode 100644 src/app/api/tasks/route.ts create mode 100644 src/app/api/weather/route.ts create mode 100644 src/app/auth/signin/page.tsx create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/auth.ts create mode 100644 src/components/layout/Sidebar.tsx create mode 100644 src/components/widgets/CalendarWidget.tsx create mode 100644 src/components/widgets/ClaudeApiWidget.tsx create mode 100644 src/components/widgets/ClaudeUsageWidget.tsx create mode 100644 src/components/widgets/ServicesGrid.tsx create mode 100644 src/components/widgets/TasksWidget.tsx create mode 100644 src/components/widgets/WeatherWidget.tsx create mode 100644 src/lib/utils.ts create mode 100644 src/middleware.ts create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5a31b49 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.git +*.md +.env +.env.local diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f1f1fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.next +node_modules +.env.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ecb3681 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +ENV NEXTAUTH_SECRET=digital-home-jwt-secret-2026-cosmo +ENV NEXTAUTH_URL=https://home.digital-home.site +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5f6093e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +networks: + coolify: + third-party: true + +services: + dashboard: + build: . + ports: + - "3005:3000" + environment: + - NEXTAUTH_SECRET=digital-home-jwt-secret-2026-cosmo + - NEXTAUTH_URL=https://home.digital-home.site + - NODE_ENV=production + restart: unless-stopped + networks: + - coolify + labels: + - traefik.enable=true + - traefik.docker.network=coolify + - traefik.http.routers.dh-dashboard.rule=Host(`home.digital-home.site`) + - traefik.http.routers.dh-dashboard.entrypoints=websecure + - traefik.http.routers.dh-dashboard.tls=true + - traefik.http.routers.dh-dashboard.tls.certresolver=letsencrypt + - traefik.http.services.dh-dashboard.loadbalancer.server.port=3000 diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..d58690c --- /dev/null +++ b/next.config.js @@ -0,0 +1,11 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: 'standalone', + images: { + remotePatterns: [ + { protocol: 'https', hostname: '**' }, + { protocol: 'http', hostname: '**' }, + ], + }, +} +module.exports = nextConfig diff --git a/package.json b/package.json new file mode 100644 index 0000000..ca5fefc --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "digital-home-dashboard", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "14.2.3", + "next-auth": "5.0.0-beta.19", + "react": "^18", + "react-dom": "^18", + "typescript": "^5", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "tailwindcss": "^3.4.1", + "postcss": "^8", + "autoprefixer": "^10.0.1", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "tailwind-merge": "^2.3.0", + "lucide-react": "^0.379.0", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-tooltip": "^1.0.7", + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-slot": "^1.0.2", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "eslint": "^8", + "eslint-config-next": "14.2.3" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/app/(dashboard)/bookmarks/page.tsx b/src/app/(dashboard)/bookmarks/page.tsx new file mode 100644 index 0000000..f4216f7 --- /dev/null +++ b/src/app/(dashboard)/bookmarks/page.tsx @@ -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 ( +
+
+

Bookmarks

+

Избранные ссылки

+
+ + {categories.map((cat) => ( +
+

+ {cat.emoji} + {cat.label} +

+
+ {cat.links.map((link) => ( + +
+ {link.name} +
+
{link.desc}
+
+ {new URL(link.url).hostname} +
+
+ ))} +
+
+ ))} +
+ ); +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..1aee9f8 --- /dev/null +++ b/src/app/(dashboard)/layout.tsx @@ -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 ( +
+ +
+ {children} +
+
+ ); +} diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx new file mode 100644 index 0000000..b77c6dd --- /dev/null +++ b/src/app/(dashboard)/page.tsx @@ -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 ( +
+
+

Dashboard

+

Добро пожаловать домой

+
+ + {/* Top row: weather, calendar, tasks */} +
+ + + +
+ + {/* Claude row */} +
+ + +
+ + {/* Services */} + +
+ ); +} diff --git a/src/app/(dashboard)/system/page.tsx b/src/app/(dashboard)/system/page.tsx new file mode 100644 index 0000000..c1a593e --- /dev/null +++ b/src/app/(dashboard)/system/page.tsx @@ -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 ( + + + + ); +} + +function StatCard({ icon: Icon, label, value, sub, sparkData, color }: { + icon: React.ElementType; + label: string; + value: string; + sub?: string; + sparkData?: number[]; + color: string; +}) { + return ( +
+
+
+ + {label} +
+ {sparkData && } +
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +export default function SystemPage() { + const [activeTab, setActiveTab] = useState("Моя машина"); + const data = MOCK_DATA[activeTab]; + + const cpuHistory = Array.from({ length: 12 }, () => Math.floor(Math.random() * 40 + data.cpu - 10)); + + return ( +
+
+

System

+

Мониторинг систем (mock данные)

+
+ + {/* Tabs */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Stats */} +
+ + + + +
+ + {/* Usage bars */} +
+

Использование ресурсов

+ {[ + { 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) => ( +
+
+ {item.label} + {item.value.toFixed(0)}% +
+
+
+
+
+ ))} +
+ +
+ ⚠️ Данные примерные. Реальный мониторинг будет добавлен позже. +
+
+ ); +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..7c62e2d --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +import { handlers } from "@/auth"; +export const { GET, POST } = handlers; diff --git a/src/app/api/calendar/route.ts b/src/app/api/calendar/route.ts new file mode 100644 index 0000000..219e625 --- /dev/null +++ b/src/app/api/calendar/route.ts @@ -0,0 +1,6 @@ +export const dynamic = "force-dynamic"; +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ events: [] }); +} diff --git a/src/app/api/claude-api/route.ts b/src/app/api/claude-api/route.ts new file mode 100644 index 0000000..8056726 --- /dev/null +++ b/src/app/api/claude-api/route.ts @@ -0,0 +1,6 @@ +export const dynamic = "force-dynamic"; +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ cost: "—", requests: "—", tokens: "—" }); +} diff --git a/src/app/api/claude-usage/route.ts b/src/app/api/claude-usage/route.ts new file mode 100644 index 0000000..7ac1ea7 --- /dev/null +++ b/src/app/api/claude-usage/route.ts @@ -0,0 +1,6 @@ +export const dynamic = "force-dynamic"; +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ used: "—", limit: "—", reset: "—" }); +} diff --git a/src/app/api/ping/route.ts b/src/app/api/ping/route.ts new file mode 100644 index 0000000..558a88f --- /dev/null +++ b/src/app/api/ping/route.ts @@ -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" }); + } +} diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts new file mode 100644 index 0000000..307f8b5 --- /dev/null +++ b/src/app/api/tasks/route.ts @@ -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 { + 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: [] }); + } +} diff --git a/src/app/api/weather/route.ts b/src/app/api/weather/route.ts new file mode 100644 index 0000000..0a5176e --- /dev/null +++ b/src/app/api/weather/route.ts @@ -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 }); + } +} diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx new file mode 100644 index 0000000..01b254b --- /dev/null +++ b/src/app/auth/signin/page.tsx @@ -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 ( +
+ {/* Background decorations */} +
+
+
+
+
+ +
+ {/* Logo */} +
+
+ +
+

Digital Home

+

Домашний дашборд

+
+ + {/* Card */} +
+
+ {error && ( +
+ + {error} +
+ )} + +
+ +
+ + 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" + /> +
+
+ +
+ +
+ + 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" + /> +
+
+ + +
+
+
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..ddad068 --- /dev/null +++ b/src/app/globals.css @@ -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; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..adc9886 --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..0c625a0 --- /dev/null +++ b/src/auth.ts @@ -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; + }, + }, +}); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..7b1c02c --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -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 ( + + ); +} diff --git a/src/components/widgets/CalendarWidget.tsx b/src/components/widgets/CalendarWidget.tsx new file mode 100644 index 0000000..7890bf1 --- /dev/null +++ b/src/components/widgets/CalendarWidget.tsx @@ -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([]); + 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 ( +
+
+
+ + Календарь +
+
+ {today} + +
+
+ + {loading ? ( +
+ {[1,2].map(i =>
)} +
+ ) : events.length === 0 ? ( +
+ + Нет событий +
+ ) : ( +
+ {events.map((ev) => ( +
+
+
+
{ev.title}
+ {ev.time &&
{ev.time}
} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/widgets/ClaudeApiWidget.tsx b/src/components/widgets/ClaudeApiWidget.tsx new file mode 100644 index 0000000..6e9f0b5 --- /dev/null +++ b/src/components/widgets/ClaudeApiWidget.tsx @@ -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 ( +
+
+
+
+ A +
+ Claude API Usage & Cost +
+ +
+ +
+ {[ + { label: "Стоимость", value: "—" }, + { label: "Запросы", value: "—" }, + { label: "Токены", value: "—" }, + ].map((item) => ( +
+
{item.value}
+
{item.label}
+
+ ))} +
+ +
+
+ Автоматическое получение данных недоступно +
+
+ ); +} diff --git a/src/components/widgets/ClaudeUsageWidget.tsx b/src/components/widgets/ClaudeUsageWidget.tsx new file mode 100644 index 0000000..5e55bb3 --- /dev/null +++ b/src/components/widgets/ClaudeUsageWidget.tsx @@ -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 ( +
+
+
+
+ C +
+ Claude Subscription Usage +
+ +
+ +
+ {[ + { label: "Использовано", value: "—" }, + { label: "Лимит", value: "—" }, + { label: "Сброс", value: "—" }, + ].map((item) => ( +
+
{item.value}
+
{item.label}
+
+ ))} +
+ +
+
+ Автоматическое получение данных недоступно +
+
+ ); +} diff --git a/src/components/widgets/ServicesGrid.tsx b/src/components/widgets/ServicesGrid.tsx new file mode 100644 index 0000000..b9d75a3 --- /dev/null +++ b/src/components/widgets/ServicesGrid.tsx @@ -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 ( + +
+ {service.icon ? ( + // eslint-disable-next-line @next/next/no-img-element + {service.name} { + (e.target as HTMLImageElement).style.display = "none"; + (e.target as HTMLImageElement).parentElement!.textContent = service.emoji ?? "🔗"; + }} /> + ) : ( + {service.emoji ?? "🔗"} + )} +
+
+
+ {service.name} + +
+

{service.desc}

+
+
+
+
+
+ ); +} + +export function ServicesGrid() { + return ( +
+

Сервисы

+ {SERVICE_CATEGORIES.map((cat) => ( +
+

{cat.label}

+
+ {cat.services.map((svc) => ( + + ))} +
+
+ ))} +
+ ); +} diff --git a/src/components/widgets/TasksWidget.tsx b/src/components/widgets/TasksWidget.tsx new file mode 100644 index 0000000..6e75fb2 --- /dev/null +++ b/src/components/widgets/TasksWidget.tsx @@ -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([]); + 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 = { + high: "text-red-400", + medium: "text-yellow-400", + low: "text-green-400", + }; + + return ( +
+
+
+ + Задачи сегодня +
+ +
+ + {loading ? ( +
+ {[1,2,3].map(i =>
)} +
+ ) : tasks.length === 0 ? ( +
+ + Задач нет +
+ ) : ( +
+ {tasks.map((task) => ( +
+ {task.done ? ( + + ) : ( + + )} + + {task.title} + +
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/widgets/WeatherWidget.tsx b/src/components/widgets/WeatherWidget.tsx new file mode 100644 index 0000000..eb646ce --- /dev/null +++ b/src/components/widgets/WeatherWidget.tsx @@ -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(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 ( +
+
+
+ + Погода +
+ +
+ + {loading ? ( +
+
+
+
+
+ ) : error ? ( +
Не удалось получить данные
+ ) : current ? ( +
+
+ {current.temp_C}° + Санкт-Петербург +
+
{current.weatherDesc?.[0]?.value}
+
+
+ + Ощущ. {current.FeelsLikeC}° +
+
+ + {current.humidity}% +
+
+ + {current.windspeedKmph} км/ч +
+
+
+ ) : null} +
+ ); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..365058c --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..76dc05c --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,5 @@ +export { auth as middleware } from "./auth"; + +export const config = { + matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico|auth/signin).*)"], +}; diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..31f02a9 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,62 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + darkMode: ["class"], + content: [ + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +}; +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..49e4cf3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}