dokploy/apps/dokploy/components/layouts/side.tsx

1130 lines
32 KiB
TypeScript

"use client";
import {
Activity,
BarChartHorizontalBigIcon,
Bell,
BlocksIcon,
BookIcon,
BotIcon,
Boxes,
ChevronRight,
ChevronsUpDown,
CircleHelp,
CreditCard,
Database,
Folder,
Forward,
GalleryVerticalEnd,
GitBranch,
HeartIcon,
KeyRound,
Loader2,
type LucideIcon,
Package,
PieChart,
Server,
ShieldCheck,
Trash2,
User,
Users,
} from "lucide-react";
import { usePathname } from "next/navigation";
import type * as React from "react";
import { useEffect, useState } from "react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from "@/components/ui/breadcrumb";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import {
SIDEBAR_COOKIE_NAME,
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import type { inferRouterOutputs } from "@trpc/server";
import Link from "next/link";
import { useRouter } from "next/router";
import { toast } from "sonner";
import { AddOrganization } from "../dashboard/organization/handle-organization";
import { DialogAction } from "../shared/dialog-action";
import { Logo } from "../shared/logo";
import { Button } from "../ui/button";
import { UpdateServerButton } from "./update-server";
import { UserNav } from "./user-nav";
import { useTranslation } from "next-i18next";
// The types of the queries we are going to use
type AuthQueryOutput = inferRouterOutputs<AppRouter>["user"]["get"];
type SingleNavItem = {
isSingle?: true;
title: string;
titleKey: string;
url: string;
icon?: LucideIcon;
isEnabled?: (opts: {
auth?: AuthQueryOutput;
isCloud: boolean;
}) => boolean;
};
// NavItem type
// Consists of a single item or a group of items
// If `isSingle` is true or undefined, the item is a single item
// If `isSingle` is false, the item is a group of items
type NavItem =
| SingleNavItem
| {
isSingle: false;
title: string;
titleKey: string;
icon: LucideIcon;
items: SingleNavItem[];
isEnabled?: (opts: {
auth?: AuthQueryOutput;
isCloud: boolean;
}) => boolean;
};
// ExternalLink type
// Represents an external link item (used for the help section)
type ExternalLink = {
name: string;
nameKey: string;
url: string;
icon: React.ComponentType<{ className?: string }>;
isEnabled?: (opts: {
auth?: AuthQueryOutput;
isCloud: boolean;
}) => boolean;
};
// Menu type
// Consists of home, settings, and help items
type Menu = {
home: NavItem[];
settings: NavItem[];
help: ExternalLink[];
};
// Menu items
// Consists of unfiltered home, settings, and help items
// The items are filtered based on the user's role and permissions
// The `isEnabled` function is called to determine if the item should be displayed
const MENU: Menu = {
home: [
{
isSingle: true,
title: "Projects",
titleKey: "common.side.projects",
url: "/dashboard/projects",
icon: Folder,
},
{
isSingle: true,
title: "Monitoring",
titleKey: "common.side.monitoring",
url: "/dashboard/monitoring",
icon: BarChartHorizontalBigIcon,
// Only enabled in non-cloud environments
isEnabled: ({ isCloud }) => !isCloud,
},
{
isSingle: true,
title: "Traefik File System",
titleKey: "common.side.traefik",
url: "/dashboard/traefik",
icon: GalleryVerticalEnd,
// Only enabled for admins and users with access to Traefik files in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role === "owner" || auth?.canAccessToTraefikFiles) &&
!isCloud
),
},
{
isSingle: true,
title: "Docker",
titleKey: "common.side.docker",
url: "/dashboard/docker",
icon: BlocksIcon,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
},
{
isSingle: true,
title: "Swarm",
titleKey: "common.side.swarm",
url: "/dashboard/swarm",
icon: PieChart,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
},
{
isSingle: true,
title: "Requests",
titleKey: "common.side.requests",
url: "/dashboard/requests",
icon: Forward,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
},
// Legacy unused menu, adjusted to the new structure
// {
// isSingle: true,
// title: "Projects",
// url: "/dashboard/projects",
// icon: Folder,
// },
// {
// isSingle: true,
// title: "Monitoring",
// icon: BarChartHorizontalBigIcon,
// url: "/dashboard/settings/monitoring",
// },
// {
// isSingle: false,
// title: "Settings",
// icon: Settings2,
// items: [
// {
// title: "Profile",
// url: "/dashboard/settings/profile",
// },
// {
// title: "Users",
// url: "/dashboard/settings/users",
// },
// {
// title: "SSH Key",
// url: "/dashboard/settings/ssh-keys",
// },
// {
// title: "Git",
// url: "/dashboard/settings/git-providers",
// },
// ],
// },
// {
// isSingle: false,
// title: "Integrations",
// icon: BlocksIcon,
// items: [
// {
// title: "S3 Destinations",
// url: "/dashboard/settings/destinations",
// },
// {
// title: "Registry",
// url: "/dashboard/settings/registry",
// },
// {
// title: "Notifications",
// url: "/dashboard/settings/notifications",
// },
// ],
// },
],
settings: [
{
isSingle: true,
title: "Web Server",
titleKey: "common.side.web-server",
url: "/dashboard/settings/server",
icon: Activity,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
},
{
isSingle: true,
title: "Profile",
titleKey: "common.side.profile",
url: "/dashboard/settings/profile",
icon: User,
},
{
isSingle: true,
title: "Remote Servers",
titleKey: "common.side.remote-servers",
url: "/dashboard/settings/servers",
icon: Server,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
title: "Users",
titleKey: "common.side.users",
icon: Users,
url: "/dashboard/settings/users",
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
title: "SSH Keys",
titleKey: "common.side.ssh-keys",
icon: KeyRound,
url: "/dashboard/settings/ssh-keys",
// Only enabled for admins and users with access to SSH keys
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.canAccessToSSHKeys),
},
{
title: "AI",
titleKey: "common.side.ai",
icon: BotIcon,
url: "/dashboard/settings/ai",
isSingle: true,
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
title: "Git",
titleKey: "common.side.git",
url: "/dashboard/settings/git-providers",
icon: GitBranch,
// Only enabled for admins and users with access to Git providers
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.canAccessToGitProviders),
},
{
isSingle: true,
title: "Registry",
titleKey: "common.side.registry",
url: "/dashboard/settings/registry",
icon: Package,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
title: "S3 Destinations",
titleKey: "common.side.s3-destinations",
url: "/dashboard/settings/destinations",
icon: Database,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
title: "Certificates",
titleKey: "common.side.certificates",
url: "/dashboard/settings/certificates",
icon: ShieldCheck,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
title: "Cluster",
titleKey: "common.side.cluster",
url: "/dashboard/settings/cluster",
icon: Boxes,
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
},
{
isSingle: true,
title: "Notifications",
titleKey: "common.side.notifications",
url: "/dashboard/settings/notifications",
icon: Bell,
// Only enabled for admins
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
title: "Billing",
titleKey: "common.side.billing",
url: "/dashboard/settings/billing",
icon: CreditCard,
// Only enabled for admins in cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
},
],
help: [
{
name: "Documentation",
nameKey: "common.side.documentation",
url: "https://docs.dokploy.com/docs/core",
icon: BookIcon,
},
{
name: "Support",
nameKey: "common.side.support",
url: "https://discord.gg/2tBnJ3jDJc",
icon: CircleHelp,
},
{
name: "Sponsor",
nameKey: "common.side.sponsor",
url: "https://opencollective.com/dokploy",
icon: ({ className }) => (
<HeartIcon
className={cn(
"text-red-500 fill-red-600 animate-heartbeat",
className,
)}
/>
),
},
],
} as const;
/**
* Creates a menu based on the current user's role and permissions
* @returns a menu object with the home, settings, and help items
*/
function createMenuForAuthUser(opts: {
auth?: AuthQueryOutput;
isCloud: boolean;
}): Menu {
return {
// Filter the home items based on the user's role and permissions
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
home: MENU.home.filter((item) =>
!item.isEnabled
? true
: item.isEnabled({
auth: opts.auth,
isCloud: opts.isCloud,
}),
),
// Filter the settings items based on the user's role and permissions
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
settings: MENU.settings.filter((item) =>
!item.isEnabled
? true
: item.isEnabled({
auth: opts.auth,
isCloud: opts.isCloud,
}),
),
// Filter the help items based on the user's role and permissions
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
help: MENU.help.filter((item) =>
!item.isEnabled
? true
: item.isEnabled({
auth: opts.auth,
isCloud: opts.isCloud,
}),
),
};
}
/**
* Determines if an item url is active based on the current pathname
* @returns true if the item url is active, false otherwise
*/
function isActiveRoute(opts: {
/** The url of the item. Usually obtained from `item.url` */
itemUrl: string;
/** The current pathname. Usually obtained from `usePathname()` */
pathname: string;
}): boolean {
const normalizedItemUrl = opts.itemUrl?.replace("/projects", "/project");
const normalizedPathname = opts.pathname?.replace("/projects", "/project");
if (!normalizedPathname) return false;
if (normalizedPathname === normalizedItemUrl) return true;
if (normalizedPathname.startsWith(normalizedItemUrl)) {
const nextChar = normalizedPathname.charAt(normalizedItemUrl.length);
return nextChar === "/";
}
return false;
}
/**
* Finds the active nav item based on the current pathname
* @returns the active nav item with `SingleNavItem` type or undefined if none is active
*/
function findActiveNavItem(
navItems: NavItem[],
pathname: string,
): SingleNavItem | undefined {
const found = navItems.find((item) =>
item.isSingle !== false
? // The current item is single, so check if the item url is active
isActiveRoute({ itemUrl: item.url, pathname })
: // The current item is not single, so check if any of the sub items are active
item.items.some((item) =>
isActiveRoute({ itemUrl: item.url, pathname }),
),
);
if (found?.isSingle !== false) {
// The found item is single, so return it
return found;
}
// The found item is not single, so find the active sub item
return found?.items.find((item) =>
isActiveRoute({ itemUrl: item.url, pathname }),
);
}
interface Props {
children: React.ReactNode;
}
function LogoWrapper() {
return <SidebarLogo />;
}
function SidebarLogo() {
const { t } = useTranslation("common");
const { state } = useSidebar();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: user } = api.user.get.useQuery();
const { data: session } = authClient.useSession();
const {
data: organizations,
refetch,
isLoading,
} = api.organization.all.useQuery();
const { mutateAsync: deleteOrganization, isLoading: isRemoving } =
api.organization.delete.useMutation();
const { isMobile } = useSidebar();
const { data: activeOrganization } = authClient.useActiveOrganization();
const _utils = api.useUtils();
const { data: invitations, refetch: refetchInvitations } =
api.user.getInvitations.useQuery();
const [_activeTeam, setActiveTeam] = useState<
typeof activeOrganization | null
>(null);
useEffect(() => {
if (activeOrganization) {
setActiveTeam(activeOrganization);
}
}, [activeOrganization]);
return (
<>
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[5vh] pt-4">
<Loader2 className="animate-spin size-4" />
</div>
) : (
<SidebarMenu
className={cn(
"flex gap-2",
state === "collapsed"
? "flex-col"
: "flex-row justify-between items-center",
)}
>
{/* Organization Logo and Selector */}
<SidebarMenuItem className={"w-full"}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size={state === "collapsed" ? "sm" : "lg"}
className={cn(
"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground",
state === "collapsed" &&
"flex justify-center items-center p-2 h-10 w-10 mx-auto",
)}
>
<div
className={cn(
"flex items-center gap-2",
state === "collapsed" && "justify-center",
)}
>
<div
className={cn(
"flex items-center justify-center rounded-sm border",
"size-6",
)}
>
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-4" : "size-5",
)}
logoUrl={activeOrganization?.logo || undefined}
/>
</div>
<div
className={cn(
"flex flex-col items-start",
state === "collapsed" && "hidden",
)}
>
<p className="text-sm font-medium leading-none">
{activeOrganization?.name ??
t("common.side.organizations.select-organization")}
</p>
</div>
</div>
<ChevronsUpDown
className={cn("ml-auto", state === "collapsed" && "hidden")}
/>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="rounded-lg"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t("common.side.organizations")}
</DropdownMenuLabel>
{organizations?.map((org) => (
<div className="flex flex-row justify-between" key={org.name}>
<DropdownMenuItem
onClick={async () => {
await authClient.organization.setActive({
organizationId: org.id,
});
window.location.reload();
}}
className="w-full gap-2 p-2"
>
<div className="flex flex-col gap-4">{org.name}</div>
<div className="flex size-6 items-center justify-center rounded-sm border">
<Logo
className={cn(
"transition-all",
state === "collapsed" ? "size-6" : "size-10",
)}
logoUrl={org.logo ?? undefined}
/>
</div>
</DropdownMenuItem>
{org.ownerId === session?.user?.id && (
<div className="flex items-center gap-2">
<AddOrganization organizationId={org.id} />
<DialogAction
title={t(
"common.side.organizations.delete-organization",
)}
description={t(
"common.side.organizations.confirm-delete-organization",
)}
type="destructive"
onClick={async () => {
await deleteOrganization({
organizationId: org.id,
})
.then(() => {
refetch();
toast.success(
t(
"common.side.organizations.organization-deleted",
),
);
})
.catch((error) => {
toast.error(
error?.message ||
t(
"common.side.organizations.error-deleting-organization",
),
);
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
)}
</div>
))}
{(user?.role === "owner" || isCloud) && (
<>
<DropdownMenuSeparator />
<AddOrganization />
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
{/* Notification Bell */}
<SidebarMenuItem className={cn(state === "collapsed" && "mt-2")}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"relative",
state === "collapsed" && "h-8 w-8 p-1.5 mx-auto",
)}
>
<Bell className="size-4" />
{invitations && invitations.length > 0 && (
<span className="absolute -top-0 -right-0 flex size-4 items-center justify-center rounded-full bg-blue-500 text-xs text-white">
{invitations.length}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side={"right"}
className="w-80"
>
<DropdownMenuLabel>
{t("common.side.invitations.pending-invitations")}
</DropdownMenuLabel>
<div className="flex flex-col gap-2">
{invitations && invitations.length > 0 ? (
invitations.map((invitation) => (
<div key={invitation.id} className="flex flex-col gap-2">
<DropdownMenuItem
className="flex flex-col items-start gap-1 p-3"
onSelect={(e) => e.preventDefault()}
>
<div className="font-medium">
{invitation?.organization?.name}
</div>
<div className="text-xs text-muted-foreground">
{t("common.side.invitations.expires", {
expireDate: new Date(
invitation.expiresAt,
).toLocaleString(),
})}
</div>
<div className="text-xs text-muted-foreground">
{t("common.side.invitations.role", {
role: invitation.role,
})}
</div>
</DropdownMenuItem>
<DialogAction
title={t("common.side.invitations.accept-invitation")}
description={t(
"common.side.invitations.confirm-accept-invitation",
)}
type="default"
onClick={async () => {
const { error } =
await authClient.organization.acceptInvitation({
invitationId: invitation.id,
});
if (error) {
toast.error(
error.message ||
t(
"common.side.invitations.error-accepting-invitation",
),
);
} else {
toast.success(
t(
"common.side.invitations.invitation-accepted",
),
);
await refetchInvitations();
await refetch();
}
}}
>
<Button size="sm" variant="secondary">
{t("common.side.invitations.accept-invitation")}
</Button>
</DialogAction>
</div>
))
) : (
<DropdownMenuItem disabled>
{t("common.side.invitations.no-pending-invitations")}
</DropdownMenuItem>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)}
</>
);
}
export default function Page({ children }: Props) {
const { t } = useTranslation("common");
const [defaultOpen, setDefaultOpen] = useState<boolean | undefined>(
undefined,
);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const cookieValue = document.cookie
.split("; ")
.find((row) => row.startsWith(`${SIDEBAR_COOKIE_NAME}=`))
?.split("=")[1];
setDefaultOpen(cookieValue === undefined ? true : cookieValue === "true");
setIsLoaded(true);
}, []);
const router = useRouter();
const pathname = usePathname();
const _currentPath = router.pathname;
const { data: auth } = api.user.get.useQuery();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
const includesProjects = pathname?.includes("/dashboard/project");
const { data: isCloud } = api.settings.isCloud.useQuery();
const {
home: filteredHome,
settings: filteredSettings,
help,
} = createMenuForAuthUser({ auth, isCloud: !!isCloud });
const activeItem = findActiveNavItem(
[...filteredHome, ...filteredSettings],
pathname,
);
if (!isLoaded) {
return <div className="w-full h-screen bg-background" />; // Placeholder mientras se carga
}
return (
<SidebarProvider
defaultOpen={defaultOpen}
open={defaultOpen}
onOpenChange={(open) => {
setDefaultOpen(open);
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}`;
}}
style={
{
"--sidebar-width": "19.5rem",
"--sidebar-width-mobile": "19.5rem",
} as React.CSSProperties
}
>
<Sidebar collapsible="icon" variant="floating">
<SidebarHeader>
{/* <SidebarMenuButton
className="group-data-[collapsible=icon]:!p-0"
size="lg"
> */}
<LogoWrapper />
{/* </SidebarMenuButton> */}
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>{t("common.side.home")}</SidebarGroupLabel>
<SidebarMenu>
{filteredHome.map((item) => {
const isSingle = item.isSingle !== false;
const isActive = isSingle
? isActiveRoute({ itemUrl: item.url, pathname })
: item.items.some((item) =>
isActiveRoute({ itemUrl: item.url, pathname }),
);
return (
<Collapsible
key={item.title}
asChild
defaultOpen={isActive}
className="group/collapsible"
>
<SidebarMenuItem>
{isSingle ? (
<SidebarMenuButton
asChild
tooltip={t(item.titleKey)}
className={cn(isActive && "bg-border")}
>
<Link
href={item.url}
className="flex w-full items-center gap-2"
>
{item.icon && (
<item.icon
className={cn(isActive && "text-primary")}
/>
)}
<span>{t(item.titleKey)}</span>
</Link>
</SidebarMenuButton>
) : (
<>
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={t(item.titleKey)}
isActive={isActive}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.items?.length && (
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
)}
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
className={cn(isActive && "bg-border")}
>
<Link
href={subItem.url}
className="flex w-full items-center"
>
{subItem.icon && (
<span className="mr-2">
<subItem.icon
className={cn(
"h-4 w-4 text-muted-foreground",
isActive && "text-primary",
)}
/>
</span>
)}
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
)}
</SidebarMenuItem>
</Collapsible>
);
})}
</SidebarMenu>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>{t("common.side.settings")}</SidebarGroupLabel>
<SidebarMenu className="gap-2">
{filteredSettings.map((item) => {
const isSingle = item.isSingle !== false;
const isActive = isSingle
? isActiveRoute({ itemUrl: item.url, pathname })
: item.items.some((item) =>
isActiveRoute({ itemUrl: item.url, pathname }),
);
return (
<Collapsible
key={item.title}
asChild
defaultOpen={isActive}
className="group/collapsible"
>
<SidebarMenuItem>
{isSingle ? (
<SidebarMenuButton
asChild
tooltip={t(item.titleKey)}
className={cn(isActive && "bg-border")}
>
<Link
href={item.url}
className="flex w-full items-center gap-2"
>
{item.icon && (
<item.icon
className={cn(isActive && "text-primary")}
/>
)}
<span>{t(item.titleKey)}</span>
</Link>
</SidebarMenuButton>
) : (
<>
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={t(item.titleKey)}
isActive={isActive}
>
{item.icon && <item.icon />}
<span>{t(item.titleKey)}</span>
{item.items?.length && (
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
)}
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
className={cn(isActive && "bg-border")}
>
<Link
href={subItem.url}
className="flex w-full items-center"
>
{subItem.icon && (
<span className="mr-2">
<subItem.icon
className={cn(
"h-4 w-4 text-muted-foreground",
isActive && "text-primary",
)}
/>
</span>
)}
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
)}
</SidebarMenuItem>
</Collapsible>
);
})}
</SidebarMenu>
</SidebarGroup>
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>{t("common.side.extra")}</SidebarGroupLabel>
<SidebarMenu>
{help.map((item: ExternalLink) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center gap-2"
>
<span className="mr-2">
<item.icon className="h-4 w-4" />
</span>
<span>{t(item.nameKey)}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu className="flex flex-col gap-2">
{!isCloud && auth?.role === "owner" && (
<SidebarMenuItem>
<UpdateServerButton />
</SidebarMenuItem>
)}
<SidebarMenuItem>
<UserNav />
</SidebarMenuItem>
{dokployVersion && (
<>
<div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden">
Version {dokployVersion}
</div>
<div className="hidden text-xs text-muted-foreground text-center group-data-[collapsible=icon]:block">
{dokployVersion}
</div>
</>
)}
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
<SidebarInset>
{!includesProjects && (
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
<div className="flex items-center justify-between w-full px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="block">
<BreadcrumbLink asChild>
<Link
href={activeItem?.url || "/"}
className="flex items-center gap-1.5"
>
{activeItem?.title}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</div>
</header>
)}
<div className="flex flex-col w-full gap-4 p-4 pt-0">{children}</div>
</SidebarInset>
</SidebarProvider>
);
}