feat(admin): integrate Next.js admin panel (admin-next/) from feat/nextjs-admin
- Add admin-next/: full Next.js + Prisma + shadcn admin panel (45 API routes, 76 components) - docker-compose: tg_shop_admin service (port 3000, shared db/shop.db, prisma), bot talks to it via ADMIN_CHAT_URL=http://tg_shop_admin:3000/api/chat - tor-proxy now proxies onion admin to tg_shop_admin:3000 (new panel) - chatbotService: ADMIN_CHAT_URL default = http://tg_shop_admin:3000/api/chat (removed localhost:3100 anachronism) - .env admin secrets gitignored (admin-next/.env)
This commit is contained in:
332
admin-next/src/components/layout/admin-sidebar.tsx
Executable file
332
admin-next/src/components/layout/admin-sidebar.tsx
Executable file
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
Users,
|
||||
Wallet,
|
||||
ShoppingCart,
|
||||
FileText,
|
||||
Settings,
|
||||
Languages,
|
||||
AlertTriangle,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
Shield,
|
||||
Bot,
|
||||
Target,
|
||||
FolderTree,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
const mainNav = [
|
||||
{ title: "Dashboard", href: "/", icon: LayoutDashboard, shortcut: "1" },
|
||||
{ title: "Пользователи", href: "/users", icon: Users, shortcut: "2" },
|
||||
{ title: "Кошельки", href: "/wallets", icon: Wallet, shortcut: "3" },
|
||||
{ title: "Покупки", href: "/purchases", icon: ShoppingCart, badge: true, shortcut: "4" },
|
||||
{ title: "Аудит", href: "/audit", icon: FileText, shortcut: "5" },
|
||||
];
|
||||
|
||||
const catalogNav = [
|
||||
{ title: "Каталог товаров", href: "/catalog", icon: FolderTree, shortcut: "6" },
|
||||
];
|
||||
|
||||
const automationNav = [
|
||||
{ title: "AI Chatbot", href: "/chatbot", icon: Bot },
|
||||
{ title: "Лиды", href: "/leads", icon: Target },
|
||||
];
|
||||
|
||||
const systemNav = [
|
||||
{ title: "Настройки", href: "/settings", icon: Settings, shortcut: "9" },
|
||||
{ title: "Локали", href: "/locales", icon: Languages },
|
||||
];
|
||||
|
||||
function usePendingCount(isAuthenticated: boolean) {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
let cancelled = false;
|
||||
|
||||
const load = () => {
|
||||
fetch("/api/purchases/bulk?status=pending&limit=1")
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (!cancelled && data) setCount(data.total ?? 0);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
load();
|
||||
window.addEventListener("focus", load);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("focus", load);
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
function useConnectionStatus() {
|
||||
const { checkSession } = useAuthStore();
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const check = () => {
|
||||
checkSession().then((valid) => {
|
||||
if (!cancelled) setConnected(valid);
|
||||
});
|
||||
};
|
||||
|
||||
check();
|
||||
window.addEventListener("focus", check);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("focus", check);
|
||||
};
|
||||
}, [checkSession]);
|
||||
|
||||
return connected;
|
||||
}
|
||||
|
||||
function useHashPath() {
|
||||
const [hash, setHash] = useState("");
|
||||
useEffect(() => {
|
||||
const update = () => setHash(window.location.hash.slice(1) || "/");
|
||||
update();
|
||||
window.addEventListener("hashchange", update);
|
||||
return () => window.removeEventListener("hashchange", update);
|
||||
}, []);
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = useHashPath();
|
||||
const { role, logout, isAuthenticated } = useAuthStore();
|
||||
const pendingCount = usePendingCount(isAuthenticated);
|
||||
const connected = useConnectionStatus();
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="p-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 group-data-[collapsible=icon]:justify-center w-full transition-transform hover:scale-110 cursor-pointer"
|
||||
onClick={() => { window.location.hash = '#/'; }}
|
||||
aria-label="Go to Dashboard"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground text-sm font-bold">
|
||||
TS
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
|
||||
<span className="text-sm font-semibold truncate">TG Shop</span>
|
||||
<span className="text-xs text-muted-foreground">Admin Panel</span>
|
||||
</div>
|
||||
</button>
|
||||
</SidebarHeader>
|
||||
<SidebarSeparator />
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Основное</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="stagger-in">
|
||||
{mainNav.map((item) => (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={
|
||||
item.href === "/"
|
||||
? pathname === "/"
|
||||
: pathname.startsWith(item.href)
|
||||
}
|
||||
tooltip={item.title}
|
||||
className="[&[data-active='true']]:border-l-2 [&[data-active='true']]:border-l-sidebar-primary relative"
|
||||
>
|
||||
<a href={item.href}>
|
||||
<item.icon className="size-4 text-muted-foreground transition-colors group-hover/sidebar-menu-button:text-primary" />
|
||||
<span>{item.title}</span>
|
||||
{item.shortcut && (
|
||||
<kbd className="ml-auto text-[10px] font-mono text-muted-foreground/50 bg-muted/50 px-1.5 py-0.5 rounded group-data-[collapsible=icon]:hidden">
|
||||
{item.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
{item.badge && pendingCount > 0 && (
|
||||
<SidebarMenuBadge className="bg-destructive text-destructive-foreground">
|
||||
{pendingCount}
|
||||
</SidebarMenuBadge>
|
||||
)}
|
||||
{item.badge && pendingCount > 0 && (
|
||||
<span className="sidebar-indicator-dot absolute left-0 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full bg-destructive group-data-[collapsible=icon]:left-1/2 group-data-[collapsible=icon]:-translate-x-1/2" />
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Каталог товаров</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="stagger-in">
|
||||
{catalogNav.map((item) => (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={
|
||||
item.href === "/catalog"
|
||||
? (pathname === "/catalog" || pathname.startsWith("/catalog?"))
|
||||
: pathname === item.href || pathname.startsWith(item.href)
|
||||
}
|
||||
tooltip={item.title}
|
||||
className="[&[data-active='true']]:border-l-2 [&[data-active='true']]:border-l-sidebar-primary relative"
|
||||
>
|
||||
<a href={item.href}>
|
||||
<item.icon className="size-4 text-muted-foreground transition-colors group-hover/sidebar-menu-button:text-primary" />
|
||||
<span>{item.title}</span>
|
||||
{item.shortcut && (
|
||||
<kbd className="ml-auto text-[10px] font-mono text-muted-foreground/50 bg-muted/50 px-1.5 py-0.5 rounded group-data-[collapsible=icon]:hidden">
|
||||
{item.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Автоматизация</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="stagger-in">
|
||||
{automationNav.map((item) => (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname.startsWith(item.href)}
|
||||
tooltip={item.title}
|
||||
className="[&[data-active='true']]:border-l-2 [&[data-active='true']]:border-l-sidebar-primary relative"
|
||||
>
|
||||
<a href={item.href}>
|
||||
<item.icon className="size-4 text-muted-foreground transition-colors group-hover/sidebar-menu-button:text-primary" />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Система</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu className="stagger-in">
|
||||
{systemNav.map((item) => (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname.startsWith(item.href)}
|
||||
tooltip={item.title}
|
||||
className="[&[data-active='true']]:border-l-2 [&[data-active='true']]:border-l-sidebar-primary relative"
|
||||
>
|
||||
<a href={item.href}>
|
||||
<item.icon className="size-4 text-muted-foreground transition-colors group-hover/sidebar-menu-button:text-primary" />
|
||||
<span>{item.title}</span>
|
||||
{item.shortcut && (
|
||||
<kbd className="ml-auto text-[10px] font-mono text-muted-foreground/50 bg-muted/50 px-1.5 py-0.5 rounded group-data-[collapsible=icon]:hidden">
|
||||
{item.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
{role === "super_admin" && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname.startsWith("/seed")}
|
||||
tooltip="Danger Zone"
|
||||
>
|
||||
<a href="/seed">
|
||||
<AlertTriangle className="size-4 text-destructive" />
|
||||
<span className="text-destructive">Danger Zone</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarSeparator className="mb-1" />
|
||||
<div className="flex items-center gap-3 px-3 py-2 group-data-[collapsible=icon]:justify-center">
|
||||
<Avatar className="size-8 ring-1 ring-border">
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-xs">
|
||||
{role === "super_admin" ? (
|
||||
<ShieldCheck className="size-4" />
|
||||
) : (
|
||||
<Shield className="size-4" />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-1 flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{role === "super_admin" ? "Super Admin" : "Admin"}
|
||||
</span>
|
||||
<Badge
|
||||
variant={role === "super_admin" ? "default" : "secondary"}
|
||||
className="w-fit text-[10px] px-1.5 py-0 mt-0.5"
|
||||
>
|
||||
{role}
|
||||
</Badge>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="shrink-0 rounded-md p-1.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors group-data-[collapsible=icon]:hidden"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 pb-3 pt-1 group-data-[collapsible=icon]:justify-center">
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 transition-colors ${
|
||||
connected ? "bg-green-500 glow-success" : "bg-muted-foreground/50"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-[11px] text-muted-foreground group-data-[collapsible=icon]:hidden">
|
||||
{connected ? "Connected" : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user