Feat(webapp): collapsible side menu (#2939)
- Better separation between main feature pages and manage pages - Toggle via keyboard shortcut Cmd/Ctrl + B or click collapse button - Collapsed state persisted to database (survives page refresh) - All menu items show tooltips when collapsed - Smooth animations using Framer Motion and CSS transitions - Impersonation-safe (preferences not modified when impersonating) https://github.com/user-attachments/assets/35827922-b80a-418e-8341-eb22c9bb5ed4 <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2939"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
HandThumbUpIcon,
|
||||
StopIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import DOMPurify from "dompurify";
|
||||
@@ -37,7 +38,7 @@ function useKapaWebsiteId() {
|
||||
return routeMatch?.kapa.websiteId;
|
||||
}
|
||||
|
||||
export function AskAI() {
|
||||
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const websiteId = useKapaWebsiteId();
|
||||
|
||||
@@ -54,21 +55,23 @@ export function AskAI() {
|
||||
hideShortcutKey
|
||||
data-modal-override-open-class-ask-ai="true"
|
||||
disabled
|
||||
className={isCollapsed ? "w-full justify-center" : ""}
|
||||
>
|
||||
<AISparkleIcon className="size-5" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{() => <AskAIProvider websiteId={websiteId} />}
|
||||
{() => <AskAIProvider websiteId={websiteId} isCollapsed={isCollapsed} />}
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
|
||||
type AskAIProviderProps = {
|
||||
websiteId: string;
|
||||
isCollapsed?: boolean;
|
||||
};
|
||||
|
||||
function AskAIProvider({ websiteId }: AskAIProviderProps) {
|
||||
function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [initialQuery, setInitialQuery] = useState<string | undefined>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -112,28 +115,38 @@ function AskAIProvider({ websiteId }: AskAIProviderProps) {
|
||||
}}
|
||||
botProtectionMechanism="hcaptcha"
|
||||
>
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="inline-flex">
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
data-action="ask-ai"
|
||||
shortcut={{ modifiers: ["mod"], key: "/", enabledOnInputElements: true }}
|
||||
hideShortcutKey
|
||||
data-modal-override-open-class-ask-ai="true"
|
||||
onClick={() => openAskAI()}
|
||||
>
|
||||
<AISparkleIcon className="size-5" />
|
||||
</Button>
|
||||
<motion.div layout="position" transition={{ duration: 0.2, ease: "easeInOut" }}>
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<div className={isCollapsed ? "w-full" : "inline-flex"}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
data-action="ask-ai"
|
||||
shortcut={{ modifiers: ["mod"], key: "/", enabledOnInputElements: true }}
|
||||
hideShortcutKey
|
||||
data-modal-override-open-class-ask-ai="true"
|
||||
onClick={() => openAskAI()}
|
||||
className={isCollapsed ? "w-full justify-center" : ""}
|
||||
>
|
||||
<AISparkleIcon className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="flex items-center gap-1 py-1.5 pl-2.5 pr-2 text-xs">
|
||||
Ask AI
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"], key: "/" }} variant="medium/bright" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipContent
|
||||
side={isCollapsed ? "right" : "top"}
|
||||
sideOffset={isCollapsed ? 8 : 4}
|
||||
className="flex items-center gap-1 py-1.5 pl-2.5 pr-2 text-xs"
|
||||
>
|
||||
Ask AI
|
||||
<span className="flex items-center">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "/" }} variant="medium/bright" />
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</motion.div>
|
||||
<AskAIDialog
|
||||
initialQuery={initialQuery}
|
||||
isOpen={isOpen}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { Keyboard } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
SheetTrigger
|
||||
} from "./primitives/SheetV3";
|
||||
import { ShortcutKey } from "./primitives/ShortcutKey";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { useState } from "react";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
|
||||
export function Shortcuts() {
|
||||
return (
|
||||
@@ -26,8 +25,8 @@ export function Shortcuts() {
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
shortcut={{ modifiers: ["shift"], key: "?", enabled: false }}
|
||||
className="gap-x-0 pl-0.5"
|
||||
iconSpacing="gap-x-0.5"
|
||||
className="gap-x-0 pl-1.5"
|
||||
iconSpacing="gap-x-1.5"
|
||||
>
|
||||
Shortcuts
|
||||
</Button>
|
||||
@@ -82,6 +81,10 @@ function ShortcutContent() {
|
||||
<Shortcut name="Filter">
|
||||
<ShortcutKey shortcut={{ key: "f" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Toggle side menu">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"]}} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "b" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Select filter">
|
||||
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
|
||||
<Paragraph variant="small" className="ml-1.5">
|
||||
|
||||
@@ -80,11 +80,13 @@ export function EnvironmentLabel({
|
||||
className,
|
||||
tooltipSideOffset = 34,
|
||||
tooltipSide = "right",
|
||||
disableTooltip = false,
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
tooltipSideOffset?: number;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
disableTooltip?: boolean;
|
||||
}) {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
@@ -117,7 +119,7 @@ export function EnvironmentLabel({
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isTruncated) {
|
||||
if (isTruncated && !disableTooltip) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
asChild
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
personalAccessTokensPath,
|
||||
rootPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { AskAI } from "../AskAI";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
|
||||
export function AccountSideMenu({ user }: { user: User }) {
|
||||
return (
|
||||
@@ -55,8 +56,9 @@ export function AccountSideMenu({ user }: { user: User }) {
|
||||
data-action="security"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChevronRightIcon, Cog8ToothIcon } from "@heroicons/react/20/solid";
|
||||
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
@@ -9,19 +10,19 @@ import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizati
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { branchesPath, docsPath, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { EnvironmentCombo } from "../environments/EnvironmentLabel";
|
||||
import { EnvironmentCombo, EnvironmentIcon, EnvironmentLabel, environmentFullTitle } from "../environments/EnvironmentLabel";
|
||||
import { ButtonContent } from "../primitives/Buttons";
|
||||
import { Header2 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Popover,
|
||||
PopoverArrowTrigger,
|
||||
PopoverContent,
|
||||
PopoverMenuItem,
|
||||
PopoverSectionHeader,
|
||||
PopoverTrigger,
|
||||
} from "../primitives/Popover";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { V4Badge } from "../V4Badge";
|
||||
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
@@ -31,11 +32,13 @@ export function EnvironmentSelector({
|
||||
project,
|
||||
environment,
|
||||
className,
|
||||
isCollapsed = false,
|
||||
}: {
|
||||
organization: MatchedOrganization;
|
||||
project: SideMenuProject;
|
||||
environment: SideMenuEnvironment;
|
||||
className?: string;
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@@ -50,16 +53,48 @@ export function EnvironmentSelector({
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setIsMenuOpen(open)} open={isMenuOpen}>
|
||||
<PopoverArrowTrigger
|
||||
isOpen={isMenuOpen}
|
||||
overflowHidden
|
||||
fullWidth
|
||||
className={cn("h-7 overflow-hidden py-1 pl-1.5", className)}
|
||||
>
|
||||
<EnvironmentCombo environment={environment} className="w-full text-2sm" />
|
||||
</PopoverArrowTrigger>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"group flex h-8 items-center rounded pl-[0.4375rem] transition-colors hover:bg-charcoal-750",
|
||||
isCollapsed ? "justify-center pr-0.5" : "justify-between pr-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
||||
<EnvironmentIcon environment={environment} className="size-5 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"flex min-w-0 items-center overflow-hidden transition-all duration-200",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
|
||||
)}
|
||||
>
|
||||
<EnvironmentLabel environment={environment} className="text-2sm" disableTooltip />
|
||||
</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={environmentFullTitle(environment)}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
hidden={!isCollapsed}
|
||||
buttonClassName="!h-8"
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
<PopoverContent
|
||||
className="min-w-[14rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
side={isCollapsed ? "right" : "bottom"}
|
||||
sideOffset={isCollapsed ? 8 : 4}
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
>
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
SignalIcon,
|
||||
StarIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { Fragment, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { Shortcuts } from "../Shortcuts";
|
||||
import { StepContentContainer } from "../StepContentContainer";
|
||||
@@ -19,30 +22,85 @@ import { ClipboardField } from "../primitives/ClipboardField";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
|
||||
import { Icon } from "../primitives/Icon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverSideMenuTrigger } from "../primitives/Popover";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { ShortcutKey } from "../primitives/ShortcutKey";
|
||||
import { StepNumber } from "../primitives/StepNumber";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
|
||||
export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?: boolean }) {
|
||||
export function HelpAndFeedback({
|
||||
disableShortcut = false,
|
||||
isCollapsed = false,
|
||||
}: {
|
||||
disableShortcut?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
|
||||
const currentPlan = useCurrentPlan();
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: disableShortcut ? undefined : { key: "h", enabledOnInputElements: false },
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setHelpMenuOpen(true);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setHelpMenuOpen(open)}>
|
||||
<PopoverSideMenuTrigger
|
||||
isOpen={isHelpMenuOpen}
|
||||
shortcut={{ key: "h", enabledOnInputElements: false }}
|
||||
className="grow pr-2"
|
||||
disabled={disableShortcut}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<QuestionMarkCircleIcon className="size-4 text-success" />
|
||||
Help & Feedback
|
||||
</div>
|
||||
</PopoverSideMenuTrigger>
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className={isCollapsed ? undefined : "flex-1"}
|
||||
>
|
||||
<Popover open={isHelpMenuOpen} onOpenChange={setHelpMenuOpen}>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"group flex h-8 items-center gap-1.5 rounded pl-[0.4375rem] pr-2 transition-colors hover:bg-charcoal-750",
|
||||
isCollapsed ? "w-full" : "w-full justify-between"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 overflow-hidden">
|
||||
<QuestionMarkCircleIcon className="size-5 min-w-5 shrink-0 text-success" />
|
||||
<span
|
||||
className={cn(
|
||||
"overflow-hidden whitespace-nowrap text-2sm text-text-bright transition-all duration-150",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[150px] opacity-100"
|
||||
)}
|
||||
>
|
||||
Help & Feedback
|
||||
</span>
|
||||
</span>
|
||||
<ShortcutKey
|
||||
className={cn(
|
||||
"size-4 flex-none transition-all duration-150",
|
||||
isCollapsed ? "hidden" : ""
|
||||
)}
|
||||
shortcut={{ key: "h" }}
|
||||
variant="medium/bright"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
}
|
||||
content={
|
||||
<span className="flex items-center gap-1">
|
||||
Help & Feedback
|
||||
<ShortcutKey shortcut={{ key: "h" }} variant="medium/bright" />
|
||||
</span>
|
||||
}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
hidden={!isCollapsed}
|
||||
buttonClassName="!h-8 w-full"
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
<PopoverContent
|
||||
className="min-w-[14rem] divide-y divide-grid-bright overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
side={isCollapsed ? "right" : "top"}
|
||||
sideOffset={isCollapsed ? 8 : 4}
|
||||
align="start"
|
||||
>
|
||||
<Fragment>
|
||||
@@ -176,8 +234,9 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?:
|
||||
button={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
className="pl-2"
|
||||
LeadingIcon={EnvelopeIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
leadingIconClassName="text-blue-500 pr-1"
|
||||
data-action="contact-us"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
@@ -189,6 +248,7 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?:
|
||||
</div>
|
||||
</Fragment>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Popover>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { AskAI } from "../AskAI";
|
||||
|
||||
export type BuildInfo = {
|
||||
appVersion: string | undefined;
|
||||
@@ -144,8 +145,9 @@ export function OrganizationSettingsSideMenu({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
BellAlertIcon,
|
||||
ChartBarIcon,
|
||||
ChevronRightIcon,
|
||||
CircleStackIcon,
|
||||
ClockIcon,
|
||||
Cog8ToothIcon,
|
||||
CogIcon,
|
||||
@@ -15,19 +14,20 @@ import {
|
||||
GlobeAmericasIcon,
|
||||
IdentificationIcon,
|
||||
KeyIcon,
|
||||
MagnifyingGlassCircleIcon,
|
||||
PencilSquareIcon,
|
||||
PlusIcon,
|
||||
RectangleStackIcon,
|
||||
ServerStackIcon,
|
||||
Squares2X2Icon,
|
||||
TableCellsIcon,
|
||||
UsersIcon,
|
||||
UsersIcon
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Link, useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Link, useFetcher, useNavigation } from "@remix-run/react";
|
||||
import { LayoutGroup, motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import simplur from "simplur";
|
||||
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
|
||||
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
|
||||
import { LogsIcon } from "~/assets/icons/LogsIcon";
|
||||
@@ -41,7 +41,9 @@ import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { type MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { type MatchedProject } from "~/hooks/useProject";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { ShortcutKey } from "../primitives/ShortcutKey";
|
||||
import { type UserWithDashboardPreferences } from "~/models/user.server";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { type FeedbackType } from "~/routes/resources.feedback";
|
||||
import { IncidentStatusPanel } from "~/routes/resources.incidents";
|
||||
@@ -78,6 +80,7 @@ import {
|
||||
v3UsagePath,
|
||||
v3WaitpointTokensPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { AlphaBadge } from "../AlphaBadge";
|
||||
import { AskAI } from "../AskAI";
|
||||
import { FreePlanUsage } from "../billing/FreePlanUsage";
|
||||
import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence";
|
||||
@@ -87,24 +90,23 @@ import { Dialog, DialogTrigger } from "../primitives/Dialog";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Popover,
|
||||
PopoverArrowTrigger,
|
||||
PopoverContent,
|
||||
PopoverMenuItem,
|
||||
PopoverTrigger,
|
||||
PopoverTrigger
|
||||
} from "../primitives/Popover";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { ShortcutsAutoOpen } from "../Shortcuts";
|
||||
import { UserProfilePhoto } from "../UserProfilePhoto";
|
||||
import { V4Badge } from "../V4Badge";
|
||||
import { EnvironmentSelector } from "./EnvironmentSelector";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { SideMenuSection } from "./SideMenuSection";
|
||||
import { AlphaBadge } from "../AlphaBadge";
|
||||
|
||||
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
|
||||
type SideMenuUser = Pick<UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences"> & {
|
||||
isImpersonating: boolean;
|
||||
};
|
||||
export type SideMenuProject = Pick<
|
||||
MatchedProject,
|
||||
"id" | "name" | "slug" | "version" | "environments" | "engine"
|
||||
@@ -130,6 +132,15 @@ export function SideMenu({
|
||||
}: 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;
|
||||
manageSectionCollapsed?: boolean;
|
||||
}>({});
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const currentPlan = useCurrentPlan();
|
||||
const { isConnected } = useDevPresence();
|
||||
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
|
||||
@@ -137,6 +148,84 @@ export function SideMenu({
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const featureFlags = useFeatureFlags();
|
||||
|
||||
const persistSideMenuPreferences = useCallback(
|
||||
(data: { isCollapsed?: boolean; manageSectionCollapsed?: 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.manageSectionCollapsed !== undefined) {
|
||||
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
|
||||
}
|
||||
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;
|
||||
if (pending.isCollapsed !== undefined || pending.manageSectionCollapsed !== undefined) {
|
||||
const formData = new FormData();
|
||||
if (pending.isCollapsed !== undefined) {
|
||||
formData.append("isCollapsed", String(pending.isCollapsed));
|
||||
}
|
||||
if (pending.manageSectionCollapsed !== undefined) {
|
||||
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
|
||||
}
|
||||
preferencesFetcher.submit(formData, {
|
||||
method: "POST",
|
||||
action: "/resources/preferences/sidemenu",
|
||||
});
|
||||
pendingPreferencesRef.current = {};
|
||||
}
|
||||
};
|
||||
}, [preferencesFetcher, user.isImpersonating]);
|
||||
|
||||
const handleToggleCollapsed = () => {
|
||||
const newIsCollapsed = !isCollapsed;
|
||||
setIsCollapsed(newIsCollapsed);
|
||||
persistSideMenuPreferences({ isCollapsed: newIsCollapsed });
|
||||
};
|
||||
|
||||
const handleManageSectionToggle = useCallback(
|
||||
(collapsed: boolean) => {
|
||||
persistSideMenuPreferences({ manageSectionCollapsed: collapsed });
|
||||
},
|
||||
[persistSideMenuPreferences]
|
||||
);
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: { modifiers: ["mod"], key: "b", enabledOnInputElements: true },
|
||||
action: handleToggleCollapsed,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (borderRef.current) {
|
||||
@@ -154,246 +243,306 @@ export function SideMenu({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full grid-rows-[2.5rem_1fr_auto] overflow-hidden border-r border-grid-bright bg-background-bright transition"
|
||||
"relative h-full border-r border-grid-bright bg-background-bright transition-all duration-200",
|
||||
isCollapsed ? "w-[2.75rem]" : "w-56"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center overflow-hidden border-b px-1 py-1 transition duration-300",
|
||||
showHeaderDivider ? "border-grid-bright" : "border-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<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 ? (
|
||||
<TooltipProvider disableHoverableContent={true}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<LinkButton variant="minimal/medium" to={adminPath()} TrailingIcon={UsersIcon} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={"text-xs"}>
|
||||
Admin dashboard
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<CollapsibleElement isCollapsed={isCollapsed}>
|
||||
<TooltipProvider disableHoverableContent={true}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<LinkButton variant="minimal/medium" to={adminPath()} TrailingIcon={UsersIcon} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={"text-xs"}>
|
||||
Admin dashboard
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</CollapsibleElement>
|
||||
) : isAdmin && user.isImpersonating ? (
|
||||
<ImpersonationBanner />
|
||||
<CollapsibleElement isCollapsed={isCollapsed}>
|
||||
<ImpersonationBanner />
|
||||
</CollapsibleElement>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
className={cn(
|
||||
"min-h-0 overflow-y-auto pt-2",
|
||||
isCollapsed
|
||||
? "scrollbar-none"
|
||||
: "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
)}
|
||||
ref={borderRef}
|
||||
>
|
||||
<div className="mb-6 flex flex-col gap-4 px-1">
|
||||
<div className="space-y-1">
|
||||
<SideMenuHeader title={"Environment"} />
|
||||
<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" && (
|
||||
<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 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>
|
||||
<div className="w-full">
|
||||
<SideMenuItem
|
||||
name="Tasks"
|
||||
icon={TaskIconSmall}
|
||||
activeIconColor="text-tasks"
|
||||
inactiveIconColor="text-tasks"
|
||||
to={v3EnvironmentPath(organization, project, environment)}
|
||||
data-action="tasks"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Runs"
|
||||
icon={RunsIconExtraSmall}
|
||||
activeIconColor="text-runs"
|
||||
inactiveIconColor="text-runs"
|
||||
to={v3RunsPath(organization, project, environment)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Batches"
|
||||
icon={Squares2X2Icon}
|
||||
activeIconColor="text-batches"
|
||||
inactiveIconColor="text-batches"
|
||||
to={v3BatchesPath(organization, project, environment)}
|
||||
data-action="batches"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Schedules"
|
||||
icon={ClockIcon}
|
||||
activeIconColor="text-schedules"
|
||||
inactiveIconColor="text-schedules"
|
||||
to={v3SchedulesPath(organization, project, environment)}
|
||||
data-action="schedules"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Queues"
|
||||
icon={RectangleStackIcon}
|
||||
activeIconColor="text-queues"
|
||||
inactiveIconColor="text-queues"
|
||||
to={v3QueuesPath(organization, project, environment)}
|
||||
data-action="queues"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Waitpoint tokens"
|
||||
icon={WaitpointTokenIcon}
|
||||
activeIconColor="text-sky-500"
|
||||
inactiveIconColor="text-sky-500"
|
||||
to={v3WaitpointTokensPath(organization, project, environment)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Deployments"
|
||||
icon={ServerStackIcon}
|
||||
activeIconColor="text-deployments"
|
||||
inactiveIconColor="text-deployments"
|
||||
to={v3DeploymentsPath(organization, project, environment)}
|
||||
data-action="deployments"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
|
||||
<SideMenuItem
|
||||
name="Logs"
|
||||
icon={LogsIcon}
|
||||
activeIconColor="text-logs"
|
||||
inactiveIconColor="text-logs"
|
||||
to={v3LogsPath(organization, project, environment)}
|
||||
data-action="logs"
|
||||
badge={<AlphaBadge />}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Test"
|
||||
icon={BeakerIcon}
|
||||
activeIconColor="text-tests"
|
||||
inactiveIconColor="text-tests"
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
data-action="test"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
|
||||
<SideMenuItem
|
||||
name="Query"
|
||||
icon={TableCellsIcon}
|
||||
activeIconColor="text-purple-500"
|
||||
inactiveIconColor="text-purple-500"
|
||||
to={queryPath(organization, project, environment)}
|
||||
data-action="query"
|
||||
badge={<AlphaBadge />}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SideMenuSection title="Waitpoints">
|
||||
<SideMenuItem
|
||||
name="Tokens"
|
||||
icon={WaitpointTokenIcon}
|
||||
activeIconColor="text-sky-500"
|
||||
to={v3WaitpointTokensPath(organization, project, environment)}
|
||||
badge={<V4Badge />}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
|
||||
<SideMenuSection title="Manage">
|
||||
<SideMenuSection
|
||||
title="Manage"
|
||||
isSideMenuCollapsed={isCollapsed}
|
||||
itemSpacingClassName="space-y-0"
|
||||
initialCollapsed={user.dashboardPreferences.sideMenu?.manageSectionCollapsed ?? false}
|
||||
onCollapseToggle={handleManageSectionToggle}
|
||||
>
|
||||
<SideMenuItem
|
||||
name="Bulk actions"
|
||||
icon={ListCheckedIcon}
|
||||
activeIconColor="text-bulkActions"
|
||||
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-apiKeys"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ApiKeysPath(organization, project, environment)}
|
||||
data-action="api keys"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Environment variables"
|
||||
icon={IdentificationIcon}
|
||||
activeIconColor="text-environmentVariables"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3EnvironmentVariablesPath(organization, project, environment)}
|
||||
data-action="environment variables"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Alerts"
|
||||
icon={BellAlertIcon}
|
||||
activeIconColor="text-alerts"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ProjectAlertsPath(organization, project, environment)}
|
||||
data-action="alerts"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Preview branches"
|
||||
icon={BranchEnvironmentIconSmall}
|
||||
activeIconColor="text-preview"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={branchesPath(organization, project, environment)}
|
||||
data-action="preview-branches"
|
||||
badge={<V4Badge />}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{isManagedCloud && (
|
||||
<SideMenuItem
|
||||
name="Concurrency"
|
||||
icon={ConcurrencyIcon}
|
||||
activeIconColor="text-concurrency"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={concurrencyPath(organization, project, environment)}
|
||||
data-action="concurrency"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Regions"
|
||||
icon={GlobeAmericasIcon}
|
||||
activeIconColor="text-regions"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={regionsPath(organization, project, environment)}
|
||||
data-action="regions"
|
||||
badge={<V4Badge />}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Limits"
|
||||
icon={AdjustmentsHorizontalIcon}
|
||||
activeIconColor="text-limits"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={limitsPath(organization, project, environment)}
|
||||
data-action="limits"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon={Cog8ToothIcon}
|
||||
activeIconColor="text-projectSettings"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ProjectSettingsPath(organization, project, environment)}
|
||||
data-action="project-settings"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<IncidentStatusPanel />
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<HelpAndAI />
|
||||
</div>
|
||||
<IncidentStatusPanel isCollapsed={isCollapsed} />
|
||||
<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} />
|
||||
{isFreeUser && (
|
||||
<FreePlanUsage
|
||||
to={v3BillingPath(organization)}
|
||||
percentage={currentPlan.v3Usage.usagePercentage}
|
||||
/>
|
||||
<CollapsibleHeight isCollapsed={isCollapsed}>
|
||||
<FreePlanUsage
|
||||
to={v3BillingPath(organization)}
|
||||
percentage={currentPlan.v3Usage.usagePercentage}
|
||||
/>
|
||||
</CollapsibleHeight>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -404,11 +553,13 @@ function ProjectSelector({
|
||||
organization,
|
||||
organizations,
|
||||
user,
|
||||
isCollapsed = false,
|
||||
}: {
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
organizations: MatchedOrganization[];
|
||||
user: SideMenuUser;
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const currentPlan = useCurrentPlan();
|
||||
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
|
||||
@@ -428,21 +579,50 @@ function ProjectSelector({
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setOrgMenuOpen(open)} open={isOrgMenuOpen}>
|
||||
<PopoverArrowTrigger
|
||||
isOpen={isOrgMenuOpen}
|
||||
overflowHidden
|
||||
className="h-8 w-full justify-between py-1 pl-1.5"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 overflow-hidden">
|
||||
<Avatar avatar={organization.avatar} size={1.25} orgName={organization.title} />
|
||||
<SelectorDivider />
|
||||
<span className="truncate text-2sm font-normal text-text-bright">
|
||||
{project.name ?? "Select a project"}
|
||||
</span>
|
||||
</span>
|
||||
</PopoverArrowTrigger>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"group flex h-8 items-center rounded pl-[0.4375rem] transition-colors hover:bg-charcoal-750",
|
||||
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-2sm font-normal 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-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
side={isCollapsed ? "right" : "bottom"}
|
||||
sideOffset={isCollapsed ? 8 : 4}
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
>
|
||||
@@ -661,12 +841,187 @@ function SelectorDivider() {
|
||||
);
|
||||
}
|
||||
|
||||
function HelpAndAI() {
|
||||
/** Helper component that fades out but preserves width (collapses to 0 width) */
|
||||
function CollapsibleElement({
|
||||
isCollapsed,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
isCollapsed: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ShortcutsAutoOpen />
|
||||
<HelpAndFeedback />
|
||||
<AskAI />
|
||||
</>
|
||||
<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 }: { isCollapsed: boolean }) {
|
||||
return (
|
||||
<LayoutGroup>
|
||||
<div className={cn("flex w-full", isCollapsed ? "flex-col-reverse gap-1" : "items-center justify-between")}>
|
||||
<ShortcutsAutoOpen />
|
||||
<HelpAndFeedback isCollapsed={isCollapsed} />
|
||||
<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 group-hover:text-text-bright transition-colors"
|
||||
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="pointer-events-none absolute left-1/2 top-1/2 h-10 w-px -translate-y-1/2 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-charcoal-750 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
|
||||
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function SideMenuHeader({ title, children }: { title: string; children?: React.ReactNode }) {
|
||||
export function SideMenuHeader({
|
||||
title,
|
||||
children,
|
||||
isCollapsed = false,
|
||||
collapsedTitle,
|
||||
}: {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
isCollapsed?: boolean;
|
||||
/** When provided, this text stays visible when collapsed and the rest fades out */
|
||||
collapsedTitle?: string;
|
||||
}) {
|
||||
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
|
||||
@@ -11,9 +23,34 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
|
||||
setHeaderMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
// If collapsedTitle is provided and title starts with it, split the title
|
||||
const hasCollapsedTitle = collapsedTitle && title.startsWith(collapsedTitle);
|
||||
const visiblePart = hasCollapsedTitle ? collapsedTitle : title;
|
||||
const fadingPart = hasCollapsedTitle ? title.slice(collapsedTitle.length) : "";
|
||||
|
||||
return (
|
||||
<div className="group flex items-center justify-between pl-1.5">
|
||||
<h2 className="text-xs">{title}</h2>
|
||||
<motion.div
|
||||
className="group flex h-4 items-center justify-between overflow-hidden pl-1.5"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: hasCollapsedTitle ? 1 : isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<h2 className="text-xs whitespace-nowrap">
|
||||
{visiblePart}
|
||||
{fadingPart && (
|
||||
<motion.span
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
{fadingPart}
|
||||
</motion.span>
|
||||
)}
|
||||
</h2>
|
||||
{children !== undefined ? (
|
||||
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
|
||||
<PopoverCustomTrigger className="p-1">
|
||||
@@ -27,6 +64,6 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { type AnchorHTMLAttributes, type ReactNode } from "react";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { usePathName } from "~/hooks/usePathName";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { type RenderIcon } from "../primitives/Icon";
|
||||
import { type RenderIcon, Icon } from "../primitives/Icon";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
|
||||
export function SideMenuItem({
|
||||
icon,
|
||||
@@ -14,6 +16,7 @@ export function SideMenuItem({
|
||||
to,
|
||||
badge,
|
||||
target,
|
||||
isCollapsed = false,
|
||||
}: {
|
||||
icon?: RenderIcon;
|
||||
activeIconColor?: string;
|
||||
@@ -24,30 +27,67 @@ export function SideMenuItem({
|
||||
to: string;
|
||||
badge?: ReactNode;
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const pathName = usePathName();
|
||||
const isActive = pathName === to;
|
||||
|
||||
return (
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"}
|
||||
TrailingIcon={trailingIcon}
|
||||
trailingIconClassName={trailingIconClassName}
|
||||
to={to}
|
||||
target={target}
|
||||
className={cn(
|
||||
"text-text-bright group-hover:bg-charcoal-750 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
isActive ? "bg-tertiary text-text-bright" : "group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{name}
|
||||
<div className="flex items-center gap-1">{badge !== undefined && badge}</div>
|
||||
</div>
|
||||
</LinkButton>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Link
|
||||
to={to}
|
||||
target={target}
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center gap-2 overflow-hidden rounded pr-2 pl-[0.4375rem] text-text-bright transition-colors hover:bg-charcoal-750",
|
||||
isActive ? "bg-tertiary" : ""
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
className={cn(
|
||||
"size-5 shrink-0",
|
||||
isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"
|
||||
)}
|
||||
/>
|
||||
<motion.div
|
||||
className="flex min-w-0 flex-1 items-center justify-between overflow-hidden"
|
||||
initial={false}
|
||||
animate={{
|
||||
width: isCollapsed ? 0 : "auto",
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<span className="truncate text-2sm">{name}</span>
|
||||
{badge && !isCollapsed && (
|
||||
<motion.div
|
||||
className="ml-1 flex shrink-0 items-center gap-1"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
{badge}
|
||||
</motion.div>
|
||||
)}
|
||||
{trailingIcon && !isCollapsed && (
|
||||
<Icon
|
||||
icon={trailingIcon}
|
||||
className={cn("ml-1 size-4 shrink-0", trailingIconClassName)}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</Link>
|
||||
}
|
||||
content={name}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
buttonClassName="!h-8 block w-full"
|
||||
hidden={!isCollapsed}
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ type Props = {
|
||||
initialCollapsed?: boolean;
|
||||
onCollapseToggle?: (isCollapsed: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
/** When true, hides the section header and shows only children */
|
||||
isSideMenuCollapsed?: boolean;
|
||||
itemSpacingClassName?: string;
|
||||
};
|
||||
|
||||
/** A collapsible section for the side menu
|
||||
@@ -17,6 +20,8 @@ export function SideMenuSection({
|
||||
initialCollapsed = false,
|
||||
onCollapseToggle,
|
||||
children,
|
||||
isSideMenuCollapsed = false,
|
||||
itemSpacingClassName = "space-y-px",
|
||||
}: Props) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
|
||||
|
||||
@@ -27,22 +32,42 @@ export function SideMenuSection({
|
||||
}, [isCollapsed, onCollapseToggle]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1 rounded-sm py-1 pl-1.5 text-text-dimmed transition hover:bg-charcoal-750 hover:text-text-bright"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<h2 className="text-xs">{title}</h2>
|
||||
<div className="w-full overflow-hidden">
|
||||
{/* Header container - stays in DOM to preserve height */}
|
||||
<div className="relative w-full">
|
||||
{/* Header - fades out when sidebar is collapsed */}
|
||||
<motion.div
|
||||
initial={isCollapsed}
|
||||
animate={{ rotate: isCollapsed ? -90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex cursor-pointer items-center gap-1 overflow-hidden rounded-sm py-1 pl-1.5 text-text-dimmed transition hover:bg-charcoal-750 hover:text-text-bright"
|
||||
onClick={isSideMenuCollapsed ? undefined : handleToggle}
|
||||
style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }}
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isSideMenuCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<ToggleArrowIcon className="size-2" />
|
||||
<h2 className="text-xs whitespace-nowrap">{title}</h2>
|
||||
<motion.div
|
||||
initial={isCollapsed}
|
||||
animate={{ rotate: isCollapsed ? -90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ToggleArrowIcon className="size-2" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
{/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
|
||||
<motion.div
|
||||
className="absolute left-2 right-2 top-1 h-px bg-charcoal-600"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isSideMenuCollapsed && !isCollapsed ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div
|
||||
className="w-full"
|
||||
initial={isCollapsed ? "collapsed" : "expanded"}
|
||||
animate={isCollapsed ? "collapsed" : "expanded"}
|
||||
exit="collapsed"
|
||||
@@ -63,6 +88,7 @@ export function SideMenuSection({
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<motion.div
|
||||
className={`w-full ${itemSpacingClassName}`}
|
||||
variants={{
|
||||
expanded: {
|
||||
translateY: 0,
|
||||
|
||||
@@ -154,10 +154,12 @@ function PopoverSideMenuTrigger({
|
||||
children,
|
||||
className,
|
||||
shortcut,
|
||||
hideShortcutKey = false,
|
||||
...props
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
shortcut?: useShortcutKeys.ShortcutDefinition;
|
||||
hideShortcutKey?: boolean;
|
||||
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
useShortcutKeys.useShortcutKeys({
|
||||
@@ -176,14 +178,14 @@ function PopoverSideMenuTrigger({
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-[1.8rem] shrink-0 select-none items-center gap-x-1.5 rounded-sm bg-transparent px-[0.4rem] text-center font-sans text-2sm font-normal text-text-bright transition duration-150 focus-custom hover:bg-charcoal-750",
|
||||
shortcut ? "justify-between" : "",
|
||||
"flex h-[1.8rem] shrink-0 select-none items-center rounded-sm bg-transparent pl-[0.4rem] pr-2.5 text-center font-sans text-2sm font-normal text-text-bright transition duration-150 focus-custom hover:bg-charcoal-750",
|
||||
shortcut && !hideShortcutKey ? "justify-between gap-x-1.5" : "",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{shortcut && (
|
||||
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
|
||||
{shortcut && !hideShortcutKey && (
|
||||
<ShortcutKey className="size-4 flex-none" shortcut={shortcut} variant={"small"} />
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { UIMatch } from "@remix-run/react";
|
||||
import type { User } from "~/models/user.server";
|
||||
import { loader } from "~/root";
|
||||
import { type UIMatch } from "@remix-run/react";
|
||||
import type { UserWithDashboardPreferences } from "~/models/user.server";
|
||||
import { type loader } from "~/root";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
import { useIsImpersonating } from "./useOrganizations";
|
||||
|
||||
export type User = UserWithDashboardPreferences;
|
||||
|
||||
export function useOptionalUser(matches?: UIMatch[]): User | undefined {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
id: "root",
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function Project() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-[14rem_1fr] overflow-hidden">
|
||||
<div className="grid grid-cols-[auto_1fr] overflow-hidden">
|
||||
<DevPresenceProvider enabled={environment.type === "DEVELOPMENT"}>
|
||||
<SideMenu
|
||||
user={{ ...user, isImpersonating }}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
import { json } from "@remix-run/node";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { BetterStackClient } from "~/services/betterstack/betterstack.server";
|
||||
|
||||
@@ -21,12 +23,8 @@ export async function loader() {
|
||||
});
|
||||
}
|
||||
|
||||
export function IncidentStatusPanel() {
|
||||
export function IncidentStatusPanel({ isCollapsed = false }: { isCollapsed?: boolean }) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
if (!isManagedCloud) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fetcher = useFetcher<typeof loader>();
|
||||
|
||||
const fetchIncidents = useCallback(() => {
|
||||
@@ -36,36 +34,50 @@ export function IncidentStatusPanel() {
|
||||
}, [fetcher]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagedCloud) return;
|
||||
|
||||
fetchIncidents();
|
||||
|
||||
const interval = setInterval(fetchIncidents, 60 * 1000); // 1 minute
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [isManagedCloud, fetchIncidents]);
|
||||
|
||||
const operational = fetcher.data?.operational ?? true;
|
||||
|
||||
if (!isManagedCloud || operational) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!operational && (
|
||||
<Popover>
|
||||
<div className="p-1">
|
||||
{/* Expanded panel - animated height and opacity */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="p-1"
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isCollapsed ? 0 : "auto",
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-2 rounded border border-warning/20 bg-warning/5 p-2 pt-1.5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-1 border-b border-warning/20 pb-1 text-warning">
|
||||
<ExclamationTriangleIcon className="size-4" />
|
||||
<Paragraph variant="small/bright" className="text-warning">
|
||||
Active incident
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<Paragraph variant="extra-small/bright" className="text-warning/80">
|
||||
Our team is working on resolving the issue. Check our status page for more
|
||||
information.
|
||||
</Paragraph>
|
||||
|
||||
{/* Button */}
|
||||
<LinkButton
|
||||
variant="secondary/small"
|
||||
to="https://status.trigger.dev"
|
||||
@@ -77,7 +89,59 @@ export function IncidentStatusPanel() {
|
||||
</LinkButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
|
||||
{/* Collapsed button - animated height and opacity */}
|
||||
<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-warning/20 bg-warning/10 transition-colors hover:border-warning/30 hover:bg-warning/20">
|
||||
<ExclamationTriangleIcon className="size-5 text-warning" />
|
||||
</PopoverTrigger>
|
||||
}
|
||||
content="Active incident"
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
disableHoverableContent
|
||||
asChild
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
<PopoverContent side="right" sideOffset={8} align="start" className="!min-w-0 w-52 p-0">
|
||||
<IncidentPopoverContent />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentPopoverContent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded border border-warning/20 bg-warning/5 p-2 pt-1.5">
|
||||
<div className="flex items-center gap-1 border-b border-warning/20 pb-1 text-warning">
|
||||
<ExclamationTriangleIcon className="size-4" />
|
||||
<Paragraph variant="small/bright" className="text-warning">
|
||||
Active incident
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="extra-small/bright" className="text-warning/80">
|
||||
Our team is working on resolving the issue. Check our status page for more information.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
variant="secondary/small"
|
||||
to="https://status.trigger.dev"
|
||||
target="_blank"
|
||||
fullWidth
|
||||
className="border-warning/20 bg-warning/10 hover:!border-warning/30 hover:!bg-warning/20"
|
||||
>
|
||||
<span className="text-warning">View status page</span>
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { updateSideMenuPreferences } from "~/services/dashboardPreferences.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
|
||||
// Transforms form data string "true"/"false" to boolean, or undefined if not present
|
||||
const booleanFromFormData = z
|
||||
.enum(["true", "false"])
|
||||
.transform((val) => val === "true")
|
||||
.optional();
|
||||
|
||||
const RequestSchema = z.object({
|
||||
isCollapsed: booleanFromFormData,
|
||||
manageSectionCollapsed: booleanFromFormData,
|
||||
});
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const user = await requireUser(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
const rawData = Object.fromEntries(formData);
|
||||
|
||||
const result = RequestSchema.safeParse(rawData);
|
||||
if (!result.success) {
|
||||
return json({ success: false, error: "Invalid request data" }, { status: 400 });
|
||||
}
|
||||
|
||||
await updateSideMenuPreferences({
|
||||
user,
|
||||
isCollapsed: result.data.isCollapsed,
|
||||
manageSectionCollapsed: result.data.manageSectionCollapsed,
|
||||
});
|
||||
|
||||
return json({ success: true });
|
||||
}
|
||||
@@ -3,6 +3,13 @@ import { prisma } from "~/db.server";
|
||||
import { logger } from "./logger.server";
|
||||
import { type UserFromSession } from "./session.server";
|
||||
|
||||
const SideMenuPreferences = z.object({
|
||||
isCollapsed: z.boolean().default(false),
|
||||
manageSectionCollapsed: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type SideMenuPreferences = z.infer<typeof SideMenuPreferences>;
|
||||
|
||||
const DashboardPreferences = z.object({
|
||||
version: z.literal("1"),
|
||||
currentProjectId: z.string().optional(),
|
||||
@@ -12,6 +19,7 @@ const DashboardPreferences = z.object({
|
||||
currentEnvironment: z.object({ id: z.string() }),
|
||||
})
|
||||
),
|
||||
sideMenu: SideMenuPreferences.optional(),
|
||||
});
|
||||
|
||||
export type DashboardPreferences = z.infer<typeof DashboardPreferences>;
|
||||
@@ -99,3 +107,47 @@ export async function clearCurrentProject({ user }: { user: UserFromSession }) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSideMenuPreferences({
|
||||
user,
|
||||
isCollapsed,
|
||||
manageSectionCollapsed,
|
||||
}: {
|
||||
user: UserFromSession;
|
||||
isCollapsed?: boolean;
|
||||
manageSectionCollapsed?: boolean;
|
||||
}) {
|
||||
if (user.isImpersonating) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse with schema to apply defaults, then overlay any new values
|
||||
const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {});
|
||||
const updatedSideMenu = SideMenuPreferences.parse({
|
||||
...currentSideMenu,
|
||||
...(isCollapsed !== undefined && { isCollapsed }),
|
||||
...(manageSectionCollapsed !== undefined && { manageSectionCollapsed }),
|
||||
});
|
||||
|
||||
// Only update if something changed
|
||||
if (
|
||||
updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed &&
|
||||
updatedSideMenu.manageSectionCollapsed === currentSideMenu.manageSectionCollapsed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedPreferences: DashboardPreferences = {
|
||||
...user.dashboardPreferences,
|
||||
sideMenu: updatedSideMenu,
|
||||
};
|
||||
|
||||
return prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
dashboardPreferences: updatedPreferences,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user