Files
kandev/apps/web/components/integrations/integrations-menu.tsx
T

209 lines
6.6 KiB
TypeScript

"use client";
import { useEffect, useId, useRef, useState } from "react";
import Link from "@/components/routing/app-link";
import { Button } from "@kandev/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { IconChevronDown, IconChevronRight, IconPlugConnected } from "@tabler/icons-react";
import { DestinationRows } from "@/components/navigation/destination-rows";
import { useAppDestinations } from "@/hooks/use-app-destinations";
import { useAppStore } from "@/components/state-provider";
import { workspaceSettingsHref } from "@/lib/settings/workspace-settings-tabs";
import { useTranslation } from "react-i18next";
type MobileIntegrationsSectionProps = {
onNavigate: () => void;
showSetup?: boolean;
collapsible?: boolean;
};
const HOVER_CLOSE_DELAY_MS = 180;
const INTEGRATIONS_LABEL = "common:integrations";
/**
* Configured integration destinations, in manifest order. Ids, labels, hrefs and
* icons come from `lib/navigation/core-destinations.ts`; availability comes from
* `useNavAvailability` through `useAppDestinations`.
*/
function useIntegrationDestinations() {
return useAppDestinations("sidebar", "integrations");
}
export function IntegrationsMenu() {
const { t } = useTranslation();
const links = useIntegrationDestinations();
const [open, setOpen] = useState(false);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
};
}, []);
const clearCloseTimeout = () => {
if (!closeTimeoutRef.current) return;
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
};
const openOnHover = () => {
clearCloseTimeout();
setOpen((current) => (current ? current : true));
};
const closeAfterHover = () => {
clearCloseTimeout();
closeTimeoutRef.current = setTimeout(() => setOpen(false), HOVER_CLOSE_DELAY_MS);
};
const handleOpenChange = (nextOpen: boolean) => {
clearCloseTimeout();
setOpen(nextOpen);
};
if (links.length === 0) return null;
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange} modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-lg"
className="cursor-pointer text-muted-foreground hover:text-foreground"
aria-label={t(INTEGRATIONS_LABEL)}
onPointerEnter={openOnHover}
onPointerLeave={closeAfterHover}
>
<IconPlugConnected className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48"
onPointerEnter={openOnHover}
onPointerLeave={closeAfterHover}
>
<DropdownMenuLabel>{t(INTEGRATIONS_LABEL)}</DropdownMenuLabel>
{links.map((link) => {
const Icon = link.icon;
return (
<DropdownMenuItem key={link.id} asChild className="cursor-pointer">
<Link href={link.href}>
<Icon className="h-4 w-4 text-muted-foreground" />
{link.label}
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
export function IntegrationsTopbarLinks() {
const links = useIntegrationDestinations();
if (links.length === 0) return null;
return (
<>
{links.map((link) => {
const Icon = link.icon;
return (
<Tooltip key={link.id}>
<TooltipTrigger asChild>
<Button asChild variant="outline" size="icon-lg" className="cursor-pointer">
<Link href={link.href} aria-label={link.label}>
<Icon className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>{link.label}</TooltipContent>
</Tooltip>
);
})}
</>
);
}
/**
* Mobile counterpart to the desktop sidebar Integrations section: the
* hamburger-sheet surface that exposes the first-party integration links plus
* any plugin-registered nav items targeting this section
* (`registerNavItem({ section: "integrations" })`), matching the desktop
* `IntegrationsSection`. Both come from the navigation manifest, so the two
* surfaces cannot drift apart.
*/
export function MobileIntegrationsSection({
onNavigate,
showSetup = false,
collapsible = false,
}: MobileIntegrationsSectionProps) {
const { t } = useTranslation();
const destinations = useAppDestinations("mobileMenu", "integrations");
const [expanded, setExpanded] = useState(false);
const bodyId = useId();
const workspaceId = useAppStore((s) => s.workspaces.activeId);
if (destinations.length === 0 && !showSetup) return null;
return (
<div className={showSetup ? "space-y-3 border-t border-border pt-4" : "space-y-3"}>
{collapsible ? (
<Button
variant="ghost"
className="cursor-pointer h-11 w-full justify-start gap-2 px-0 text-sm font-medium hover:bg-transparent aria-expanded:bg-transparent"
aria-expanded={expanded}
aria-controls={bodyId}
onClick={() => setExpanded(!expanded)}
>
{t(INTEGRATIONS_LABEL)}
{expanded ? (
<IconChevronDown className="size-3.5 text-muted-foreground" />
) : (
<IconChevronRight className="size-3.5 text-muted-foreground" />
)}
</Button>
) : (
<div className="text-sm font-medium">{t(INTEGRATIONS_LABEL)}</div>
)}
{(!collapsible || expanded) && (
<div id={bodyId} className="space-y-3">
<DestinationRows
destinations={destinations}
onNavigate={onNavigate}
pluginTestIdPrefix="plugin-nav-item-"
/>
{showSetup && (
<Button
asChild
variant="outline"
className="cursor-pointer h-11 w-full justify-start gap-3 px-3 text-sm"
>
<Link
href={
workspaceId
? workspaceSettingsHref(workspaceId, "integrations")
: "/settings/workspaces"
}
onClick={onNavigate}
data-testid="mobile-integration-settings"
>
<IconPlugConnected className="size-4" />
{t("common:integrationSettings")}
</Link>
</Button>
)}
</div>
)}
</div>
);
}