feat: complete dashboard pages (dashboard, keys, devices, payments, servers, profile)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+34
-1
@@ -1 +1,34 @@
|
||||
export default function Profile() { return <div>Profile</div>; }
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { Navbar } from "../components/layout/Navbar";
|
||||
import { GlassCard } from "../components/ui/GlassCard";
|
||||
|
||||
export default function Profile() {
|
||||
const { user } = useAuthStore();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar />
|
||||
<div className="max-w-2xl mx-auto px-4 pt-24">
|
||||
<h1 className="text-2xl font-bold mb-6">Профиль</h1>
|
||||
<GlassCard animate={false} className="mb-4">
|
||||
<div className="text-sm text-white/50 mb-4">Информация аккаунта</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between"><span className="text-white/40">Имя</span><span>{user?.name}</span></div>
|
||||
<div className="flex justify-between"><span className="text-white/40">Email</span><span>{user?.email || "—"}</span></div>
|
||||
<div className="flex justify-between"><span className="text-white/40">Telegram</span>
|
||||
<span className={user?.tg_linked ? "text-green-400" : "text-yellow-400"}>
|
||||
{user?.tg_linked ? "✅ Привязан" : "⚠️ Не привязан"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
{!user?.tg_linked && (
|
||||
<GlassCard animate={false} className="mb-4">
|
||||
<div className="text-sm text-white/50 mb-3">Привязать Telegram</div>
|
||||
<p className="text-xs text-white/40 mb-3">Привяжите Telegram-аккаунт, чтобы получить полный доступ к VPN-ключам из бота.</p>
|
||||
</GlassCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,83 @@
|
||||
export default function Dashboard() { return <div>Dashboard</div>; }
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../../api/client";
|
||||
import { DashboardLayout } from "./DashboardLayout";
|
||||
import { GlassCard } from "../../components/ui/GlassCard";
|
||||
import { StatusBadge } from "../../components/ui/StatusBadge";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { QRModal } from "../../components/ui/QRModal";
|
||||
import { Key, Wallet, Smartphone, Calendar } from "lucide-react";
|
||||
|
||||
export default function Dashboard() {
|
||||
const [profile, setProfile] = useState<any>(null);
|
||||
const [qrKey, setQrKey] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.action("profile").then((r) => { if (r.ok) setProfile(r); });
|
||||
}, []);
|
||||
|
||||
if (!profile) return <DashboardLayout><div className="text-white/40 py-12 text-center">Загрузка...</div></DashboardLayout>;
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
{qrKey && <QRModal text={qrKey.remnawave_link || qrKey.link} title={qrKey.name} onClose={() => setQrKey(null)} />}
|
||||
|
||||
<h1 className="text-2xl font-bold mb-6">Добро пожаловать, {profile.first_name} 👋</h1>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
{[
|
||||
{ icon: Calendar, label: "Дней осталось", value: profile.days_left ?? 0 },
|
||||
{ icon: Wallet, label: "Баланс", value: `${Math.round(profile.balance)} ₽` },
|
||||
{ icon: Smartphone, label: "Устройств", value: profile.device_limit ?? 3 },
|
||||
{ icon: Key, label: "Ключей", value: (profile.keys ?? []).length },
|
||||
].map(({ icon: Icon, label, value }) => (
|
||||
<GlassCard key={label} className="text-center">
|
||||
<Icon size={20} className="text-accent mx-auto mb-2" />
|
||||
<div className="text-xl font-bold">{value}</div>
|
||||
<div className="text-xs text-white/40 mt-1">{label}</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Subscription status */}
|
||||
<GlassCard className="mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-semibold">Подписка</span>
|
||||
<StatusBadge active={profile.subscription_active} />
|
||||
</div>
|
||||
{profile.subscription_active ? (
|
||||
<>
|
||||
<p className="text-white/50 text-sm mb-2">Активна до {profile.subscription_until}</p>
|
||||
<div className="h-2 bg-white/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-accent to-accent2 rounded-full"
|
||||
style={{ width: `${Math.min(100, Math.round((profile.days_left / 30) * 100))}%` }} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-red-400 text-sm">Подписка неактивна. Купите тариф для доступа к VPN.</p>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<Link to="/dashboard/keys"><Button variant="secondary" className="text-sm">Управление ключами</Button></Link>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Keys preview */}
|
||||
{(profile.keys ?? []).slice(0, 2).map((k: any) => (
|
||||
<GlassCard key={k.client_id} className="mb-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-medium">{k.name || "Ключ"}</div>
|
||||
<div className="text-xs text-white/40 mt-1">до {k.until} · {k.days_left} дн.</div>
|
||||
</div>
|
||||
{(k.remnawave_link || k.link) && (
|
||||
<Button variant="secondary" className="text-xs py-1.5 px-3" onClick={() => setQrKey(k)}>
|
||||
QR-код
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Navbar } from "../../components/layout/Navbar";
|
||||
import { Sidebar } from "../../components/layout/Sidebar";
|
||||
|
||||
export function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar />
|
||||
<div className="max-w-6xl mx-auto px-4 pt-24 pb-16 flex gap-8">
|
||||
<Sidebar />
|
||||
<main className="flex-1 min-w-0">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,42 @@
|
||||
export default function Devices() { return <div>Devices</div>; }
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../api/client";
|
||||
import { DashboardLayout } from "./DashboardLayout";
|
||||
import { GlassCard } from "../../components/ui/GlassCard";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { Smartphone } from "lucide-react";
|
||||
|
||||
export default function Devices() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const load = () => api.action("user_devices").then((r) => setData(r));
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function handleDelete(hwid: string) {
|
||||
if (!data?.client_id) return;
|
||||
await api.action("delete_device", { client_id: data.client_id, hwid });
|
||||
load();
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<h1 className="text-2xl font-bold mb-6">Устройства</h1>
|
||||
{!data ? <div className="text-white/40 text-center py-12">Загрузка...</div> :
|
||||
!data.ok ? <div className="text-red-400 text-center py-12">{data.error}</div> :
|
||||
!(data.devices ?? []).length ? <div className="text-white/40 text-center py-12">Устройств не найдено</div> :
|
||||
(data.devices ?? []).map((d: any) => (
|
||||
<GlassCard key={d.hwid} className="mb-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-start gap-3">
|
||||
<Smartphone size={20} className="text-accent mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">{d.deviceModel || "Устройство"}</div>
|
||||
<div className="text-xs text-white/40">{[d.platform, d.osVersion].filter(Boolean).join(" ")}</div>
|
||||
<div className="text-xs text-white/25 mt-1 font-mono">{d.hwid}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="danger" className="text-xs py-1.5 px-3" onClick={() => handleDelete(d.hwid)}>Удалить</Button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,71 @@
|
||||
export default function Keys() { return <div>Keys</div>; }
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../api/client";
|
||||
import { DashboardLayout } from "./DashboardLayout";
|
||||
import { GlassCard } from "../../components/ui/GlassCard";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { QRModal } from "../../components/ui/QRModal";
|
||||
import { StatusBadge } from "../../components/ui/StatusBadge";
|
||||
|
||||
export default function Keys() {
|
||||
const [profile, setProfile] = useState<any>(null);
|
||||
const [qrKey, setQrKey] = useState<any>(null);
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
const load = () => api.action("profile").then((r) => { if (r.ok) setProfile(r); });
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function handleRename(clientId: string) {
|
||||
if (!newName.trim()) return;
|
||||
await api.action("rename_key", { client_id: clientId, name: newName.trim() });
|
||||
setRenaming(null); setNewName("");
|
||||
load();
|
||||
}
|
||||
|
||||
async function handleFreeze(clientId: string, frozen: boolean) {
|
||||
await api.action("freeze_key", { client_id: clientId, frozen });
|
||||
load();
|
||||
}
|
||||
|
||||
const keys = profile?.keys ?? [];
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
{qrKey && <QRModal text={qrKey.remnawave_link || qrKey.link} title={qrKey.name} onClose={() => setQrKey(null)} />}
|
||||
<h1 className="text-2xl font-bold mb-6">Мои ключи</h1>
|
||||
{!keys.length && <div className="text-white/40 text-center py-12">Ключей нет. Купите подписку.</div>}
|
||||
{keys.map((k: any) => (
|
||||
<GlassCard key={k.client_id} className="mb-4">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
{renaming === k.client_id ? (
|
||||
<div className="flex gap-2">
|
||||
<input className="bg-white/10 border border-white/20 rounded-lg px-3 py-1.5 text-sm text-white outline-none focus:border-accent/60"
|
||||
value={newName} onChange={(e) => setNewName(e.target.value)} autoFocus />
|
||||
<Button className="text-xs py-1.5 px-3" onClick={() => handleRename(k.client_id)}>Сохранить</Button>
|
||||
<Button variant="secondary" className="text-xs py-1.5 px-3" onClick={() => setRenaming(null)}>Отмена</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{k.name || "Ключ"}</span>
|
||||
<button className="text-white/30 hover:text-white text-xs" onClick={() => { setRenaming(k.client_id); setNewName(k.name || ""); }}>✏️</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-white/40 mt-1">Сервер: {k.server_id} · до {k.until}</div>
|
||||
</div>
|
||||
<StatusBadge active={k.active && !k.is_frozen} activeLabel={k.is_frozen ? "❄️ Заморожен" : "Активен"} inactiveLabel="Неактивен" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(k.remnawave_link || k.link) && (
|
||||
<Button variant="secondary" className="text-xs py-1.5 px-3" onClick={() => setQrKey(k)}>📷 QR-код</Button>
|
||||
)}
|
||||
<Button variant="secondary" className="text-xs py-1.5 px-3"
|
||||
onClick={() => handleFreeze(k.client_id, !k.is_frozen)}>
|
||||
{k.is_frozen ? "▶ Разморозить" : "❄️ Заморозить"}
|
||||
</Button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,26 @@
|
||||
export default function Payments() { return <div>Payments</div>; }
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../api/client";
|
||||
import { DashboardLayout } from "./DashboardLayout";
|
||||
import { GlassCard } from "../../components/ui/GlassCard";
|
||||
|
||||
export default function Payments() {
|
||||
const [payments, setPayments] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
api.action("profile").then((r) => { if (r.ok) setPayments(r.payments ?? []); });
|
||||
}, []);
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<h1 className="text-2xl font-bold mb-6">История платежей</h1>
|
||||
{!payments.length ? <div className="text-white/40 text-center py-12">Платежей нет</div> :
|
||||
payments.map((p, i) => (
|
||||
<GlassCard key={i} className="mb-3 flex justify-between items-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{p.created_at}</div>
|
||||
<div className="text-xs text-white/40">{p.payment_system} · {p.status === "success" ? "✅ Успешно" : "❌ Ошибка"}</div>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-accent">{p.amount} ₽</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,28 @@
|
||||
export default function Servers() { return <div>Servers</div>; }
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../api/client";
|
||||
import { DashboardLayout } from "./DashboardLayout";
|
||||
import { GlassCard } from "../../components/ui/GlassCard";
|
||||
import { StatusBadge } from "../../components/ui/StatusBadge";
|
||||
|
||||
export default function Servers() {
|
||||
const [servers, setServers] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
api.action("servers").then((r) => { if (r.ok) setServers(r.servers ?? []); });
|
||||
}, []);
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<h1 className="text-2xl font-bold mb-6">Серверы</h1>
|
||||
{servers.map((s: any) => (
|
||||
<GlassCard key={s.id} className="mb-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="font-medium">{s.name}</div>
|
||||
<div className="text-xs text-white/40">{s.cluster} · {s.panel_type} · макс. {s.max_keys} ключей</div>
|
||||
</div>
|
||||
<StatusBadge active={s.enabled} activeLabel="Онлайн" inactiveLabel="Офлайн" />
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user