1306 lines
46 KiB
TypeScript
1306 lines
46 KiB
TypeScript
import {
|
|
ArrowTopRightOnSquareIcon,
|
|
ChevronRightIcon,
|
|
ExclamationTriangleIcon,
|
|
PencilSquareIcon,
|
|
} from "@heroicons/react/24/outline";
|
|
import { Link, useFetcher, useNavigation } from "@remix-run/react";
|
|
import { LayoutGroup, motion } from "framer-motion";
|
|
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
import simplur from "simplur";
|
|
import { AIChatIcon } from "~/assets/icons/AIChatIcon";
|
|
import { AIPenIcon } from "~/assets/icons/AIPenIcon";
|
|
import { ArrowLeftRightIcon } from "~/assets/icons/ArrowLeftRightIcon";
|
|
import { ArrowRightSquareIcon } from "~/assets/icons/ArrowRightSquareIcon";
|
|
import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon";
|
|
import { BatchesIcon } from "~/assets/icons/BatchesIcon";
|
|
import { BellIcon } from "~/assets/icons/BellIcon";
|
|
import { Box3DIcon } from "~/assets/icons/Box3DIcon";
|
|
import { BugIcon } from "~/assets/icons/BugIcon";
|
|
import { ChartBarIcon } from "~/assets/icons/ChartBarIcon";
|
|
import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon";
|
|
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
|
|
import { DeploymentsIcon } from "~/assets/icons/DeploymentsIcon";
|
|
import { DialIcon } from "~/assets/icons/DialIcon";
|
|
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
|
|
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
|
import { FolderClosedIcon } from "~/assets/icons/FolderClosedIcon";
|
|
import { FolderOpenIcon } from "~/assets/icons/FolderOpenIcon";
|
|
import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon";
|
|
import { HomeIcon } from "~/assets/icons/HomeIcon";
|
|
import { IDIcon } from "~/assets/icons/IDIcon";
|
|
import { IntegrationsIcon } from "~/assets/icons/IntegrationsIcon";
|
|
import { KeyIcon } from "~/assets/icons/KeyIcon";
|
|
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
|
|
import { LogsIcon } from "~/assets/icons/LogsIcon";
|
|
import { PlusIcon } from "~/assets/icons/PlusIcon";
|
|
import { QueuesIcon } from "~/assets/icons/QueuesIcon";
|
|
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
|
import { SlidersIcon } from "~/assets/icons/SlidersIcon";
|
|
import { TasksIcon } from "~/assets/icons/TasksIcon";
|
|
import { UsageIcon } from "~/assets/icons/UsageIcon";
|
|
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
|
|
import { Avatar } from "~/components/primitives/Avatar";
|
|
import { type MatchedEnvironment } from "~/hooks/useEnvironment";
|
|
import { useFeatureFlags } from "~/hooks/useFeatureFlags";
|
|
import { useFeatures } from "~/hooks/useFeatures";
|
|
import { type MatchedOrganization } from "~/hooks/useOrganizations";
|
|
import { type MatchedProject } from "~/hooks/useProject";
|
|
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
|
import { useHasAdminAccess } from "~/hooks/useUser";
|
|
import { type UserWithDashboardPreferences } from "~/models/user.server";
|
|
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
|
import { type FeedbackType } from "~/routes/resources.feedback";
|
|
import { IncidentStatusPanel, useIncidentStatus } from "~/routes/resources.incidents";
|
|
import { cn } from "~/utils/cn";
|
|
import {
|
|
accountPath,
|
|
adminPath,
|
|
branchesPath,
|
|
concurrencyPath,
|
|
limitsPath,
|
|
logoutPath,
|
|
newOrganizationPath,
|
|
newProjectPath,
|
|
organizationPath,
|
|
organizationSettingsPath,
|
|
organizationTeamPath,
|
|
queryPath,
|
|
regionsPath,
|
|
v3ApiKeysPath,
|
|
v3BatchesPath,
|
|
v3BillingPath,
|
|
v3BulkActionsPath,
|
|
v3DashboardsLandingPath,
|
|
v3DeploymentsPath,
|
|
v3EnvironmentPath,
|
|
v3EnvironmentVariablesPath,
|
|
v3ErrorsPath,
|
|
v3LogsPath,
|
|
v3ModelsPath,
|
|
v3ProjectAlertsPath,
|
|
v3ProjectPath,
|
|
v3ProjectSettingsGeneralPath,
|
|
v3ProjectSettingsIntegrationsPath,
|
|
v3PromptsPath,
|
|
v3QueuesPath,
|
|
v3RunsPath,
|
|
v3SessionsPath,
|
|
v3UsagePath,
|
|
v3WaitpointTokensPath,
|
|
} from "~/utils/pathBuilder";
|
|
import { AskAI } from "../AskAI";
|
|
import { FreePlanUsage } from "../billing/FreePlanUsage";
|
|
import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence";
|
|
import { AlphaBadge, NewBadge } from "../FeatureBadges";
|
|
import { ImpersonationBanner } from "../ImpersonationBanner";
|
|
import { Button, ButtonContent, LinkButton } from "../primitives/Buttons";
|
|
import { Dialog, DialogTrigger } from "../primitives/Dialog";
|
|
import { Paragraph } from "../primitives/Paragraph";
|
|
import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover";
|
|
import { ShortcutKey } from "../primitives/ShortcutKey";
|
|
import { TextLink } from "../primitives/TextLink";
|
|
import {
|
|
SimpleTooltip,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "../primitives/Tooltip";
|
|
import { ShortcutsAutoOpen } from "../Shortcuts";
|
|
import { CreateDashboardButton } from "./DashboardDialogs";
|
|
import { DashboardList } from "./DashboardList";
|
|
import { EnvironmentSelector } from "./EnvironmentSelector";
|
|
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
|
import { NotificationPanel } from "./NotificationPanel";
|
|
import { SideMenuHeader } from "./SideMenuHeader";
|
|
import { SideMenuItem } from "./SideMenuItem";
|
|
import { SideMenuSection } from "./SideMenuSection";
|
|
import { type SideMenuSectionId } from "./sideMenuTypes";
|
|
|
|
/** Get the collapsed state for a specific side menu section from user preferences */
|
|
function getSectionCollapsed(
|
|
sideMenu: { collapsedSections?: Record<string, boolean> } | undefined,
|
|
sectionId: SideMenuSectionId
|
|
): boolean {
|
|
return sideMenu?.collapsedSections?.[sectionId] ?? false;
|
|
}
|
|
|
|
type SideMenuUser = Pick<
|
|
UserWithDashboardPreferences,
|
|
"email" | "admin" | "dashboardPreferences"
|
|
> & {
|
|
isImpersonating: boolean;
|
|
};
|
|
export type SideMenuProject = Pick<
|
|
MatchedProject,
|
|
"id" | "name" | "slug" | "version" | "environments" | "engine" | "createdAt"
|
|
>;
|
|
export type SideMenuEnvironment = MatchedEnvironment;
|
|
|
|
type SideMenuProps = {
|
|
user: SideMenuUser;
|
|
project: SideMenuProject;
|
|
environment: SideMenuEnvironment;
|
|
organization: MatchedOrganization;
|
|
organizations: MatchedOrganization[];
|
|
button?: ReactNode;
|
|
defaultValue?: FeedbackType;
|
|
};
|
|
|
|
export function SideMenu({
|
|
user,
|
|
project,
|
|
environment,
|
|
organization,
|
|
organizations,
|
|
}: SideMenuProps) {
|
|
const borderRef = useRef<HTMLDivElement>(null);
|
|
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
|
|
const [isCollapsed, setIsCollapsed] = useState(
|
|
user.dashboardPreferences.sideMenu?.isCollapsed ?? false
|
|
);
|
|
const preferencesFetcher = useFetcher();
|
|
const pendingPreferencesRef = useRef<{
|
|
isCollapsed?: boolean;
|
|
sectionId?: SideMenuSectionId;
|
|
sectionCollapsed?: boolean;
|
|
}>({});
|
|
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const currentPlan = useCurrentPlan();
|
|
const { isConnected } = useDevPresence();
|
|
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
|
|
const isAdmin = useHasAdminAccess();
|
|
const { isManagedCloud } = useFeatures();
|
|
const featureFlags = useFeatureFlags();
|
|
const incidentStatus = useIncidentStatus();
|
|
const isV3Project = project.engine === "V1";
|
|
|
|
const persistSideMenuPreferences = useCallback(
|
|
(data: {
|
|
isCollapsed?: boolean;
|
|
sectionId?: SideMenuSectionId;
|
|
sectionCollapsed?: boolean;
|
|
}) => {
|
|
if (user.isImpersonating) return;
|
|
|
|
// Merge with any pending changes
|
|
pendingPreferencesRef.current = {
|
|
...pendingPreferencesRef.current,
|
|
...data,
|
|
};
|
|
|
|
// Clear existing timeout
|
|
if (debounceTimeoutRef.current) {
|
|
clearTimeout(debounceTimeoutRef.current);
|
|
}
|
|
|
|
// Debounce the actual submission by 500ms
|
|
debounceTimeoutRef.current = setTimeout(() => {
|
|
const pending = pendingPreferencesRef.current;
|
|
const formData = new FormData();
|
|
if (pending.isCollapsed !== undefined) {
|
|
formData.append("isCollapsed", String(pending.isCollapsed));
|
|
}
|
|
if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) {
|
|
formData.append("sectionId", pending.sectionId);
|
|
formData.append("sectionCollapsed", String(pending.sectionCollapsed));
|
|
}
|
|
preferencesFetcher.submit(formData, {
|
|
method: "POST",
|
|
action: "/resources/preferences/sidemenu",
|
|
});
|
|
pendingPreferencesRef.current = {};
|
|
}, 500);
|
|
},
|
|
[user.isImpersonating, preferencesFetcher]
|
|
);
|
|
|
|
// Flush pending preferences on unmount to avoid losing the last toggle
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceTimeoutRef.current) {
|
|
clearTimeout(debounceTimeoutRef.current);
|
|
}
|
|
if (user.isImpersonating) return;
|
|
const pending = pendingPreferencesRef.current;
|
|
const hasPendingChanges =
|
|
pending.isCollapsed !== undefined ||
|
|
(pending.sectionId !== undefined && pending.sectionCollapsed !== undefined);
|
|
|
|
if (hasPendingChanges) {
|
|
const formData = new FormData();
|
|
if (pending.isCollapsed !== undefined) {
|
|
formData.append("isCollapsed", String(pending.isCollapsed));
|
|
}
|
|
if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) {
|
|
formData.append("sectionId", pending.sectionId);
|
|
formData.append("sectionCollapsed", String(pending.sectionCollapsed));
|
|
}
|
|
preferencesFetcher.submit(formData, {
|
|
method: "POST",
|
|
action: "/resources/preferences/sidemenu",
|
|
});
|
|
pendingPreferencesRef.current = {};
|
|
}
|
|
};
|
|
}, [preferencesFetcher, user.isImpersonating]);
|
|
|
|
const handleToggleCollapsed = () => {
|
|
const newIsCollapsed = !isCollapsed;
|
|
setIsCollapsed(newIsCollapsed);
|
|
persistSideMenuPreferences({ isCollapsed: newIsCollapsed });
|
|
};
|
|
|
|
/** Generic handler for any collapsible section - just pass the section ID */
|
|
const handleSectionToggle = useCallback(
|
|
(sectionId: SideMenuSectionId) => (collapsed: boolean) => {
|
|
persistSideMenuPreferences({ sectionId, sectionCollapsed: collapsed });
|
|
},
|
|
[persistSideMenuPreferences]
|
|
);
|
|
|
|
useShortcutKeys({
|
|
shortcut: { modifiers: ["mod"], key: "b", enabledOnInputElements: true },
|
|
action: handleToggleCollapsed,
|
|
});
|
|
|
|
useEffect(() => {
|
|
const handleScroll = () => {
|
|
if (borderRef.current) {
|
|
const shouldShowHeaderDivider = borderRef.current.scrollTop > 1;
|
|
if (showHeaderDivider !== shouldShowHeaderDivider) {
|
|
setShowHeaderDivider(shouldShowHeaderDivider);
|
|
}
|
|
}
|
|
};
|
|
|
|
borderRef.current?.addEventListener("scroll", handleScroll);
|
|
return () => borderRef.current?.removeEventListener("scroll", handleScroll);
|
|
}, [showHeaderDivider]);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative h-full border-r border-grid-bright bg-background-bright transition-all duration-200",
|
|
isCollapsed ? "w-11" : "w-56"
|
|
)}
|
|
>
|
|
<CollapseToggle isCollapsed={isCollapsed} onToggle={handleToggleCollapsed} />
|
|
<div className="absolute inset-0 grid grid-cols-[100%] grid-rows-[2.5rem_1fr_auto] overflow-hidden">
|
|
<div
|
|
className={cn(
|
|
"flex min-w-0 items-center overflow-hidden border-b px-1 py-1 transition duration-300",
|
|
showHeaderDivider || isCollapsed ? "border-grid-bright" : "border-transparent"
|
|
)}
|
|
>
|
|
<div className={cn("min-w-0", !isCollapsed && "flex-1")}>
|
|
<ProjectSelector
|
|
organizations={organizations}
|
|
organization={organization}
|
|
project={project}
|
|
user={user}
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
</div>
|
|
{isAdmin && !user.isImpersonating ? (
|
|
<CollapsibleElement isCollapsed={isCollapsed}>
|
|
<TooltipProvider disableHoverableContent={true}>
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<LinkButton
|
|
variant="minimal/medium"
|
|
to={adminPath()}
|
|
TrailingIcon={HomeIcon}
|
|
trailingIconClassName="h-4.5 w-4.5"
|
|
className="h-8 w-8"
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" className={"text-xs"}>
|
|
Admin dashboard
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</CollapsibleElement>
|
|
) : isAdmin && user.isImpersonating ? (
|
|
<CollapsibleElement isCollapsed={isCollapsed}>
|
|
<ImpersonationBanner />
|
|
</CollapsibleElement>
|
|
) : null}
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"min-h-0 overflow-y-auto pt-2",
|
|
isCollapsed
|
|
? "scrollbar-none"
|
|
: "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
|
)}
|
|
ref={borderRef}
|
|
>
|
|
<div className="mb-6 flex w-full flex-col gap-4 overflow-hidden px-1">
|
|
<div className="w-full space-y-1">
|
|
<SideMenuHeader
|
|
title={"Environment"}
|
|
isCollapsed={isCollapsed}
|
|
collapsedTitle="Env"
|
|
/>
|
|
<div className="flex items-center">
|
|
<EnvironmentSelector
|
|
organization={organization}
|
|
project={project}
|
|
environment={environment}
|
|
className="w-full"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
|
|
<CollapsibleElement isCollapsed={isCollapsed}>
|
|
<Dialog>
|
|
<TooltipProvider disableHoverableContent={true}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="inline-flex">
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
variant="minimal/small"
|
|
className="aspect-square h-7 p-1"
|
|
LeadingIcon={<ConnectionIcon isConnected={isConnected} />}
|
|
/>
|
|
</DialogTrigger>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className={"text-xs"}>
|
|
{isConnected === undefined
|
|
? "Checking connection…"
|
|
: isConnected
|
|
? "Your dev server is connected"
|
|
: "Your dev server is not connected"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<DevPresencePanel isConnected={isConnected} />
|
|
</Dialog>
|
|
</CollapsibleElement>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full space-y-0">
|
|
<SideMenuItem
|
|
name="Tasks"
|
|
icon={TasksIcon}
|
|
activeIconColor="text-tasks"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3EnvironmentPath(organization, project, environment)}
|
|
data-action="tasks"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
<SideMenuItem
|
|
name="Runs"
|
|
icon={RunsIcon}
|
|
activeIconColor="text-runs"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3RunsPath(organization, project, environment)}
|
|
data-action="runs"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
<SideMenuItem
|
|
name="Sessions"
|
|
icon={AIChatIcon}
|
|
activeIconColor="text-sessions"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3SessionsPath(organization, project, environment)}
|
|
data-action="sessions"
|
|
badge={<NewBadge />}
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
</div>
|
|
|
|
{(user.admin || user.isImpersonating || featureFlags.hasAiAccess) && (
|
|
<SideMenuSection
|
|
title="AI"
|
|
isSideMenuCollapsed={isCollapsed}
|
|
itemSpacingClassName="space-y-0"
|
|
initialCollapsed={getSectionCollapsed(user.dashboardPreferences.sideMenu, "ai")}
|
|
onCollapseToggle={handleSectionToggle("ai")}
|
|
>
|
|
<SideMenuItem
|
|
name="Prompts"
|
|
icon={AIPenIcon}
|
|
trailingIconClassName="size-6"
|
|
activeIconColor="text-aiPrompts"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3PromptsPath(organization, project, environment)}
|
|
data-action="prompts"
|
|
badge={<NewBadge />}
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
{(user.admin || user.isImpersonating || featureFlags.hasAiAccess) && (
|
|
<SideMenuItem
|
|
name="Models"
|
|
icon={Box3DIcon}
|
|
activeIconColor="text-models"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3ModelsPath(organization, project, environment)}
|
|
data-action="models"
|
|
badge={<NewBadge />}
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
)}
|
|
</SideMenuSection>
|
|
)}
|
|
|
|
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
|
|
<SideMenuSection
|
|
title="Observability"
|
|
isSideMenuCollapsed={isCollapsed}
|
|
itemSpacingClassName="space-y-0"
|
|
initialCollapsed={getSectionCollapsed(
|
|
user.dashboardPreferences.sideMenu,
|
|
"metrics"
|
|
)}
|
|
onCollapseToggle={handleSectionToggle("metrics")}
|
|
>
|
|
{(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
|
|
<SideMenuItem
|
|
name="Logs"
|
|
icon={LogsIcon}
|
|
activeIconColor="text-logs"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3LogsPath(organization, project, environment)}
|
|
data-action="logs"
|
|
badge={<AlphaBadge />}
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
)}
|
|
<SideMenuItem
|
|
name="Errors"
|
|
icon={BugIcon}
|
|
activeIconColor="text-errors"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3ErrorsPath(organization, project, environment)}
|
|
data-action="errors"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
<SideMenuItem
|
|
name="Query"
|
|
icon={CodeSquareIcon}
|
|
activeIconColor="text-query"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={queryPath(organization, project, environment)}
|
|
data-action="query"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
<SideMenuItem
|
|
name="Queues"
|
|
icon={QueuesIcon}
|
|
activeIconColor="text-queues"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3QueuesPath(organization, project, environment)}
|
|
data-action="queues"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
<SideMenuItem
|
|
name="Dashboards"
|
|
icon={ChartBarIcon}
|
|
activeIconColor="text-metrics"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3DashboardsLandingPath(organization, project, environment)}
|
|
data-action="dashboards-landing"
|
|
isCollapsed={isCollapsed}
|
|
action={
|
|
<CreateDashboardButton
|
|
organization={organization}
|
|
project={project}
|
|
environment={environment}
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
}
|
|
/>
|
|
<DashboardList
|
|
organization={organization}
|
|
project={project}
|
|
environment={environment}
|
|
isCollapsed={isCollapsed}
|
|
user={user}
|
|
/>
|
|
</SideMenuSection>
|
|
)}
|
|
|
|
<SideMenuSection
|
|
title="Deployments"
|
|
isSideMenuCollapsed={isCollapsed}
|
|
itemSpacingClassName="space-y-0"
|
|
initialCollapsed={getSectionCollapsed(
|
|
user.dashboardPreferences.sideMenu,
|
|
"deployments"
|
|
)}
|
|
onCollapseToggle={handleSectionToggle("deployments")}
|
|
>
|
|
<SideMenuItem
|
|
name="Deploys"
|
|
icon={DeploymentsIcon}
|
|
activeIconColor="text-deployments"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3DeploymentsPath(organization, project, environment)}
|
|
data-action="deployments"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
<SideMenuItem
|
|
name="Environment variables"
|
|
icon={IDIcon}
|
|
activeIconColor="text-environmentVariables"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3EnvironmentVariablesPath(organization, project, environment)}
|
|
data-action="environment variables"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
<SideMenuItem
|
|
name="Preview branches"
|
|
icon={BranchEnvironmentIconSmall}
|
|
activeIconColor="text-previewBranches"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={branchesPath(organization, project, environment)}
|
|
data-action="preview-branches"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
<SideMenuItem
|
|
name="Regions"
|
|
icon={GlobeLinesIcon}
|
|
activeIconColor="text-regions"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={regionsPath(organization, project, environment)}
|
|
data-action="regions"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
</SideMenuSection>
|
|
|
|
<SideMenuSection
|
|
title="Manage"
|
|
isSideMenuCollapsed={isCollapsed}
|
|
itemSpacingClassName="space-y-0"
|
|
initialCollapsed={getSectionCollapsed(user.dashboardPreferences.sideMenu, "manage")}
|
|
onCollapseToggle={handleSectionToggle("manage")}
|
|
>
|
|
<SideMenuItem
|
|
name="Waitpoint tokens"
|
|
icon={WaitpointTokenIcon}
|
|
activeIconColor="text-sky-500"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3WaitpointTokensPath(organization, project, environment)}
|
|
data-action="waitpoint-tokens"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
<SideMenuItem
|
|
name="Batches"
|
|
icon={BatchesIcon}
|
|
activeIconColor="text-batches"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3BatchesPath(organization, project, environment)}
|
|
data-action="batches"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
<SideMenuItem
|
|
name="Bulk actions"
|
|
icon={ListCheckedIcon}
|
|
activeIconColor="text-text-bright"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3BulkActionsPath(organization, project, environment)}
|
|
data-action="bulk actions"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
<SideMenuItem
|
|
name="API keys"
|
|
icon={KeyIcon}
|
|
activeIconColor="text-text-bright"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3ApiKeysPath(organization, project, environment)}
|
|
data-action="api keys"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
<SideMenuItem
|
|
name="Alerts"
|
|
icon={BellIcon}
|
|
activeIconColor="text-text-bright"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3ProjectAlertsPath(organization, project, environment)}
|
|
data-action="alerts"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
{isManagedCloud && (
|
|
<SideMenuItem
|
|
name="Concurrency"
|
|
icon={ConcurrencyIcon}
|
|
activeIconColor="text-text-bright"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={concurrencyPath(organization, project, environment)}
|
|
data-action="concurrency"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
)}
|
|
<SideMenuItem
|
|
name="Limits"
|
|
icon={DialIcon}
|
|
activeIconColor="text-text-bright"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={limitsPath(organization, project, environment)}
|
|
data-action="limits"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
<SideMenuItem
|
|
name="Integrations"
|
|
icon={IntegrationsIcon}
|
|
activeIconColor="text-text-bright"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
|
|
data-action="project-settings-integrations"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
<SideMenuItem
|
|
name="Project settings"
|
|
icon={SlidersIcon}
|
|
activeIconColor="text-text-bright"
|
|
inactiveIconColor="text-text-dimmed"
|
|
to={v3ProjectSettingsGeneralPath(organization, project, environment)}
|
|
data-action="project-settings-general"
|
|
isCollapsed={isCollapsed}
|
|
/>
|
|
</SideMenuSection>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<NotificationPanel
|
|
isCollapsed={isCollapsed}
|
|
hasIncident={incidentStatus.hasIncident}
|
|
organizationId={organization.id}
|
|
projectId={project.id}
|
|
/>
|
|
<IncidentStatusPanel
|
|
isCollapsed={isCollapsed}
|
|
title={incidentStatus.title}
|
|
hasIncident={incidentStatus.hasIncident}
|
|
isManagedCloud={incidentStatus.isManagedCloud}
|
|
/>
|
|
<V3DeprecationPanel
|
|
isCollapsed={isCollapsed}
|
|
isV3={isV3Project}
|
|
projectCreatedAt={project.createdAt}
|
|
hasIncident={incidentStatus.hasIncident}
|
|
isManagedCloud={incidentStatus.isManagedCloud}
|
|
/>
|
|
<motion.div
|
|
layout
|
|
transition={{ duration: 0.2, ease: "easeInOut" }}
|
|
className={cn(
|
|
"flex flex-col gap-1 border-t border-grid-bright p-1",
|
|
isCollapsed && "items-center"
|
|
)}
|
|
>
|
|
<HelpAndAI
|
|
isCollapsed={isCollapsed}
|
|
organizationId={organization.id}
|
|
projectId={project.id}
|
|
/>
|
|
{isFreeUser && (
|
|
<CollapsibleHeight isCollapsed={isCollapsed}>
|
|
<FreePlanUsage
|
|
to={v3BillingPath(organization)}
|
|
percentage={currentPlan.v3Usage.usagePercentage}
|
|
/>
|
|
</CollapsibleHeight>
|
|
)}
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function V3DeprecationPanel({
|
|
isCollapsed,
|
|
isV3,
|
|
projectCreatedAt,
|
|
hasIncident,
|
|
isManagedCloud,
|
|
}: {
|
|
isCollapsed: boolean;
|
|
isV3: boolean;
|
|
projectCreatedAt: Date;
|
|
hasIncident: boolean;
|
|
isManagedCloud: boolean;
|
|
}) {
|
|
// Only show for projects created before v4 was released
|
|
const V4_RELEASE_DATE = new Date("2025-09-01");
|
|
const isLikelyV3 = isV3 && new Date(projectCreatedAt) < V4_RELEASE_DATE;
|
|
|
|
if (!isManagedCloud || !isLikelyV3 || hasIncident) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Popover>
|
|
<div className="p-1">
|
|
<motion.div
|
|
initial={false}
|
|
animate={{
|
|
height: isCollapsed ? 0 : "auto",
|
|
opacity: isCollapsed ? 0 : 1,
|
|
}}
|
|
transition={{ duration: 0.15 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<V3DeprecationContent />
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
initial={false}
|
|
animate={{
|
|
height: isCollapsed ? "auto" : 0,
|
|
opacity: isCollapsed ? 1 : 0,
|
|
}}
|
|
transition={{ duration: 0.15 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<SimpleTooltip
|
|
button={
|
|
<PopoverTrigger className="flex h-8! w-full items-center justify-center rounded border border-amber-500/30 bg-amber-500/15 transition-colors hover:border-amber-500/50 hover:bg-amber-500/25">
|
|
<ExclamationTriangleIcon className="size-5 text-amber-400" />
|
|
</PopoverTrigger>
|
|
}
|
|
content="V3 deprecation warning"
|
|
side="right"
|
|
sideOffset={8}
|
|
disableHoverableContent
|
|
asChild
|
|
/>
|
|
</motion.div>
|
|
</div>
|
|
<PopoverContent side="right" sideOffset={8} align="start" className="w-52 min-w-0! p-0">
|
|
<V3DeprecationContent />
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function V3DeprecationContent() {
|
|
return (
|
|
<div className="flex flex-col gap-2 rounded border border-amber-500/30 bg-amber-500/10 p-2 pt-1.5">
|
|
<div className="flex items-center gap-1 border-b border-amber-500/30 pb-1">
|
|
<ExclamationTriangleIcon className="size-4 text-amber-400" />
|
|
<Paragraph variant="small/bright" className="text-amber-300">
|
|
V3 deprecation warning
|
|
</Paragraph>
|
|
</div>
|
|
<Paragraph variant="extra-small/bright" className="text-amber-300">
|
|
This is a v3 project. V3 deploys will stop working on 1 April 2026. Full shutdown is 1 July
|
|
2026 where all v3 runs will stop executing. Migrate to v4 to avoid downtime.
|
|
</Paragraph>
|
|
<LinkButton
|
|
variant="secondary/small"
|
|
to="https://trigger.dev/docs/migrating-from-v3"
|
|
target="_blank"
|
|
fullWidth
|
|
TrailingIcon={ArrowTopRightOnSquareIcon}
|
|
trailingIconClassName="text-amber-300"
|
|
className="border-amber-500/30 bg-amber-500/15 hover:border-amber-500/50! hover:bg-amber-500/25!"
|
|
>
|
|
<span className="text-amber-300">View migration guide</span>
|
|
</LinkButton>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProjectSelector({
|
|
project,
|
|
organization,
|
|
organizations,
|
|
user,
|
|
isCollapsed = false,
|
|
}: {
|
|
project: SideMenuProject;
|
|
organization: MatchedOrganization;
|
|
organizations: MatchedOrganization[];
|
|
user: SideMenuUser;
|
|
isCollapsed?: boolean;
|
|
}) {
|
|
const currentPlan = useCurrentPlan();
|
|
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
|
|
const navigation = useNavigation();
|
|
const { isManagedCloud } = useFeatures();
|
|
|
|
let plan: string | undefined = undefined;
|
|
if (currentPlan?.v3Subscription?.isPaying === false) {
|
|
plan = "Free";
|
|
} else if (currentPlan?.v3Subscription?.isPaying === true) {
|
|
plan = currentPlan.v3Subscription.plan?.title;
|
|
}
|
|
|
|
useEffect(() => {
|
|
setOrgMenuOpen(false);
|
|
}, [navigation.location?.pathname]);
|
|
|
|
return (
|
|
<Popover onOpenChange={(open) => setOrgMenuOpen(open)} open={isOrgMenuOpen}>
|
|
<SimpleTooltip
|
|
button={
|
|
<PopoverTrigger
|
|
className={cn(
|
|
"group flex h-8 items-center rounded pl-1.75 transition-colors hover:bg-background-hover",
|
|
isCollapsed ? "justify-center pr-0.5" : "w-full justify-between pr-1"
|
|
)}
|
|
>
|
|
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
|
<Avatar avatar={organization.avatar} size={1.25} orgName={organization.title} />
|
|
<span
|
|
className={cn(
|
|
"flex min-w-0 items-center gap-1.5 overflow-hidden transition-all duration-200",
|
|
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
|
|
)}
|
|
>
|
|
<SelectorDivider />
|
|
<span className="truncate text-[0.90625rem] font-medium tracking-[-0.01em] text-text-bright">
|
|
{project.name ?? "Select a project"}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"overflow-hidden transition-all duration-200",
|
|
isCollapsed ? "max-w-0 opacity-0" : "max-w-[16px] opacity-100"
|
|
)}
|
|
>
|
|
<DropdownIcon className="size-4 min-w-4 text-text-dimmed transition group-hover:text-text-bright" />
|
|
</span>
|
|
</PopoverTrigger>
|
|
}
|
|
content={`${organization.title} / ${project.name ?? "Select a project"}`}
|
|
side="right"
|
|
sideOffset={8}
|
|
hidden={!isCollapsed}
|
|
buttonClassName="h-8!"
|
|
asChild
|
|
disableHoverableContent
|
|
/>
|
|
<PopoverContent
|
|
className="min-w-64 overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
|
side={isCollapsed ? "right" : "bottom"}
|
|
sideOffset={isCollapsed ? 8 : 4}
|
|
align="start"
|
|
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
|
>
|
|
<div className="flex flex-col gap-2 bg-background-hover p-2">
|
|
<div className="flex items-center gap-2.5">
|
|
<Link
|
|
to={organizationSettingsPath(organization)}
|
|
className="group relative box-content size-10 overflow-clip rounded-sm bg-background-bright"
|
|
>
|
|
<Avatar avatar={organization.avatar} size={2.5} orgName={organization.title} />
|
|
<div className="absolute inset-0 z-10 grid h-full w-full place-items-center bg-black/50 opacity-0 transition group-hover:opacity-100">
|
|
<PencilSquareIcon className="size-5 text-text-bright" />
|
|
</div>
|
|
</Link>
|
|
<div className="space-y-0.5">
|
|
<Paragraph variant="small/bright">{organization.title}</Paragraph>
|
|
<div className="flex items-baseline gap-2">
|
|
{plan && (
|
|
<TextLink
|
|
variant="secondary"
|
|
className="text-xs"
|
|
to={v3BillingPath(organization)}
|
|
>
|
|
{plan} plan
|
|
</TextLink>
|
|
)}
|
|
<TextLink
|
|
variant="secondary"
|
|
className="text-xs"
|
|
to={organizationTeamPath(organization)}
|
|
>{simplur`${organization.membersCount} member[|s]`}</TextLink>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<LinkButton
|
|
variant="secondary/small"
|
|
to={organizationSettingsPath(organization)}
|
|
fullWidth
|
|
iconSpacing="gap-1.5"
|
|
className="group-hover/button:border-border-brightest"
|
|
>
|
|
<SlidersIcon className="size-4 text-text-dimmed" />
|
|
<span className="text-text-bright">Settings</span>
|
|
</LinkButton>
|
|
{isManagedCloud && (
|
|
<LinkButton
|
|
variant="secondary/small"
|
|
to={v3UsagePath(organization)}
|
|
fullWidth
|
|
iconSpacing="gap-1.5"
|
|
className="group-hover/button:border-border-brightest"
|
|
>
|
|
<UsageIcon className="size-4 text-text-dimmed" />
|
|
<span className="text-text-bright">Usage</span>
|
|
</LinkButton>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-1 p-1">
|
|
{organization.projects.map((p) => {
|
|
const isSelected = p.id === project.id;
|
|
return (
|
|
<PopoverMenuItem
|
|
key={p.id}
|
|
to={v3ProjectPath(organization, p)}
|
|
title={
|
|
<div className="flex w-full items-center justify-between text-text-bright">
|
|
<span className="grow truncate text-left">{p.name}</span>
|
|
</div>
|
|
}
|
|
isSelected={isSelected}
|
|
icon={isSelected ? FolderOpenIcon : FolderClosedIcon}
|
|
leadingIconClassName="text-indigo-500"
|
|
/>
|
|
);
|
|
})}
|
|
<PopoverMenuItem to={newProjectPath(organization)} title="New project" icon={PlusIcon} />
|
|
</div>
|
|
<div className="border-t border-grid-bright p-1">
|
|
{organizations.length > 1 ? (
|
|
<SwitchOrganizations organizations={organizations} organization={organization} />
|
|
) : (
|
|
<PopoverMenuItem
|
|
to={newOrganizationPath()}
|
|
title="New organization"
|
|
icon={PlusIcon}
|
|
leadingIconClassName="text-text-dimmed"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="border-t border-grid-bright p-1">
|
|
<PopoverMenuItem
|
|
to={accountPath()}
|
|
title="Account"
|
|
icon={AvatarCircleIcon}
|
|
leadingIconClassName="text-text-dimmed"
|
|
/>
|
|
</div>
|
|
<div className="border-t border-grid-bright p-1">
|
|
<PopoverMenuItem
|
|
to={logoutPath()}
|
|
title="Logout"
|
|
icon={ArrowRightSquareIcon}
|
|
leadingIconClassName="text-text-dimmed"
|
|
danger
|
|
/>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function SwitchOrganizations({
|
|
organizations,
|
|
organization,
|
|
}: {
|
|
organizations: MatchedOrganization[];
|
|
organization: MatchedOrganization;
|
|
}) {
|
|
const navigation = useNavigation();
|
|
const [isMenuOpen, setMenuOpen] = useState(false);
|
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Clear timeout on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setMenuOpen(false);
|
|
}, [navigation.location?.pathname]);
|
|
|
|
const handleMouseEnter = () => {
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
}
|
|
setMenuOpen(true);
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
// Small delay before closing to allow moving to the content
|
|
timeoutRef.current = setTimeout(() => {
|
|
setMenuOpen(false);
|
|
}, 150);
|
|
};
|
|
|
|
return (
|
|
<Popover onOpenChange={(open) => setMenuOpen(open)} open={isMenuOpen}>
|
|
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} className="flex">
|
|
<PopoverTrigger className="w-full justify-between overflow-hidden focus-custom">
|
|
<ButtonContent
|
|
variant="small-menu-item"
|
|
className="hover:bg-background-hover"
|
|
LeadingIcon={ArrowLeftRightIcon}
|
|
leadingIconClassName="text-text-dimmed"
|
|
TrailingIcon={ChevronRightIcon}
|
|
trailingIconClassName="text-text-dimmed"
|
|
textAlignLeft
|
|
fullWidth
|
|
>
|
|
Switch organization
|
|
</ButtonContent>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="min-w-64 overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
|
align="start"
|
|
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
|
side="right"
|
|
alignOffset={0}
|
|
sideOffset={-4}
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
>
|
|
<div className="flex flex-col gap-1 p-1">
|
|
{organizations.map((org) => (
|
|
<PopoverMenuItem
|
|
key={org.id}
|
|
to={organizationPath(org)}
|
|
title={org.title}
|
|
icon={<Avatar size={1} avatar={org.avatar} orgName={org.title} />}
|
|
leadingIconClassName="text-text-dimmed"
|
|
isSelected={org.id === organization.id}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="border-t border-grid-bright p-1">
|
|
<PopoverMenuItem
|
|
to={newOrganizationPath()}
|
|
title="New organization"
|
|
icon={PlusIcon}
|
|
leadingIconClassName="text-text-dimmed"
|
|
/>
|
|
</div>
|
|
</PopoverContent>
|
|
</div>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function SelectorDivider() {
|
|
return (
|
|
<svg width="6" height="21" viewBox="0 0 6 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<line
|
|
x1="5.3638"
|
|
y1="0.606339"
|
|
x2="0.606339"
|
|
y2="19.6362"
|
|
stroke="var(--color-border-bright)"
|
|
strokeLinecap="round"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/** Helper component that fades out but preserves width (collapses to 0 width) */
|
|
function CollapsibleElement({
|
|
isCollapsed,
|
|
children,
|
|
className,
|
|
}: {
|
|
isCollapsed: boolean;
|
|
children: ReactNode;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"overflow-hidden transition-all duration-200",
|
|
isCollapsed ? "max-w-0 opacity-0" : "max-w-[100px] opacity-100",
|
|
className
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Helper component that fades out and collapses height completely */
|
|
function CollapsibleHeight({
|
|
isCollapsed,
|
|
children,
|
|
className,
|
|
}: {
|
|
isCollapsed: boolean;
|
|
children: ReactNode;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"grid transition-all duration-200 ease-in-out",
|
|
isCollapsed ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr] opacity-100",
|
|
className
|
|
)}
|
|
>
|
|
<div className="overflow-hidden">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HelpAndAI({
|
|
isCollapsed,
|
|
organizationId,
|
|
projectId,
|
|
}: {
|
|
isCollapsed: boolean;
|
|
organizationId: string;
|
|
projectId: string;
|
|
}) {
|
|
return (
|
|
<LayoutGroup>
|
|
<div
|
|
className={cn(
|
|
"flex w-full",
|
|
isCollapsed ? "flex-col-reverse gap-1" : "items-center justify-between"
|
|
)}
|
|
>
|
|
<ShortcutsAutoOpen />
|
|
<HelpAndFeedback
|
|
isCollapsed={isCollapsed}
|
|
organizationId={organizationId}
|
|
projectId={projectId}
|
|
/>
|
|
<AskAI isCollapsed={isCollapsed} />
|
|
</div>
|
|
</LayoutGroup>
|
|
);
|
|
}
|
|
|
|
function AnimatedChevron({
|
|
isHovering,
|
|
isCollapsed,
|
|
}: {
|
|
isHovering: boolean;
|
|
isCollapsed: boolean;
|
|
}) {
|
|
// When hovering and expanded: left chevron (pointing left to collapse)
|
|
// When hovering and collapsed: right chevron (pointing right to expand)
|
|
// When not hovering: straight vertical line
|
|
|
|
const getRotation = () => {
|
|
if (!isHovering) return { top: 0, bottom: 0 };
|
|
if (isCollapsed) {
|
|
// Right chevron
|
|
return { top: -17, bottom: 17 };
|
|
} else {
|
|
// Left chevron
|
|
return { top: 17, bottom: -17 };
|
|
}
|
|
};
|
|
|
|
const { top, bottom } = getRotation();
|
|
|
|
// Calculate horizontal offset to keep chevron centered when rotated
|
|
// Left chevron: translate left (-1.5px)
|
|
// Right chevron: translate right (+1.5px)
|
|
const getTranslateX = () => {
|
|
if (!isHovering) return 0;
|
|
return isCollapsed ? 1.5 : -1.5;
|
|
};
|
|
|
|
return (
|
|
<motion.svg
|
|
width="4"
|
|
height="30"
|
|
viewBox="0 0 4 30"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
className="pointer-events-none relative z-10 overflow-visible text-charcoal-600 transition-colors group-hover:text-text-bright"
|
|
initial={false}
|
|
animate={{
|
|
x: getTranslateX(),
|
|
}}
|
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
|
>
|
|
{/* Top segment */}
|
|
<motion.line
|
|
x1="2"
|
|
y1="1.5"
|
|
x2="2"
|
|
y2="15"
|
|
stroke="currentColor"
|
|
strokeWidth="3"
|
|
strokeLinecap="round"
|
|
initial={false}
|
|
animate={{
|
|
rotate: top,
|
|
}}
|
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
|
style={{ transformOrigin: "2px 15px" }}
|
|
/>
|
|
{/* Bottom segment */}
|
|
<motion.line
|
|
x1="2"
|
|
y1="15"
|
|
x2="2"
|
|
y2="28.5"
|
|
stroke="currentColor"
|
|
strokeWidth="3"
|
|
strokeLinecap="round"
|
|
initial={false}
|
|
animate={{
|
|
rotate: bottom,
|
|
}}
|
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
|
style={{ transformOrigin: "2px 15px" }}
|
|
/>
|
|
</motion.svg>
|
|
);
|
|
}
|
|
|
|
function CollapseToggle({ isCollapsed, onToggle }: { isCollapsed: boolean; onToggle: () => void }) {
|
|
const [isHovering, setIsHovering] = useState(false);
|
|
|
|
return (
|
|
<div className="absolute -right-3 top-1/2 z-10 -translate-y-1/2">
|
|
{/* Vertical line to mask the side menu border */}
|
|
<div
|
|
className={cn(
|
|
"pointer-events-none absolute left-1/2 top-1/2 h-10 w-px -translate-y-1/2 transition-colors duration-200",
|
|
isHovering ? "bg-background-hover" : "bg-background-bright"
|
|
)}
|
|
/>
|
|
<TooltipProvider disableHoverableContent>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
aria-label={isCollapsed ? "Expand side menu" : "Collapse side menu"}
|
|
onClick={onToggle}
|
|
onMouseEnter={() => setIsHovering(true)}
|
|
onMouseLeave={() => setIsHovering(false)}
|
|
className={cn(
|
|
"group flex h-12 w-6 items-center justify-center rounded-md text-text-dimmed transition-all duration-200 focus-custom",
|
|
isHovering
|
|
? "border border-grid-bright bg-background-bright shadow-md hover:bg-background-hover hover:text-text-bright"
|
|
: "border border-transparent bg-transparent"
|
|
)}
|
|
>
|
|
<AnimatedChevron isHovering={isHovering} isCollapsed={isCollapsed} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" className="flex items-center gap-2 text-xs">
|
|
{isCollapsed ? "Expand" : "Collapse"}
|
|
<span className="flex items-center">
|
|
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
|
|
<ShortcutKey shortcut={{ key: "b" }} variant="medium/bright" />
|
|
</span>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</div>
|
|
);
|
|
}
|