452 lines
14 KiB
TypeScript
452 lines
14 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|