Files
tgex-frontend/components/layout/app-sidebar.tsx
2025-12-29 10:12:13 +03:00

452 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
// ============================================================================
// Application Sidebar
// ============================================================================
import * as React from "react";
import { useParams } from "next/navigation";
import {
BarChart3,
Building2,
Check,
ChevronsUpDown,
ClipboardList,
ExternalLink,
Folder,
HelpCircle,
LayoutDashboard,
Megaphone,
MessageCircle,
Plus,
Settings,
ShoppingCart,
Tv,
BookOpen,
type LucideIcon,
} from "lucide-react";
import { NavMain } from "@/components/nav-main";
import { NavUser } from "@/components/nav-user";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
SidebarGroup,
SidebarGroupContent,
} from "@/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/components/auth/auth-provider";
import { useWorkspace } from "@/components/providers/workspace-provider";
import { buildRoute, BOT_USERNAME } from "@/lib/utils/constants";
// ============================================================================
// Types
// ============================================================================
interface NavItem {
title: string;
url: string;
icon?: LucideIcon;
isActive?: boolean;
requiredPermission?: "projects_read" | "placements_read" | "analytics_read" | "admin_full";
items?: {
title: string;
url: string;
}[];
}
interface FooterLink {
title: string;
url: string;
icon: LucideIcon;
external?: boolean;
}
// ============================================================================
// Footer Links Configuration
// ============================================================================
const footerLinks: FooterLink[] = [
{
title: "Поддержка",
url: `https://t.me/${BOT_USERNAME}`,
icon: MessageCircle,
external: true,
},
{
title: "Информационный канал",
url: "https://t.me/tgex_news",
icon: Megaphone,
external: true,
},
{
title: "База знаний",
url: "https://docs.tgex.io",
icon: BookOpen,
external: true,
},
];
// ============================================================================
// Component
// ============================================================================
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const params = useParams();
const workspaceId = params?.workspaceId as string;
const { user } = useAuth();
const {
workspaces,
currentWorkspace,
setCurrentWorkspace,
hasPermission,
isAdmin,
createWorkspace,
} = useWorkspace();
const [showCreateDialog, setShowCreateDialog] = React.useState(false);
const [newWorkspaceName, setNewWorkspaceName] = React.useState("");
const [isCreating, setIsCreating] = React.useState(false);
// Build navigation items based on permissions
const navItems: NavItem[] = React.useMemo(() => {
if (!workspaceId) return [];
const items: NavItem[] = [
{
title: "Главная",
url: buildRoute.dashboard(workspaceId),
icon: LayoutDashboard,
isActive: true,
},
];
// Projects - always visible (contains instructions how to add projects via bot)
items.push({
title: "Проекты",
url: buildRoute.projects(workspaceId),
icon: Tv,
});
// Channels catalog (available to all)
items.push({
title: "Каталог каналов",
url: buildRoute.channels(workspaceId),
icon: Building2,
});
// Purchase Plans (requires placements_read or admin)
if (hasPermission("placements_read")) {
items.push({
title: "Планы закупок",
url: buildRoute.purchasePlans(workspaceId),
icon: ClipboardList,
requiredPermission: "placements_read",
});
}
// Creatives (requires placements_read or admin)
if (hasPermission("placements_read")) {
items.push({
title: "Креативы",
url: buildRoute.creatives(workspaceId),
icon: Folder,
requiredPermission: "placements_read",
items: [
{
title: "Все креативы",
url: buildRoute.creatives(workspaceId),
},
...(hasPermission("placements_write")
? [
{
title: "Создать",
url: buildRoute.creativeNew(workspaceId),
},
]
: []),
],
});
}
// Placements (requires placements_read or admin)
if (hasPermission("placements_read")) {
items.push({
title: "Размещения",
url: buildRoute.placements(workspaceId),
icon: ShoppingCart,
requiredPermission: "placements_read",
items: [
{
title: "Все размещения",
url: buildRoute.placements(workspaceId),
},
...(hasPermission("placements_write")
? [
{
title: "Создать",
url: buildRoute.placementNew(workspaceId),
},
]
: []),
],
});
}
// Analytics (requires analytics_read or admin)
if (hasPermission("analytics_read")) {
items.push({
title: "Аналитика",
url: buildRoute.analytics(workspaceId),
icon: BarChart3,
requiredPermission: "analytics_read",
items: [
{
title: "Обзор",
url: buildRoute.analytics(workspaceId),
},
{
title: "Расходы",
url: buildRoute.analyticsSpending(workspaceId),
},
{
title: "Каналы",
url: buildRoute.analyticsChannels(workspaceId),
},
{
title: "Креативы",
url: buildRoute.analyticsCreatives(workspaceId),
},
],
});
}
// Settings (admin only)
if (isAdmin) {
items.push({
title: "Настройки",
url: buildRoute.settings(workspaceId),
icon: Settings,
requiredPermission: "admin_full",
items: [
{
title: "Участники",
url: buildRoute.members(workspaceId),
},
{
title: "Приглашения",
url: buildRoute.invites(workspaceId),
},
],
});
}
return items;
}, [workspaceId, hasPermission, isAdmin]);
const handleCreateWorkspace = async () => {
if (!newWorkspaceName.trim()) return;
try {
setIsCreating(true);
const workspace = await createWorkspace(newWorkspaceName.trim());
setShowCreateDialog(false);
setNewWorkspaceName("");
setCurrentWorkspace(workspace);
} catch (err) {
console.error("Failed to create workspace:", err);
} finally {
setIsCreating(false);
}
};
return (
<>
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
{/* Workspace Switcher */}
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Megaphone className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{currentWorkspace?.name || "TGEX"}
</span>
<span className="truncate text-xs text-muted-foreground">
{workspaces.length} воркспейс{workspaces.length === 1 ? "" : workspaces.length < 5 ? "а" : "ов"}
</span>
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
align="start"
side="bottom"
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Воркспейсы
</DropdownMenuLabel>
{workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => setCurrentWorkspace(workspace)}
className="gap-2 p-2"
>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Building2 className="size-4 shrink-0" />
</div>
<span className="flex-1 truncate">{workspace.name}</span>
{currentWorkspace?.id === workspace.id && (
<Check className="size-4 ml-auto" />
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowCreateDialog(true)}
className="gap-2 p-2"
>
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<Plus className="size-4" />
</div>
<span className="text-muted-foreground">
Создать воркспейс
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={navItems} />
</SidebarContent>
<SidebarFooter>
{/* Footer Links */}
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{footerLinks.map((link) => (
<SidebarMenuItem key={link.title}>
<SidebarMenuButton asChild size="sm">
<a
href={link.url}
target={link.external ? "_blank" : undefined}
rel={link.external ? "noopener noreferrer" : undefined}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
<link.icon className="size-4" />
<span>{link.title}</span>
{link.external && (
<ExternalLink className="size-3 ml-auto opacity-50" />
)}
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
{/* User */}
{user && (
<NavUser
user={{
name: user.username || `User ${user.telegram_id}`,
email: user.username
? `@${user.username}`
: `ID: ${user.telegram_id}`,
avatar: user.username
? `https://ui-avatars.com/api/?name=${user.username}`
: `https://ui-avatars.com/api/?name=User`,
}}
/>
)}
</SidebarFooter>
<SidebarRail />
</Sidebar>
{/* Create Workspace Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Создать воркспейс</DialogTitle>
<DialogDescription>
Воркспейс это изолированное пространство для вашей команды
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="workspace-name">Название</Label>
<Input
id="workspace-name"
placeholder="Например: Моя команда"
value={newWorkspaceName}
onChange={(e) => setNewWorkspaceName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleCreateWorkspace();
}
}}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateDialog(false)}
>
Отмена
</Button>
<Button
onClick={handleCreateWorkspace}
disabled={!newWorkspaceName.trim() || isCreating}
>
{isCreating ? "Создание..." : "Создать"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}