[WEB-4328] feat: sidebar revamp (#7217)

* chore: sidebar peek state added to theme store

* chore: extended sidebar wrapper added

* chore: resizeable sidebar component added

* chore: appsidebar root component

* chore: updated sidebar and applied necessary changes across codebase

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: breadcrumb changes

* chore: sidebar improvements and fixes

* chore: enhancements and fixes

* fix: peek sidebar leave

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: icons added

* chore: add dock variable and toggle function to theme store

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: theme and workspace store updated

* chore: workspace content wrapper and apprail context

* chore: workspace and project wrapper updated

* chore: app rail component

* chore: content wrapper

* chore: sidebar component updated

* chore: layout changes and code refactoring

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: appsidebar toggle button

* chore: code refactor

* chore: workspace menu improvements

* chore: sidebar spacing and padding improvements

* chore: settings layout improvement

* chore: enhancements

* chore: extended sidebar code refactor

* chore: code refactor

* fix: merge conflict

* fix: merge conflict

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor
This commit is contained in:
Anmol Singh Bhatia 2025-07-08 20:18:39 +05:30 committed by GitHub
parent fd9da3164e
commit 0225d806cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 2126 additions and 1143 deletions

View file

@ -0,0 +1,63 @@
"use client";
import { FC, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { SIDEBAR_WIDTH } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
// hooks
import { ResizableSidebar } from "@/components/sidebar";
import { useAppTheme } from "@/hooks/store";
import { useAppRail } from "@/hooks/use-app-rail";
// local imports
import { ExtendedAppSidebar } from "./extended-sidebar";
import { AppSidebar } from "./sidebar";
export const ProjectAppSidebar: FC = observer(() => {
// store hooks
const {
sidebarCollapsed,
toggleSidebar,
sidebarPeek,
toggleSidebarPeek,
isExtendedSidebarOpened,
isAnySidebarDropdownOpen,
} = useAppTheme();
const { storedValue, setValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH);
// states
const [sidebarWidth, setSidebarWidth] = useState<number>(storedValue ?? SIDEBAR_WIDTH);
// hooks
const { shouldRenderAppRail } = useAppRail();
// derived values
const isAnyExtendedSidebarOpen = isExtendedSidebarOpened;
// handlers
const handleWidthChange = (width: number) => setValue(width);
return (
<>
<ResizableSidebar
showPeek={sidebarPeek}
defaultWidth={storedValue ?? 250}
width={sidebarWidth}
setWidth={setSidebarWidth}
defaultCollapsed={sidebarCollapsed}
peekDuration={1500}
onWidthChange={handleWidthChange}
onCollapsedChange={toggleSidebar}
isCollapsed={sidebarCollapsed}
toggleCollapsed={toggleSidebar}
togglePeek={toggleSidebarPeek}
extendedSidebar={
<>
<ExtendedAppSidebar />
</>
}
isAnyExtendedSidebarExpanded={isAnyExtendedSidebarOpen}
isAnySidebarDropdownOpen={isAnySidebarDropdownOpen}
disablePeekTrigger={shouldRenderAppRail}
>
<AppSidebar />
</ResizableSidebar>
</>
);
});

View file

@ -8,14 +8,14 @@ import { Plus, Search } from "lucide-react";
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
import { cn, copyUrlToClipboard, orderJoinedProjects } from "@plane/utils";
import { copyUrlToClipboard, orderJoinedProjects } from "@plane/utils";
// components
import { CreateProjectModal } from "@/components/project";
import { SidebarProjectsListItem } from "@/components/workspace";
// hooks
import { useAppTheme, useProject, useUserPermissions } from "@/hooks/store";
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
import { TProject } from "@/plane-web/types";
import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper";
export const ExtendedProjectSidebar = observer(() => {
// refs
@ -27,7 +27,7 @@ export const ExtendedProjectSidebar = observer(() => {
const { workspaceSlug } = useParams();
// store hooks
const { t } = useTranslation();
const { sidebarCollapsed, extendedProjectSidebarCollapsed, toggleExtendedProjectSidebar } = useAppTheme();
const { isExtendedProjectSidebarOpened, toggleExtendedProjectSidebar } = useAppTheme();
const { getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject();
const { allowPermissions } = useUserPermissions();
@ -74,15 +74,7 @@ export const ExtendedProjectSidebar = observer(() => {
EUserPermissionsLevel.WORKSPACE
);
useExtendedSidebarOutsideClickDetector(
extendedProjectSidebarRef,
() => {
if (!isProjectModalOpen) {
toggleExtendedProjectSidebar(false);
}
},
"extended-project-sidebar-toggle"
);
const handleClose = () => toggleExtendedProjectSidebar(false);
const handleCopyText = (projectId: string) => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
@ -103,17 +95,11 @@ export const ExtendedProjectSidebar = observer(() => {
workspaceSlug={workspaceSlug.toString()}
/>
)}
<div
ref={extendedProjectSidebarRef}
className={cn(
"absolute top-0 h-full z-[19] flex flex-col gap-2 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 shadow-md",
{
"translate-x-0 opacity-100 pointer-events-auto": extendedProjectSidebarCollapsed,
"-translate-x-full opacity-0 pointer-events-none": !extendedProjectSidebarCollapsed,
"left-[70px]": sidebarCollapsed,
"left-[250px]": !sidebarCollapsed,
}
)}
<ExtendedSidebarWrapper
isExtendedSidebarOpened={!!isExtendedProjectSidebarOpened}
extendedSidebarRef={extendedProjectSidebarRef}
handleClose={handleClose}
excludedElementId="extended-project-sidebar-toggle"
>
<div className="flex flex-col gap-1 w-full sticky top-4 pt-0 px-4">
<div className="flex items-center justify-between">
@ -159,7 +145,7 @@ export const ExtendedProjectSidebar = observer(() => {
/>
))}
</div>
</div>
</ExtendedSidebarWrapper>
</>
);
});

View file

@ -0,0 +1,46 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { EXTENDED_SIDEBAR_WIDTH, SIDEBAR_WIDTH } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
import { cn } from "@plane/utils";
// hooks
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
type Props = {
children: React.ReactNode;
extendedSidebarRef: React.RefObject<HTMLDivElement>;
isExtendedSidebarOpened: boolean;
handleClose: () => void;
excludedElementId: string;
};
export const ExtendedSidebarWrapper: FC<Props> = observer((props) => {
const { children, extendedSidebarRef, isExtendedSidebarOpened, handleClose, excludedElementId } = props;
// store hooks
const { storedValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH);
useExtendedSidebarOutsideClickDetector(extendedSidebarRef, handleClose, excludedElementId);
return (
<div
id={excludedElementId}
ref={extendedSidebarRef}
className={cn(
`absolute h-full z-[19] flex flex-col py-2 transform transition-all duration-300 ease-in-out bg-custom-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-sm`,
{
"translate-x-0 opacity-100": isExtendedSidebarOpened,
[`-translate-x-[${EXTENDED_SIDEBAR_WIDTH}px] opacity-0 hidden`]: !isExtendedSidebarOpened,
}
)}
style={{
left: `${storedValue ?? SIDEBAR_WIDTH}px`,
width: `${isExtendedSidebarOpened ? EXTENDED_SIDEBAR_WIDTH : 0}px`,
}}
>
{children}
</div>
);
});

View file

@ -6,12 +6,11 @@ import { useParams } from "next/navigation";
// plane imports
import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants";
import { EUserWorkspaceRoles } from "@plane/types";
import { cn } from "@plane/utils";
// hooks
import { useAppTheme, useWorkspace } from "@/hooks/store";
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
// plane-web imports
import { ExtendedSidebarItem } from "@/plane-web/components/workspace/sidebar";
import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper";
export const ExtendedAppSidebar = observer(() => {
// refs
@ -19,7 +18,7 @@ export const ExtendedAppSidebar = observer(() => {
// routers
const { workspaceSlug } = useParams();
// store hooks
const { sidebarCollapsed, extendedSidebarCollapsed, toggleExtendedSidebar } = useAppTheme();
const { isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
const { updateSidebarPreference, getNavigationPreferences } = useWorkspace();
// derived values
@ -95,24 +94,14 @@ export const ExtendedAppSidebar = observer(() => {
});
};
useExtendedSidebarOutsideClickDetector(
extendedSidebarRef,
() => toggleExtendedSidebar(true),
"extended-sidebar-toggle"
);
const handleClose = () => toggleExtendedSidebar(false);
return (
<div
ref={extendedSidebarRef}
className={cn(
"absolute top-0 h-full z-[19] flex flex-col gap-0.5 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-md pb-6",
{
"-translate-x-full opacity-0 pointer-events-none": extendedSidebarCollapsed,
"translate-x-0 opacity-100 pointer-events-auto": !extendedSidebarCollapsed,
"left-[70px]": sidebarCollapsed,
"left-[250px]": !sidebarCollapsed,
}
)}
<ExtendedSidebarWrapper
isExtendedSidebarOpened={!!isExtendedSidebarOpened}
extendedSidebarRef={extendedSidebarRef}
handleClose={handleClose}
excludedElementId="extended-sidebar-toggle"
>
{sortedNavigationItems.map((item, index) => (
<ExtendedSidebarItem
@ -122,6 +111,6 @@ export const ExtendedAppSidebar = observer(() => {
handleOnNavigationItemDrop={handleOnNavigationItemDrop}
/>
))}
</div>
</ExtendedSidebarWrapper>
);
});

View file

@ -1,5 +1,6 @@
"use client";
import { observer } from "mobx-react";
import Image from "next/image";
import { useTheme } from "next-themes";
import { Home } from "lucide-react";
@ -16,16 +17,17 @@ import { BreadcrumbLink } from "@/components/common";
// hooks
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
export const WorkspaceDashboardHeader = () => {
export const WorkspaceDashboardHeader = observer(() => {
// hooks
const { resolvedTheme } = useTheme();
const { t } = useTranslation();
return (
<>
<Header>
<Header.LeftItem>
<div>
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
@ -65,4 +67,4 @@ export const WorkspaceDashboardHeader = () => {
</Header>
</>
);
};
});

View file

@ -2,21 +2,22 @@
import { CommandPalette } from "@/components/command-palette";
import { AuthenticationWrapper } from "@/lib/wrappers";
// plane web components
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
import { AppSidebar } from "./sidebar";
import { ProjectAppSidebar } from "./_sidebar";
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
return (
<AuthenticationWrapper>
<CommandPalette />
<WorkspaceAuthWrapper>
<div className="relative flex h-screen w-full overflow-hidden">
<AppSidebar />
<div className="relative flex flex-col h-full w-full overflow-hidden rounded-lg border border-custom-border-200">
<div className="relative flex size-full overflow-hidden">
<ProjectAppSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
{children}
</main>
</div>
</div>
</WorkspaceAuthWrapper>
</AuthenticationWrapper>
);

View file

@ -5,25 +5,25 @@ import { observer } from "mobx-react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useOutsideClickDetector } from "@plane/hooks";
// components
import { cn } from "@plane/utils";
import { SidebarDropdown, SidebarHelpSection, SidebarProjectsList, SidebarQuickActions } from "@/components/workspace";
import { AppSidebarToggleButton } from "@/components/sidebar";
import { SidebarDropdown, SidebarProjectsList, SidebarQuickActions } from "@/components/workspace";
import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu";
import { HelpMenu } from "@/components/workspace/sidebar/help-menu";
import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items";
// helpers
// hooks
import { useAppTheme, useUserPermissions } from "@/hooks/store";
import { useFavorite } from "@/hooks/store/use-favorite";
import { useAppRail } from "@/hooks/use-app-rail";
import useSize from "@/hooks/use-window-size";
// plane web components
import { SidebarAppSwitcher } from "@/plane-web/components/sidebar";
import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge";
import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list";
import { ExtendedProjectSidebar } from "./extended-project-sidebar";
import { ExtendedAppSidebar } from "./extended-sidebar";
export const AppSidebar: FC = observer(() => {
// store hooks
const { allowPermissions } = useUserPermissions();
const { toggleSidebar, sidebarCollapsed } = useAppTheme();
const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail();
const { groupedFavorites } = useFavorite();
const windowSize = useSize();
// refs
@ -52,47 +52,23 @@ export const AppSidebar: FC = observer(() => {
return (
<>
<div
className={cn(
"fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-custom-sidebar-border-200 bg-custom-sidebar-background-100 duration-300 w-[250px] md:relative md:ml-0",
{
"w-[70px] -ml-[250px]": sidebarCollapsed,
}
)}
>
<div
ref={ref}
className={cn("size-full flex flex-col flex-1 pt-4 pb-0", {
"p-2 pt-4": sidebarCollapsed,
})}
>
<div
className={cn("px-2", {
"px-4": !sidebarCollapsed,
})}
>
<div className="flex flex-col gap-3 px-3">
{/* Workspace switcher and settings */}
<SidebarDropdown />
<div className="flex-shrink-0 h-4" />
{/* App switcher */}
{canPerformWorkspaceMemberActions && <SidebarAppSwitcher />}
{!shouldRenderAppRail && <SidebarDropdown />}
{isAppRailEnabled && (
<div className="flex items-center justify-between gap-2">
<span className="text-md text-custom-text-200 font-medium pt-1">Projects</span>
<div className="flex items-center gap-2">
<AppSidebarToggleButton />
</div>
</div>
)}
{/* Quick actions */}
<SidebarQuickActions />
</div>
<hr
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1", {
"opacity-0": !sidebarCollapsed,
})}
/>
<div
className={cn("overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto px-2 py-0.5", {
"vertical-scrollbar px-4": !sidebarCollapsed,
})}
>
<div className="flex flex-col gap-3 overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto vertical-scrollbar px-3 pt-3 pb-0.5">
<SidebarMenuItems />
{sidebarCollapsed && (
<hr className="flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1" />
)}
{/* Favorites Menu */}
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
{/* Teams List */}
@ -101,11 +77,13 @@ export const AppSidebar: FC = observer(() => {
<SidebarProjectsList />
</div>
{/* Help Section */}
<SidebarHelpSection />
<div className="flex items-center justify-between p-3 border-t border-custom-border-200 bg-custom-sidebar-background-100 h-12">
<WorkspaceEditionBadge />
<div className="flex items-center gap-2">
{!shouldRenderAppRail && <HelpMenu />}
{!isAppRailEnabled && <AppSidebarToggleButton />}
</div>
</div>
<ExtendedAppSidebar />
<ExtendedProjectSidebar />
</>
);
});

View file

@ -11,14 +11,16 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
<AuthenticationWrapper>
<WorkspaceAuthWrapper>
<CommandPalette />
<main className="relative flex h-screen w-full flex-col overflow-hidden bg-custom-background-100">
<div className="relative flex h-full w-full overflow-hidden rounded-lg border border-custom-border-200">
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
{/* Header */}
<SettingsHeader />
{/* Content */}
<ContentWrapper className="px-4 md:pl-12 md:flex w-full">
<ContentWrapper className="p-page-x md:flex w-full">
<div className="w-full h-full overflow-hidden">{children}</div>
</ContentWrapper>
</main>
</div>
</WorkspaceAuthWrapper>
</AuthenticationWrapper>
);

View file

@ -0,0 +1,12 @@
"use client";
import { AppRailProvider } from "@/hooks/context/app-rail-context";
import { WorkspaceContentWrapper } from "@/plane-web/components/workspace";
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
return (
<AppRailProvider>
<WorkspaceContentWrapper>{children}</WorkspaceContentWrapper>
</AppRailProvider>
);
}

View file

@ -19,7 +19,7 @@ export default function ProfileSettingsLayout(props: Props) {
<>
<CommandPalette />
<AuthenticationWrapper>
<div className="relative flex h-full w-full overflow-hidden">
<div className="relative flex h-full w-full overflow-hidden rounded-lg border border-custom-border-200">
<ProfileLayoutSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
<div className="h-full w-full overflow-hidden">{children}</div>

View file

@ -0,0 +1 @@
export * from "./root";

View file

@ -0,0 +1,4 @@
"use client";
import React from "react";
export const AppRailRoot = () => <></>;

View file

@ -7,12 +7,9 @@ import { ProjectNavigation } from "@/components/workspace";
type TProjectItemsRootProps = {
workspaceSlug: string;
projectId: string;
isSidebarCollapsed: boolean;
};
export const ProjectNavigationRoot: FC<TProjectItemsRootProps> = (props) => {
const { workspaceSlug, projectId, isSidebarCollapsed } = props;
return (
<ProjectNavigation workspaceSlug={workspaceSlug} projectId={projectId} isSidebarCollapsed={isSidebarCollapsed} />
);
const { workspaceSlug, projectId } = props;
return <ProjectNavigation workspaceSlug={workspaceSlug} projectId={projectId} />;
};

View file

@ -0,0 +1,5 @@
"use client";
import React from "react";
export const WorkspaceAppSwitcher = () => <></>;

View file

@ -0,0 +1,9 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
export const WorkspaceContentWrapper = observer(({ children }: { children: React.ReactNode }) => (
<div className="flex relative size-full overflow-hidden bg-custom-background-90 rounded-lg transition-all ease-in-out duration-300">
<div className="size-full p-2 flex-grow transition-all ease-in-out duration-300">{children}</div>
</div>
));

View file

@ -4,3 +4,5 @@ export * from "./billing";
export * from "./delete-workspace-section";
export * from "./sidebar";
export * from "./members";
export * from "./content-wrapper";
export * from "./app-switcher";

View file

@ -2,14 +2,11 @@ import { observer } from "mobx-react";
import { Search } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// helpers
import { cn } from "@plane/utils";
// hooks
import { useAppTheme, useCommandPalette } from "@/hooks/store";
import { useCommandPalette } from "@/hooks/store";
export const AppSearch = observer(() => {
// store hooks
const { sidebarCollapsed } = useAppTheme();
const { toggleCommandPaletteModal } = useCommandPalette();
// translation
const { t } = useTranslation();
@ -17,12 +14,7 @@ export const AppSearch = observer(() => {
return (
<button
type="button"
className={cn(
"flex-shrink-0 size-8 aspect-square grid place-items-center rounded hover:bg-custom-sidebar-background-90 outline-none",
{
"border-[0.5px] border-custom-sidebar-border-300": !sidebarCollapsed,
}
)}
className="flex-shrink-0 size-8 aspect-square grid place-items-center rounded hover:bg-custom-sidebar-background-90 outline-none border-[0.5px] border-custom-sidebar-border-300"
onClick={() => toggleCommandPaletteModal(true)}
aria-label={t("aria_labels.projects_sidebar.open_command_palette")}
>

View file

@ -171,10 +171,7 @@ export const ExtendedSidebarItem: FC<TExtendedSidebarItemProps> = observer((prop
className={cn(
"flex items-center justify-center absolute top-1/2 -left-3 -translate-y-1/2 rounded text-custom-sidebar-text-400 cursor-grab",
{
// "cursor-not-allowed opacity-60": project.sort_order === null,
"cursor-grabbing": isDragging,
// "!hidden": isSidebarCollapsed,
}
)}
ref={dragHandleRef}

View file

@ -5,9 +5,7 @@ import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
// plane imports
import { EUserPermissionsLevel, IWorkspaceSidebarNavigationItem } from "@plane/constants";
import { usePlatformOS } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/ui";
// components
import { SidebarNavItem } from "@/components/sidebar";
import { NotificationAppSidebarOption } from "@/components/workspace-notifications";
@ -31,14 +29,13 @@ export const SidebarItem: FC<TSidebarItemProps> = observer((props) => {
const { data } = useUser();
// store hooks
const { toggleSidebar, sidebarCollapsed, extendedSidebarCollapsed, toggleExtendedSidebar } = useAppTheme();
const { isMobile } = usePlatformOS();
const { toggleSidebar, isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
const handleLinkClick = () => {
if (window.innerWidth < 768) {
toggleSidebar();
}
if (!extendedSidebarCollapsed) toggleExtendedSidebar();
if (isExtendedSidebarOpened) toggleExtendedSidebar(false);
};
const staticItems = ["home", "inbox", "pi-chat", "projects"];
@ -61,30 +58,14 @@ export const SidebarItem: FC<TSidebarItemProps> = observer((props) => {
const icon = getSidebarNavigationItemIcon(item.key);
return (
<Tooltip
tooltipContent={t(item.labelTranslationKey)}
position="right"
className="ml-2"
disabled={!sidebarCollapsed}
isMobile={isMobile}
>
<Link href={itemHref} onClick={() => handleLinkClick()}>
<SidebarNavItem
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
isActive={isActive}
>
<SidebarNavItem isActive={isActive}>
<div className="flex items-center gap-1.5 py-[1px]">
{icon}
{!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>}
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
</div>
{item.key === "inbox" && (
<NotificationAppSidebarOption
workspaceSlug={workspaceSlug?.toString()}
isSidebarCollapsed={sidebarCollapsed ?? false}
/>
)}
{item.key === "inbox" && <NotificationAppSidebarOption workspaceSlug={workspaceSlug?.toString()} />}
</SidebarNavItem>
</Link>
</Tooltip>
);
});

View file

@ -1,27 +1,31 @@
"use client";
import { ReactNode } from "react";
// components
import { observer } from "mobx-react";
// plane imports
import { Row } from "@plane/ui";
import { SidebarHamburgerToggle } from "@/components/core";
// components
import { AppSidebarToggleButton } from "@/components/sidebar";
// hooks
import { useAppTheme } from "@/hooks/store";
export interface AppHeaderProps {
header: ReactNode;
mobileHeader?: ReactNode;
}
export const AppHeader = (props: AppHeaderProps) => {
export const AppHeader = observer((props: AppHeaderProps) => {
const { header, mobileHeader } = props;
// store hooks
const { sidebarCollapsed } = useAppTheme();
return (
<div className="z-[18]">
<Row className="h-[3.75rem] flex gap-2 w-full items-center border-b border-custom-border-200 bg-custom-sidebar-background-100">
<div className="block bg-custom-sidebar-background-100 md:hidden">
<SidebarHamburgerToggle />
</div>
{sidebarCollapsed && <AppSidebarToggleButton />}
<div className="w-full">{header}</div>
</Row>
{mobileHeader && mobileHeader}
</div>
);
};
});

View file

@ -1,20 +1,26 @@
"use client";
import { observer } from "mobx-react";
import { Menu } from "lucide-react";
import { PanelRight } from "lucide-react";
import { useAppTheme } from "@/hooks/store";
export const SidebarHamburgerToggle = observer(() => {
// store hooks
const { toggleSidebar } = useAppTheme();
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
toggleSidebar();
};
return (
<button
type="button"
className="group flex-shrink-0 size-7 grid place-items-center rounded bg-custom-background-80 transition-all hover:bg-custom-background-90 md:hidden"
onClick={() => toggleSidebar()}
className="group flex-shrink-0 size-7 grid place-items-center rounded hover:bg-custom-background-80 transition-all bg-custom-background-90"
onClick={handleClick}
>
<Menu className="size-3.5 text-custom-text-200 transition-all group-hover:text-custom-text-100" />
<PanelRight className="size-3.5 text-custom-text-200 transition-all group-hover:text-custom-text-100" />
</button>
);
});

View file

@ -2,6 +2,7 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { useTheme } from "next-themes";
import { ChevronLeftIcon } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { getButtonStyling } from "@plane/ui/src/button";
@ -15,16 +16,16 @@ export const SettingsHeader = observer(() => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const { isScrolled } = useUserSettings();
// resolved theme
const { resolvedTheme } = useTheme();
// redirect url for normal mode
return (
<div
className={cn(
"bg-custom-background-90 px-4 py-4 gap-2 md:px-12 md:py-8 transition-all duration-300 ease-in-out relative",
{
className={cn("bg-custom-background-90 p-page-x transition-all duration-300 ease-in-out relative", {
"!pt-4 flex md:flex-col": isScrolled,
}
)}
"bg-custom-background-90/50": resolvedTheme === "dark",
})}
>
<Link
href={`/${currentWorkspace?.slug}`}
@ -41,7 +42,7 @@ export const SettingsHeader = observer(() => {
<Link
href={`/${currentWorkspace?.slug}`}
className={cn(
"group flex gap-2 text-custom-text-300 mb-4 border border-transparent w-fit rounded-lg",
"group flex gap-2 text-custom-text-300 mb-3 border border-transparent w-fit rounded-lg",
!isScrolled ? "hover:bg-custom-background-100 hover:border-custom-border-200 items-center pr-2 " : " h-0 m-0"
)}
>

View file

@ -1,5 +1,6 @@
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { ScrollArea } from "@plane/ui";
import { cn } from "@plane/utils";
import { SettingsSidebarHeader } from "./header";
import SettingsSidebarNavItem, { TSettingItem } from "./nav-item";
@ -45,12 +46,15 @@ export const SettingsSidebar = observer((props: SettingsSidebarProps) => {
{/* Header */}
<SettingsSidebarHeader customHeader={customHeader} />
{/* Navigation */}
<div className="divide-y divide-custom-border-100 overflow-x-hidden w-full h-full overflow-y-scroll">
<ScrollArea
className="divide-y divide-custom-border-100 overflow-x-hidden w-full h-full overflow-y-scroll"
type="hover"
>
{categories.map((category) => {
if (groupedSettings[category].length === 0) return null;
return (
<div key={category} className="py-3">
<span className="text-sm font-semibold text-custom-text-350 capitalize mb-2">{t(category)}</span>
<span className="text-sm font-semibold text-custom-text-350 capitalize mb-2 px-2">{t(category)}</span>
<div className="relative flex flex-col gap-0.5 h-full mt-2">
{groupedSettings[category].map(
(setting) =>
@ -70,7 +74,7 @@ export const SettingsSidebar = observer((props: SettingsSidebarProps) => {
</div>
);
})}
</div>
</ScrollArea>
</div>
);
});

View file

@ -1 +1,4 @@
export * from "./sidebar-navigation";
export * from "./resizable-sidebar";
export * from "./sidebar-item";
export * from "./sidebar-toggle-button";

View file

@ -0,0 +1,287 @@
"use client";
import React, { Dispatch, ReactElement, SetStateAction, useCallback, useEffect, useState, useRef } from "react";
// helpers
import { cn } from "@plane/utils";
interface ResizableSidebarProps {
showPeek?: boolean;
togglePeek: (value?: boolean) => void;
isCollapsed?: boolean;
width: number;
setWidth: Dispatch<SetStateAction<number>>;
defaultWidth?: number;
minWidth?: number;
maxWidth?: number;
defaultCollapsed?: boolean;
peekDuration?: number;
toggleCollapsed: (value?: boolean) => void;
onWidthChange?: (width: number) => void;
onCollapsedChange?: (collapsed: boolean) => void;
className?: string;
children?: ReactElement;
extendedSidebar?: ReactElement;
isAnyExtendedSidebarExpanded?: boolean;
isAnySidebarDropdownOpen?: boolean;
disablePeekTrigger?: boolean;
}
export function ResizableSidebar({
showPeek = false,
togglePeek,
peekDuration = 500,
isCollapsed = false,
toggleCollapsed: toggleCollapsedProp,
onCollapsedChange,
width,
setWidth,
onWidthChange,
minWidth = 236,
maxWidth = 350,
className = "",
children,
extendedSidebar,
isAnyExtendedSidebarExpanded = false,
isAnySidebarDropdownOpen = false,
disablePeekTrigger = false,
}: ResizableSidebarProps) {
// states
const [isResizing, setIsResizing] = useState(false);
const [isHoveringTrigger, setIsHoveringTrigger] = useState(false);
// refs
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// handlers
const setShowPeek = useCallback(
(value: boolean) => {
togglePeek(value);
},
[togglePeek]
);
const handleResize = useCallback(
(e: MouseEvent) => {
if (!isResizing) return;
const newWidth = Math.min(Math.max(e.clientX, minWidth), maxWidth);
setWidth(newWidth);
},
[isResizing, minWidth, maxWidth, setWidth]
);
const startResizing = useCallback(() => {
setIsResizing(true);
}, []);
const stopResizing = useCallback(() => {
setIsResizing(false);
}, []);
const toggleCollapsed = useCallback(() => {
toggleCollapsedProp();
setShowPeek(false);
setIsHoveringTrigger(false);
if (peekTimeoutRef.current) {
clearTimeout(peekTimeoutRef.current);
}
}, [toggleCollapsedProp, setShowPeek]);
const handleTriggerEnter = useCallback(() => {
if (isCollapsed) {
setIsHoveringTrigger(true);
setShowPeek(true);
if (peekTimeoutRef.current) {
clearTimeout(peekTimeoutRef.current);
}
}
}, [isCollapsed, setShowPeek]);
const handleTriggerLeave = useCallback(() => {
if (isCollapsed && !isAnyExtendedSidebarExpanded) {
setIsHoveringTrigger(false);
peekTimeoutRef.current = setTimeout(() => {
setShowPeek(false);
}, peekDuration);
}
}, [isCollapsed, peekDuration, setShowPeek, isAnyExtendedSidebarExpanded]);
const handlePeekEnter = useCallback(() => {
if (isCollapsed && showPeek) {
if (peekTimeoutRef.current) {
clearTimeout(peekTimeoutRef.current);
}
}
}, [isCollapsed, showPeek]);
const handlePeekLeave = useCallback(() => {
if (isCollapsed && !isAnyExtendedSidebarExpanded && !isAnySidebarDropdownOpen) {
peekTimeoutRef.current = setTimeout(() => {
setShowPeek(false);
}, peekDuration);
}
}, [isCollapsed, peekDuration, setShowPeek, isAnyExtendedSidebarExpanded, isAnySidebarDropdownOpen]);
// Set up event listeners for resizing
useEffect(() => {
if (isResizing) {
document.addEventListener("mousemove", handleResize);
document.addEventListener("mouseup", stopResizing);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}
return () => {
document.removeEventListener("mousemove", handleResize);
document.removeEventListener("mouseup", stopResizing);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [isResizing, handleResize, stopResizing]);
// Clean up timeout on unmount
useEffect(
() => () => {
if (peekTimeoutRef.current) {
clearTimeout(peekTimeoutRef.current);
}
},
[]
);
useEffect(() => {
if (!isAnySidebarDropdownOpen && isCollapsed && isHoveringTrigger) {
handlePeekLeave();
}
}, [isAnySidebarDropdownOpen]);
useEffect(() => {
if (!isAnyExtendedSidebarExpanded && isCollapsed && isHoveringTrigger) {
handlePeekLeave();
}
}, [isAnyExtendedSidebarExpanded]);
// Reset peek when sidebar is expanded
useEffect(() => {
if (!isCollapsed) {
setShowPeek(false);
setIsHoveringTrigger(false);
if (peekTimeoutRef.current) {
clearTimeout(peekTimeoutRef.current);
}
}
}, [isCollapsed, setShowPeek]);
// Call external handlers when state changes
useEffect(() => {
onWidthChange?.(width);
}, [width, onWidthChange]);
useEffect(() => {
onCollapsedChange?.(isCollapsed);
}, [isCollapsed, onCollapsedChange]);
return (
<>
{/* Main Sidebar */}
<div
className={cn(
"h-full z-20 bg-custom-background-100 border-r border-custom-sidebar-border-200",
!isResizing && "transition-all duration-300 ease-in-out",
isCollapsed ? "translate-x-[-100%] opacity-0 w-0" : "translate-x-0 opacity-100",
className
)}
style={{
width: `${isCollapsed ? 0 : width}px`,
minWidth: `${isCollapsed ? 0 : width}px`,
maxWidth: `${isCollapsed ? 0 : width}px`,
}}
role="complementary"
aria-label="Main sidebar"
>
<aside
className={cn(
"group/sidebar h-full w-full bg-custom-background-100 overflow-hidden relative flex flex-col pt-3",
isAnyExtendedSidebarExpanded && "rounded-none"
)}
>
{children}
{/* Resize Handle */}
<div
className={cn(
"transition-all duration-200 cursor-ew-resize absolute h-full w-1 z-[20]",
!isResizing && "hover:bg-custom-background-90",
isResizing && "w-1.5 bg-custom-background-80",
"top-0 right-0"
)}
// onDoubleClick toggle sidebar
onDoubleClick={() => toggleCollapsed()}
onMouseDown={startResizing}
role="separator"
aria-label="Resize sidebar"
/>
</aside>
</div>
{/* Peek Trigger Area */}
{isCollapsed && !disablePeekTrigger && (
<div
className={cn(
"absolute top-0 left-0 w-1 h-full z-50 bg-transparent",
"transition-opacity duration-200",
isHoveringTrigger ? "opacity-100" : "opacity-0"
)}
onMouseEnter={handleTriggerEnter}
onMouseLeave={handleTriggerLeave}
role="button"
aria-label="Show sidebar peek"
/>
)}
{/* Peek View */}
<div
className={cn(
"absolute left-0 z-20 bg-custom-background-100 shadow-sm h-full",
!isResizing && "transition-all duration-300 ease-in-out",
isCollapsed && showPeek ? "translate-x-0 opacity-100" : "translate-x-[-100%] opacity-0",
"pointer-events-none",
isCollapsed && showPeek && "pointer-events-auto",
!showPeek ? "w-0" : "w-full"
)}
style={{
width: `${width}px`,
}}
onMouseEnter={handlePeekEnter}
onMouseLeave={handlePeekLeave}
role="complementary"
aria-label="Sidebar peek view"
>
<aside
className={cn(
"group/sidebar h-full w-full bg-custom-background-100 overflow-hidden relative flex flex-col z-20 pt-4",
"self-center border-r border-custom-sidebar-border-200 rounded-md rounded-tl-none rounded-bl-none",
isAnyExtendedSidebarExpanded && "rounded-none"
)}
>
{children}
{/* Resize Handle */}
<div
className={cn(
"transition-all duration-200 cursor-ew-resize absolute h-full w-1 z-[20]",
!isResizing && "hover:bg-custom-background-90",
isResizing && "bg-custom-background-80",
"top-0 right-0"
)}
// onDoubleClick toggle sidebar
onDoubleClick={() => toggleCollapsed()}
onMouseDown={startResizing}
role="separator"
aria-label="Resize sidebar"
/>
</aside>
</div>
{/* Extended Sidebar */}
{extendedSidebar && extendedSidebar}
</>
);
}

View file

@ -0,0 +1,158 @@
import Link from "next/link";
import { cn } from "@plane/utils";
// ============================================================================
// TYPES
// ============================================================================
interface AppSidebarItemData {
href?: string;
label?: string;
icon?: React.ReactNode;
isActive?: boolean;
onClick?: () => void;
disabled?: boolean;
}
interface AppSidebarItemProps {
variant?: "link" | "button";
item?: AppSidebarItemData;
}
interface AppSidebarItemLabelProps {
highlight?: boolean;
label?: string;
}
interface AppSidebarItemIconProps {
icon?: React.ReactNode;
highlight?: boolean;
}
interface AppSidebarLinkItemProps {
href?: string;
children: React.ReactNode;
className?: string;
}
interface AppSidebarButtonItemProps {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
className?: string;
}
// ============================================================================
// STYLES
// ============================================================================
const styles = {
base: "group flex flex-col gap-0.5 items-center justify-center text-custom-text-300",
icon: "flex items-center justify-center gap-2 size-8 rounded-md text-custom-text-300",
iconActive: "bg-custom-background-80 text-custom-text-200",
iconInactive: "group-hover:text-custom-text-200 group-hover:bg-custom-background-80",
label: "text-xs font-semibold",
labelActive: "text-custom-text-200",
labelInactive: "group-hover:text-custom-text-200 text-custom-text-300",
} as const;
// ============================================================================
// SUB-COMPONENTS
// ============================================================================
const AppSidebarItemLabel: React.FC<AppSidebarItemLabelProps> = ({ highlight = false, label }) => {
if (!label) return null;
return (
<span
className={cn(styles.label, {
[styles.labelActive]: highlight,
[styles.labelInactive]: !highlight,
})}
>
{label}
</span>
);
};
const AppSidebarItemIcon: React.FC<AppSidebarItemIconProps> = ({ icon, highlight }) => {
if (!icon) return null;
return (
<div
className={cn(styles.icon, {
[styles.iconActive]: highlight,
[styles.iconInactive]: !highlight,
})}
>
{icon}
</div>
);
};
const AppSidebarLinkItem: React.FC<AppSidebarLinkItemProps> = ({ href, children, className }) => {
if (!href) return null;
return (
<Link href={href} className={cn(styles.base, className)}>
{children}
</Link>
);
};
const AppSidebarButtonItem: React.FC<AppSidebarButtonItemProps> = ({
children,
onClick,
disabled = false,
className,
}) => (
<button className={cn(styles.base, className)} onClick={onClick} disabled={disabled} type="button">
{children}
</button>
);
// ============================================================================
// MAIN COMPONENT
// ============================================================================
type AppSidebarItemComponent = React.FC<AppSidebarItemProps> & {
Label: React.FC<AppSidebarItemLabelProps>;
Icon: React.FC<AppSidebarItemIconProps>;
Link: React.FC<AppSidebarLinkItemProps>;
Button: React.FC<AppSidebarButtonItemProps>;
};
const AppSidebarItem: AppSidebarItemComponent = ({ variant = "link", item }) => {
if (!item) return null;
const { icon, isActive, label, href, onClick, disabled } = item;
const commonItems = (
<>
<AppSidebarItemIcon icon={icon} highlight={isActive} />
<AppSidebarItemLabel highlight={isActive} label={label} />
</>
);
if (variant === "link") {
return <AppSidebarLinkItem href={href}>{commonItems}</AppSidebarLinkItem>;
}
return (
<AppSidebarButtonItem onClick={onClick} disabled={disabled}>
{commonItems}
</AppSidebarButtonItem>
);
};
// ============================================================================
// COMPOUND COMPONENT ASSIGNMENT
// ============================================================================
AppSidebarItem.Label = AppSidebarItemLabel;
AppSidebarItem.Icon = AppSidebarItemIcon;
AppSidebarItem.Link = AppSidebarLinkItem;
AppSidebarItem.Button = AppSidebarButtonItem;
export { AppSidebarItem };
export type { AppSidebarItemData, AppSidebarItemProps };

View file

@ -0,0 +1,23 @@
"use client";
import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react";
// hooks
import { useAppTheme } from "@/hooks/store";
export const AppSidebarToggleButton = observer(() => {
// store hooks
const { toggleSidebar, sidebarPeek, toggleSidebarPeek } = useAppTheme();
return (
<button
className="flex items-center justify-center size-6 rounded-md text-custom-text-400 hover:text-custom-primary-100 hover:bg-custom-background-90"
onClick={() => {
if (sidebarPeek) toggleSidebarPeek(false);
toggleSidebar();
}}
>
<PanelLeft className="size-4" />
</button>
);
});

View file

@ -12,11 +12,10 @@ import { useWorkspaceNotifications } from "@/hooks/store";
type TNotificationAppSidebarOption = {
workspaceSlug: string;
isSidebarCollapsed: boolean | undefined;
};
export const NotificationAppSidebarOption: FC<TNotificationAppSidebarOption> = observer((props) => {
const { workspaceSlug, isSidebarCollapsed } = props;
const { workspaceSlug } = props;
// hooks
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
@ -33,9 +32,6 @@ export const NotificationAppSidebarOption: FC<TNotificationAppSidebarOption> = o
if (totalNotifications <= 0) return <></>;
if (isSidebarCollapsed)
return <div className="absolute right-2 top-1.5 h-2 w-2 rounded-full bg-custom-primary-300" />;
return (
<div className="ml-auto">
<CountChip count={`${isMentionsEnabled ? `@ ` : ``}${getNumberCount(totalNotifications)}`} />

View file

@ -9,6 +9,8 @@ import { Breadcrumbs, Header } from "@plane/ui";
import { BreadcrumbLink } from "@/components/common";
import { SidebarHamburgerToggle } from "@/components/core";
import { NotificationSidebarHeaderOptions } from "@/components/workspace-notifications";
// hooks
import { useAppTheme } from "@/hooks/store";
type TNotificationSidebarHeader = {
workspaceSlug: string;
@ -17,14 +19,14 @@ type TNotificationSidebarHeader = {
export const NotificationSidebarHeader: FC<TNotificationSidebarHeader> = observer((props) => {
const { workspaceSlug } = props;
const { t } = useTranslation();
const { sidebarCollapsed } = useAppTheme();
if (!workspaceSlug) return <></>;
return (
<Header className="my-auto bg-custom-background-100">
<Header.LeftItem>
<div className="block bg-custom-sidebar-background-100 md:hidden">
<SidebarHamburgerToggle />
</div>
{sidebarCollapsed && <SidebarHamburgerToggle />}
<Breadcrumbs>
<Breadcrumbs.Item
component={

View file

@ -64,7 +64,7 @@ const SidebarDropdownItem = observer((props: TProps) => {
</span>
<div className="w-[inherit]">
<div
className={`truncate text-ellipsis text-sm font-medium ${workspaceSlug === workspace.slug ? "" : "text-custom-text-200"}`}
className={`truncate text-left text-ellipsis text-sm font-medium ${workspaceSlug === workspace.slug ? "" : "text-custom-text-200"}`}
>
{workspace.name}
</div>

View file

@ -1,258 +1,22 @@
"use client";
import { Fragment, Ref, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { usePopper } from "react-popper";
// icons
import { ChevronDown, CirclePlus, LogOut, Mails, Settings } from "lucide-react";
// ui
import { Menu, Transition } from "@headlessui/react";
// plane imports
import { GOD_MODE_URL } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IWorkspace } from "@plane/types";
import { Avatar, Loader, TOAST_TYPE, setToast } from "@plane/ui";
import { orderWorkspacesList, cn, getFileURL } from "@plane/utils";
// helpers
// hooks
import { useAppTheme, useUser, useUserProfile, useWorkspace } from "@/hooks/store";
// plane web helpers
import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper";
import { useAppRail } from "@/hooks/use-app-rail";
// components
import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item";
import { WorkspaceAppSwitcher } from "@/plane-web/components/workspace/app-switcher";
import { UserMenuRoot } from "./user-menu-root";
import { WorkspaceMenuRoot } from "./workspace-menu-root";
export const SidebarDropdown = observer(() => {
const { workspaceSlug } = useParams();
// store hooks
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
const { data: currentUser } = useUser();
const { signOut } = useUser();
const { updateUserProfile } = useUserProfile();
const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace();
// derived values
const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false;
const isUserInstanceAdmin = false;
// translation
const { t } = useTranslation();
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "right",
modifiers: [{ name: "preventOverflow", options: { padding: 12 } }],
});
// hooks
const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail();
const handleWorkspaceNavigation = (workspace: IWorkspace) => updateUserProfile({ last_workspace_id: workspace?.id });
const handleSignOut = async () => {
await signOut().catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: t("sign_out.toast.error.title"),
message: t("sign_out.toast.error.message"),
})
);
};
const handleItemClick = () => {
if (window.innerWidth < 768) {
toggleSidebar();
}
};
const workspacesList = orderWorkspacesList(Object.values(workspaces ?? {}));
// TODO: fix workspaces list scroll
return (
<div
className={cn("flex items-center justify-center gap-x-3 gap-y-2", {
"flex-col gap-y-3": sidebarCollapsed,
})}
>
<Menu
as="div"
className={cn("relative h-full truncate text-left flex-grow flex justify-stretch", {
"flex-grow-0 justify-center": sidebarCollapsed,
})}
>
{({ open, close }) => (
<>
<Menu.Button
className={cn(
"group/menu-button flex items-center justify-between gap-1 p-1 truncate rounded text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:outline-none",
{ "flex-grow": !sidebarCollapsed }
)}
aria-label={t("aria_labels.projects_sidebar.open_workspace_switcher")}
>
<div className="flex-grow flex items-center gap-2 truncate">
<WorkspaceLogo logo={activeWorkspace?.logo_url} name={activeWorkspace?.name} />
{!sidebarCollapsed && (
<h4 className="truncate text-base font-medium text-custom-text-100">
{activeWorkspace?.name ?? t("loading")}
</h4>
)}
</div>
{!sidebarCollapsed && (
<ChevronDown
className={cn(
"flex-shrink-0 mx-1 hidden size-4 group-hover/menu-button:block text-custom-sidebar-text-400 duration-300",
{ "rotate-180": open }
)}
/>
)}
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items as={Fragment}>
<div className="fixed top-12 left-4 z-[21] mt-1 flex w-full max-w-[19rem] origin-top-left flex-col divide-y divide-custom-border-100 rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg outline-none">
<div className="overflow-x-hidden vertical-scrollbar scrollbar-sm flex max-h-96 flex-col items-start justify-start overflow-y-scroll">
<span className="rounded-md px-4 sticky top-0 z-[21] h-full w-full bg-custom-sidebar-background-100 pb-1 pt-3 text-sm font-medium text-custom-text-400 truncate flex-shrink-0">
{currentUser?.email}
</span>
{workspacesList ? (
<div className="size-full flex flex-col items-start justify-start">
{(activeWorkspace
? [
activeWorkspace,
...workspacesList.filter((workspace) => workspace.id !== activeWorkspace?.id),
]
: workspacesList
).map((workspace) => (
<SidebarDropdownItem
key={workspace.id}
workspace={workspace}
activeWorkspace={activeWorkspace}
handleItemClick={handleItemClick}
handleWorkspaceNavigation={handleWorkspaceNavigation}
handleClose={close}
/>
))}
</div>
) : (
<div className="w-full">
<Loader className="space-y-2">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
</div>
)}
</div>
<div className="w-full flex flex-col items-start justify-start gap-2 px-4 py-2 text-sm">
{isWorkspaceCreationEnabled && (
<Link href="/create-workspace" className="w-full">
<Menu.Item
as="div"
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
>
<CirclePlus className="size-4 flex-shrink-0" />
{t("create_workspace")}
</Menu.Item>
</Link>
)}
<Link href="/invitations" className="w-full" onClick={handleItemClick}>
<Menu.Item
as="div"
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
>
<Mails className="h-4 w-4 flex-shrink-0" />
{t("workspace_invites")}
</Menu.Item>
</Link>
<div className="w-full">
<Menu.Item
as="button"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1 text-sm font-medium text-red-600 hover:bg-custom-sidebar-background-80"
onClick={handleSignOut}
>
<LogOut className="size-4 flex-shrink-0" />
{t("sign_out")}
</Menu.Item>
</div>
</div>
</div>
</Menu.Items>
</Transition>
</>
)}
</Menu>
<Menu as="div" className="relative flex-shrink-0">
<Menu.Button
className="grid place-items-center outline-none"
ref={setReferenceElement}
aria-label={t("aria_labels.projects_sidebar.open_user_menu")}
>
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={24}
shape="circle"
className="!text-base"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute left-0 z-[21] mt-1 flex w-52 origin-top-left flex-col divide-y
divide-custom-sidebar-border-200 rounded-md border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 text-xs shadow-lg outline-none"
ref={setPopperElement as Ref<HTMLDivElement>}
style={styles.popper}
{...attributes.popper}
>
<div className="flex flex-col gap-2.5 pb-2">
<span className="px-2 text-custom-sidebar-text-200">{currentUser?.email}</span>
<Link href={`/${workspaceSlug}/settings/account`}>
<Menu.Item as="div">
<span className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80">
<Settings className="h-4 w-4 stroke-[1.5]" />
<span>{t("settings")}</span>
</span>
</Menu.Item>
</Link>
</div>
<div className={`pt-2 ${isUserInstanceAdmin || false ? "pb-2" : ""}`}>
<Menu.Item
as="button"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
onClick={handleSignOut}
>
<LogOut className="size-4 stroke-[1.5]" />
{t("sign_out")}
</Menu.Item>
</div>
{isUserInstanceAdmin && (
<div className="p-2 pb-0">
<Link href={GOD_MODE_URL}>
<Menu.Item as="button" type="button" className="w-full">
<span className="flex w-full items-center justify-center rounded bg-custom-primary-100/20 px-2 py-1 text-sm font-medium text-custom-primary-100 hover:bg-custom-primary-100/30 hover:text-custom-primary-200">
{t("enter_god_mode")}
</span>
</Menu.Item>
</Link>
</div>
)}
</Menu.Items>
</Transition>
</Menu>
<div className="flex items-center justify-center gap-1.5 w-full">
<WorkspaceMenuRoot />
{isAppRailEnabled && !shouldRenderAppRail && <WorkspaceAppSwitcher />}
<UserMenuRoot />
</div>
);
});

View file

@ -25,7 +25,6 @@ import { CustomMenu, Tooltip, DropIndicator, FavoriteFolderIcon, DragHandle } fr
// helpers
import { cn } from "@plane/utils";
// hooks
import { useAppTheme } from "@/hooks/store";
import { useFavorite } from "@/hooks/store/use-favorite";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
@ -44,7 +43,6 @@ type Props = {
export const FavoriteFolder: React.FC<Props> = (props) => {
const { favorite, handleRemoveFromFavorites, isLastChild, handleDrop } = props;
// store hooks
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
const { getGroupedFavorites } = useFavorite();
const { isMobile } = usePlatformOS();
const { workspaceSlug } = useParams();
@ -159,7 +157,6 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
"group/project-item relative w-full px-2 py-1.5 flex items-center rounded-md text-custom-sidebar-text-100 hover:bg-custom-sidebar-background-90",
{
"bg-custom-sidebar-background-90": isMenuActive,
"p-0 size-8 aspect-square justify-center mx-auto": isSidebarCollapsed,
}
)}
>
@ -169,33 +166,13 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
<GripVertical className="w-3 h-3" />
</div>
{isSidebarCollapsed ? (
<div
className={cn("flex-grow flex items-center gap-1.5 truncate text-left select-none", {
"justify-center": isSidebarCollapsed,
})}
>
<Tooltip tooltipContent={favorite.name} position="right" isMobile={isMobile}>
<Disclosure.Button
as="button"
className="size-8 aspect-square flex-shrink-0 grid place-items-center"
>
<div className="size-4 grid place-items-center flex-shrink-0">
<FavoriteFolderIcon />
</div>
</Disclosure.Button>
</Tooltip>
</div>
) : (
<>
<Tooltip tooltipContent={`${favorite.name}`} position="right" className="ml-8" isMobile={isMobile}>
<div className="flex-grow flex truncate">
<Disclosure.Button
as="button"
type="button"
className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full", {
"justify-center": isSidebarCollapsed,
})}
className="flex-grow flex items-center gap-1.5 text-left select-none w-full"
>
<Tooltip
isMobile={isMobile}
@ -212,7 +189,6 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
{
"cursor-not-allowed opacity-60": favorite.sort_order === null,
"cursor-grabbing": isDragging,
"!hidden": isSidebarCollapsed,
}
)}
>
@ -279,7 +255,6 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
/>
</Disclosure.Button>
</>
)}
</div>
{favorite.children && favorite.children.length > 0 && (
<Transition
@ -290,12 +265,7 @@ export const FavoriteFolder: React.FC<Props> = (props) => {
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Disclosure.Panel
as="div"
className={cn("flex flex-col gap-0.5 mt-1", {
"px-2": !isSidebarCollapsed,
})}
>
<Disclosure.Panel as="div" className="flex flex-col gap-0.5 mt-1 px-2">
{orderBy(favorite.children, "sequence", "desc").map((child, index) => (
<FavoriteRoot
key={child.id}

View file

@ -3,7 +3,6 @@ import React, { FC } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { Tooltip } from "@plane/ui";
import { cn } from "@plane/utils";
import { useAppTheme } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
@ -11,28 +10,23 @@ type Props = {
href: string;
title: string;
icon: JSX.Element;
isSidebarCollapsed: boolean;
};
export const FavoriteItemTitle: FC<Props> = observer((props) => {
const { href, title, icon, isSidebarCollapsed } = props;
const { href, title, icon } = props;
// store hooks
const { toggleSidebar } = useAppTheme();
const { isMobile } = usePlatformOS();
const linkClass = "flex items-center gap-1.5 truncate w-full";
const collapsedClass =
"group/project-item cursor-pointer relative group w-full flex items-center justify-center gap-1.5 rounded px-2 py-1 outline-none text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 active:bg-custom-sidebar-background-90 truncate p-0 size-8 aspect-square mx-auto";
const handleOnClick = () => {
if (isMobile) toggleSidebar();
};
return (
<Tooltip tooltipContent={title} isMobile={isMobile} position="right" className={cn(!isSidebarCollapsed && "ml-8")}>
<Link href={href} className={isSidebarCollapsed ? collapsedClass : linkClass} draggable onClick={handleOnClick}>
<Tooltip tooltipContent={title} isMobile={isMobile} position="right" className="ml-8">
<Link href={href} className="flex items-center gap-1.5 truncate w-full" draggable onClick={handleOnClick}>
<span className="flex items-center justify-center size-5">{icon}</span>
{!isSidebarCollapsed && <span className="text-sm leading-5 font-medium flex-1 truncate">{title}</span>}
<span className="text-sm leading-5 font-medium flex-1 truncate">{title}</span>
</Link>
</Tooltip>
);

View file

@ -7,16 +7,12 @@ type Props = {
children: React.ReactNode;
elementRef: React.RefObject<HTMLDivElement>;
isMenuActive?: boolean;
sidebarCollapsed?: boolean;
};
export const FavoriteItemWrapper: FC<Props> = (props) => {
const { children, elementRef, isMenuActive = false, sidebarCollapsed = false } = props;
const { children, elementRef, isMenuActive = false } = props;
return (
<>
{sidebarCollapsed ? (
<div ref={elementRef}>{children}</div>
) : (
<div
ref={elementRef}
className={cn(
@ -28,7 +24,6 @@ export const FavoriteItemWrapper: FC<Props> = (props) => {
>
{children}
</div>
)}
</>
);
};

View file

@ -27,7 +27,6 @@ import {
FavoriteItemTitle,
} from "@/components/workspace/sidebar/favorites";
// hooks
import { useAppTheme } from "@/hooks/store";
import { useFavoriteItemDetails } from "@/hooks/use-favorite-item-details";
//helpers
import { getCanDrop, getInstructionFromPayload } from "../favorites.helpers";
@ -45,7 +44,6 @@ export const FavoriteRoot: FC<Props> = observer((props) => {
// props
const { isLastChild, parentId, workspaceSlug, favorite, handleRemoveFromFavorites, handleDrop } = props;
// store hooks
const { sidebarCollapsed } = useAppTheme();
const { itemLink, itemIcon, itemTitle } = useFavoriteItemDetails(workspaceSlug, favorite);
//state
const [isDragging, setIsDragging] = useState(false);
@ -82,12 +80,7 @@ export const FavoriteRoot: FC<Props> = observer((props) => {
const root = createRoot(container);
root.render(
<div className="rounded bg-custom-background-100 text-sm p-1 pr-2">
<FavoriteItemTitle
href={itemLink}
icon={itemIcon}
title={itemTitle}
isSidebarCollapsed={!!sidebarCollapsed}
/>
<FavoriteItemTitle href={itemLink} icon={itemIcon} title={itemTitle} />
</div>
);
return () => root.unmount();
@ -138,10 +131,9 @@ export const FavoriteRoot: FC<Props> = observer((props) => {
return (
<>
<DropIndicator isVisible={instruction === "reorder-above"} />
<FavoriteItemWrapper elementRef={elementRef} isMenuActive={isMenuActive} sidebarCollapsed={sidebarCollapsed}>
{!sidebarCollapsed && <FavoriteItemDragHandle isDragging={isDragging} sort_order={favorite.sort_order} />}
<FavoriteItemTitle href={itemLink} icon={itemIcon} title={itemTitle} isSidebarCollapsed={!!sidebarCollapsed} />
{!sidebarCollapsed && (
<FavoriteItemWrapper elementRef={elementRef} isMenuActive={isMenuActive}>
<FavoriteItemDragHandle isDragging={isDragging} sort_order={favorite.sort_order} />
<FavoriteItemTitle href={itemLink} icon={itemIcon} title={itemTitle} />
<FavoriteItemQuickAction
favorite={favorite}
ref={actionSectionRef}
@ -149,7 +141,6 @@ export const FavoriteRoot: FC<Props> = observer((props) => {
onChange={handleQuickAction}
handleRemoveFromFavorites={handleRemoveFromFavorites}
/>
)}
</FavoriteItemWrapper>
{isLastChild && <DropIndicator isVisible={instruction === "reorder-below"} />}
</>

View file

@ -23,10 +23,8 @@ import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
// hooks
import { useAppTheme } from "@/hooks/store";
import { useFavorite } from "@/hooks/store/use-favorite";
import useLocalStorage from "@/hooks/use-local-storage";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { FavoriteFolder } from "./favorite-folder";
import { FavoriteRoot } from "./favorite-items";
@ -40,19 +38,10 @@ export const SidebarFavoritesMenu = observer(() => {
// navigation
const { workspaceSlug } = useParams();
// store hooks
const { sidebarCollapsed } = useAppTheme();
const {
favoriteIds,
groupedFavorites,
deleteFavorite,
removeFromFavoriteFolder,
reOrderFavorite,
moveFavoriteToFolder,
} = useFavorite();
const { groupedFavorites, deleteFavorite, removeFromFavoriteFolder, reOrderFavorite, moveFavoriteToFolder } =
useFavorite();
// translation
const { t } = useTranslation();
// platform hooks
const { isMobile } = usePlatformOS();
// local storage
const { setValue: toggleFavoriteMenu, storedValue } = useLocalStorage<boolean>(IS_FAVORITE_MENU_OPEN, false);
// derived values
@ -154,10 +143,6 @@ export const SidebarFavoritesMenu = observer(() => {
[workspaceSlug, reOrderFavorite, t]
);
useEffect(() => {
if (sidebarCollapsed) toggleFavoriteMenu(true);
}, [sidebarCollapsed, toggleFavoriteMenu]);
useEffect(() => {
const element = elementRef.current;
@ -189,15 +174,10 @@ export const SidebarFavoritesMenu = observer(() => {
return (
<>
<Disclosure as="div" defaultOpen ref={containerRef}>
{!sidebarCollapsed && (
<div
ref={elementRef}
className={cn(
"group/favorites-button w-full flex items-center justify-between px-2 py-1.5 rounded text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90",
{
"p-0 justify-center w-fit mx-auto bg-custom-sidebar-background-90 hover:bg-custom-sidebar-background-80":
sidebarCollapsed,
}
"group/favorites-button w-full flex items-center justify-between px-2 py-1.5 rounded text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90"
)}
>
<Disclosure.Button
@ -206,7 +186,6 @@ export const SidebarFavoritesMenu = observer(() => {
className={cn(
"w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400",
{
"!text-center w-8 px-2 py-1.5 justify-center": sidebarCollapsed,
"bg-custom-sidebar-background-80 opacity-60": isDragging,
}
)}
@ -252,7 +231,6 @@ export const SidebarFavoritesMenu = observer(() => {
</Disclosure.Button>
</div>
</div>
)}
<Transition
show={isFavoriteMenuOpen}
enter="transition duration-100 ease-out"
@ -263,36 +241,17 @@ export const SidebarFavoritesMenu = observer(() => {
leaveTo="transform scale-95 opacity-0"
>
{isFavoriteMenuOpen && (
<Disclosure.Panel
as="div"
className={cn("flex flex-col mt-0.5 gap-0.5", {
"space-y-0 mt-0 ml-0": sidebarCollapsed,
})}
static
>
<Disclosure.Panel as="div" className="flex flex-col mt-0.5 gap-0.5" static>
{createNewFolder && <NewFavoriteFolder setCreateNewFolder={setCreateNewFolder} actionType="create" />}
{Object.keys(groupedFavorites).length === 0 ? (
<>
{!sidebarCollapsed && (
<span className="text-custom-text-400 text-xs font-medium px-8 py-1.5">
{t("no_favorites_yet")}
</span>
)}
<span className="text-custom-text-400 text-xs font-medium px-8 py-1.5">{t("no_favorites_yet")}</span>
</>
) : (
orderBy(Object.values(groupedFavorites), "sequence", "desc")
.filter((fav) => !fav.parent)
.map((fav, index, { length }) => (
<>
{fav?.id && (
<Tooltip
key={fav?.id}
tooltipContent={fav?.entity_data ? fav?.entity_data?.name : fav?.name}
position="right"
className="ml-2"
disabled={!sidebarCollapsed}
isMobile={isMobile}
>
{fav?.is_folder ? (
<FavoriteFolder
favorite={fav}
@ -311,8 +270,6 @@ export const SidebarFavoritesMenu = observer(() => {
handleDrop={handleDrop}
/>
)}
</Tooltip>
)}
</>
))
)}
@ -320,10 +277,6 @@ export const SidebarFavoritesMenu = observer(() => {
)}
</Transition>
</Disclosure>
{sidebarCollapsed && favoriteIds.length > 0 && (
<hr className="flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1" />
)}
</>
);
});

View file

@ -0,0 +1,149 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { FileText, HelpCircle, MessagesSquare, User } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// ui
import { CustomMenu, Tooltip, ToggleSwitch } from "@plane/ui";
// components
import { cn } from "@plane/utils";
import { ProductUpdatesModal } from "@/components/global";
// helpers
// hooks
import { useCommandPalette, useInstance, useTransient, useUserSettings } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { PlaneVersionNumber } from "@/plane-web/components/global";
export interface WorkspaceHelpSectionProps {
setSidebarActive?: React.Dispatch<React.SetStateAction<boolean>>;
}
export const HelpMenu: React.FC<WorkspaceHelpSectionProps> = observer(() => {
const { workspaceSlug, projectId } = useParams();
// store hooks
const { t } = useTranslation();
const { toggleShortcutModal } = useCommandPalette();
const { isMobile } = usePlatformOS();
const { config } = useInstance();
const { isIntercomToggle, toggleIntercom } = useTransient();
const { canUseLocalDB, toggleLocalDB } = useUserSettings();
// states
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
const [isProductUpdatesModalOpen, setProductUpdatesModalOpen] = useState(false);
const handleCrispWindowShow = () => {
toggleIntercom(!isIntercomToggle);
};
return (
<>
<ProductUpdatesModal isOpen={isProductUpdatesModalOpen} handleClose={() => setProductUpdatesModalOpen(false)} />
<div className="relative flex flex-shrink-0 items-center gap-1 justify-evenly">
<CustomMenu
customButton={
<div
className={cn(
"grid place-items-center rounded-md p-1 outline-none text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90",
{
"bg-custom-background-90": isNeedHelpOpen,
}
)}
>
<Tooltip tooltipContent="Help" isMobile={isMobile} disabled={isNeedHelpOpen}>
<HelpCircle className="h-[18px] w-[18px] outline-none" />
</Tooltip>
</div>
}
customButtonClassName="relative grid place-items-center rounded-md p-1.5 outline-none"
menuButtonOnClick={() => !isNeedHelpOpen && setIsNeedHelpOpen(true)}
onMenuClose={() => setIsNeedHelpOpen(false)}
placement="top-end"
maxHeight="lg"
closeOnSelect
>
<CustomMenu.MenuItem>
<a
href="https://go.plane.so/p-docs"
target="_blank"
className="flex items-center justify- gap-x-2 rounded text-xs hover:bg-custom-background-80"
>
<FileText className="h-3.5 w-3.5 text-custom-text-200" size={14} />
<span className="text-xs">{t("documentation")}</span>
</a>
</CustomMenu.MenuItem>
{config?.intercom_app_id && config?.is_intercom_enabled && (
<CustomMenu.MenuItem>
<button
type="button"
onClick={handleCrispWindowShow}
className="flex w-full items-center gap-x-2 rounded text-xs hover:bg-custom-background-80"
>
<MessagesSquare className="h-3.5 w-3.5 text-custom-text-200" />
<span className="text-xs">{t("message_support")}</span>
</button>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem>
<a
href="mailto:sales@plane.so"
target="_blank"
className="flex items-center justify- gap-x-2 rounded text-xs hover:bg-custom-background-80"
>
<User className="h-3.5 w-3.5 text-custom-text-200" size={14} />
<span className="text-xs">{t("contact_sales")}</span>
</a>
</CustomMenu.MenuItem>
<div className="my-1 border-t border-custom-border-200" />
<CustomMenu.MenuItem>
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className="flex w-full items-center justify-between text-xs hover:bg-custom-background-80"
>
<span className="racking-tight">{t("hyper_mode")}</span>
<ToggleSwitch
value={canUseLocalDB}
onChange={() => toggleLocalDB(workspaceSlug?.toString(), projectId?.toString())}
/>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>
<button
type="button"
onClick={() => toggleShortcutModal(true)}
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
>
<span className="text-xs">{t("keyboard_shortcuts")}</span>
</button>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>
<button
type="button"
onClick={() => setProductUpdatesModalOpen(true)}
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
>
<span className="text-xs">{t("whats_new")}</span>
</button>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>
<a
href="https://go.plane.so/p-discord"
target="_blank"
className="flex items-center justify- gap-x-2 rounded text-xs hover:bg-custom-background-80"
>
<span className="text-xs">Discord</span>
</a>
</CustomMenu.MenuItem>
<div className="px-1 pt-2 mt-1 text-xs text-custom-text-200 border-t border-custom-border-200">
<PlaneVersionNumber />
</div>
</CustomMenu>
</div>
</>
);
});

View file

@ -26,7 +26,7 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
const { workspaceSlug, projectId } = useParams();
// store hooks
const { t } = useTranslation();
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
const { sidebarCollapsed: isCollapsed, toggleSidebar, sidebarPeek, toggleSidebarPeek } = useAppTheme();
const { toggleShortcutModal } = useCommandPalette();
const { isMobile } = usePlatformOS();
const { config } = useInstance();
@ -40,22 +40,11 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
toggleIntercom(!isIntercomToggle);
};
const isCollapsed = sidebarCollapsed || false;
return (
<>
<ProductUpdatesModal isOpen={isProductUpdatesModalOpen} handleClose={() => setProductUpdatesModalOpen(false)} />
<div
className={cn(
"flex w-full items-center justify-between px-2 self-baseline border-t border-custom-border-200 bg-custom-sidebar-background-100 h-12 flex-shrink-0",
{
"flex-col h-auto py-1.5": isCollapsed,
}
)}
>
<div
className={`relative flex flex-shrink-0 items-center gap-1 ${isCollapsed ? "flex-col justify-center" : "justify-evenly"}`}
>
<div className="flex w-full items-center justify-between px-2 self-baseline border-t border-custom-border-200 bg-custom-sidebar-background-100 h-12 flex-shrink-0">
<div className="relative flex flex-shrink-0 items-center gap-1 justify-evenly">
<CustomMenu
customButton={
<div
@ -71,10 +60,10 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
</Tooltip>
</div>
}
customButtonClassName={`relative grid place-items-center rounded-md p-1.5 outline-none ${isCollapsed ? "w-full" : ""}`}
customButtonClassName="relative grid place-items-center rounded-md p-1.5 outline-none"
menuButtonOnClick={() => !isNeedHelpOpen && setIsNeedHelpOpen(true)}
onMenuClose={() => setIsNeedHelpOpen(false)}
placement={isCollapsed ? "left-end" : "top-end"}
placement="top-end"
maxHeight="lg"
closeOnSelect
>
@ -158,23 +147,18 @@ export const SidebarHelpSection: React.FC<WorkspaceHelpSectionProps> = observer(
</div>
</CustomMenu>
</div>
<div
className={cn("w-full flex-grow px-0.5", {
hidden: isCollapsed,
})}
>
<div className="w-full flex-grow px-0.5">
<WorkspaceEditionBadge />
</div>
<div
className={`flex flex-shrink-0 items-center gap-1 ${isCollapsed ? "flex-col justify-center" : "justify-evenly"}`}
>
<div className="flex flex-shrink-0 items-center gap-1 justify-evenly">
<Tooltip tooltipContent={`${isCollapsed ? "Expand" : "Hide"}`} isMobile={isMobile}>
<button
type="button"
className={`grid place-items-center rounded-md p-1 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 ${
isCollapsed ? "w-full" : ""
}`}
onClick={() => toggleSidebar()}
className="grid place-items-center rounded-md p-1 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100"
onClick={() => {
if (sidebarPeek) toggleSidebarPeek(false);
toggleSidebar();
}}
aria-label={t(
isCollapsed
? "aria_labels.projects_sidebar.expand_sidebar"

View file

@ -0,0 +1 @@
export * from "./root";

View file

@ -0,0 +1,136 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { FileText, HelpCircle, MessagesSquare, User } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// ui
import { CustomMenu, ToggleSwitch } from "@plane/ui";
// components
import { ProductUpdatesModal } from "@/components/global";
import { AppSidebarItem } from "@/components/sidebar";
// hooks
import { useCommandPalette, useInstance, useTransient, useUserSettings } from "@/hooks/store";
// plane web components
import { PlaneVersionNumber } from "@/plane-web/components/global";
export const HelpMenuRoot = observer(() => {
const { workspaceSlug, projectId } = useParams();
// store hooks
const { t } = useTranslation();
const { toggleShortcutModal } = useCommandPalette();
const { config } = useInstance();
const { isIntercomToggle, toggleIntercom } = useTransient();
const { canUseLocalDB, toggleLocalDB } = useUserSettings();
// states
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
const [isProductUpdatesModalOpen, setProductUpdatesModalOpen] = useState(false);
const handleCrispWindowShow = () => {
toggleIntercom(!isIntercomToggle);
};
return (
<>
<ProductUpdatesModal isOpen={isProductUpdatesModalOpen} handleClose={() => setProductUpdatesModalOpen(false)} />
<CustomMenu
customButton={
<AppSidebarItem
variant="button"
item={{
icon: <HelpCircle className="size-5" />,
isActive: isNeedHelpOpen,
}}
/>
}
// customButtonClassName="relative grid place-items-center rounded-md p-1.5 outline-none"
menuButtonOnClick={() => !isNeedHelpOpen && setIsNeedHelpOpen(true)}
onMenuClose={() => setIsNeedHelpOpen(false)}
placement="top-end"
maxHeight="lg"
closeOnSelect
>
<CustomMenu.MenuItem>
<a
href="https://go.plane.so/p-docs"
target="_blank"
className="flex items-center justify- gap-x-2 rounded text-xs hover:bg-custom-background-80"
>
<FileText className="h-3.5 w-3.5 text-custom-text-200" size={14} />
<span className="text-xs">{t("documentation")}</span>
</a>
</CustomMenu.MenuItem>
{config?.intercom_app_id && config?.is_intercom_enabled && (
<CustomMenu.MenuItem>
<button
type="button"
onClick={handleCrispWindowShow}
className="flex w-full items-center gap-x-2 rounded text-xs hover:bg-custom-background-80"
>
<MessagesSquare className="h-3.5 w-3.5 text-custom-text-200" />
<span className="text-xs">{t("message_support")}</span>
</button>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem>
<a
href="mailto:sales@plane.so"
target="_blank"
className="flex items-center justify- gap-x-2 rounded text-xs hover:bg-custom-background-80"
>
<User className="h-3.5 w-3.5 text-custom-text-200" size={14} />
<span className="text-xs">{t("contact_sales")}</span>
</a>
</CustomMenu.MenuItem>
<div className="my-1 border-t border-custom-border-200" />
<CustomMenu.MenuItem>
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
className="flex w-full items-center justify-between text-xs hover:bg-custom-background-80"
>
<span className="racking-tight">{t("hyper_mode")}</span>
<ToggleSwitch
value={canUseLocalDB}
onChange={() => toggleLocalDB(workspaceSlug?.toString(), projectId?.toString())}
/>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>
<button
type="button"
onClick={() => toggleShortcutModal(true)}
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
>
<span className="text-xs">{t("keyboard_shortcuts")}</span>
</button>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>
<button
type="button"
onClick={() => setProductUpdatesModalOpen(true)}
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
>
<span className="text-xs">{t("whats_new")}</span>
</button>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem>
<a
href="https://go.plane.so/p-discord"
target="_blank"
className="flex items-center justify- gap-x-2 rounded text-xs hover:bg-custom-background-80"
>
<span className="text-xs">Discord</span>
</a>
</CustomMenu.MenuItem>
<div className="px-1 pt-2 mt-1 text-xs text-custom-text-200 border-t border-custom-border-200">
<PlaneVersionNumber />
</div>
</CustomMenu>
</>
);
});

View file

@ -10,3 +10,4 @@ export * from "./user-menu-item";
export * from "./workspace-menu";
export * from "./workspace-menu-item";
export * from "./workspace-menu-header";
export * from "./help-section";

View file

@ -9,13 +9,11 @@ import { EUserPermissionsLevel, EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EUserProjectRoles } from "@plane/types";
// plane ui
import { Tooltip, DiceIcon, ContrastIcon, LayersIcon, Intake } from "@plane/ui";
import { DiceIcon, ContrastIcon, LayersIcon, Intake } from "@plane/ui";
// components
import { SidebarNavItem } from "@/components/sidebar";
// hooks
import { useAppTheme, useIssueDetail, useProject, useUserPermissions } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane-web constants
export type TNavigationItem = {
name: string;
@ -32,17 +30,15 @@ type TProjectItemsProps = {
workspaceSlug: string;
projectId: string;
additionalNavigationItems?: (workspaceSlug: string, projectId: string) => TNavigationItem[];
isSidebarCollapsed: boolean;
};
export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
const { workspaceSlug, projectId, additionalNavigationItems, isSidebarCollapsed } = props;
const { workspaceSlug, projectId, additionalNavigationItems } = props;
const { workItem: workItemIdentifierFromRoute } = useParams();
// store hooks
const { t } = useTranslation();
const { toggleSidebar } = useAppTheme();
const { getPartialProjectById } = useProject();
const { isMobile } = usePlatformOS();
const { allowPermissions } = useUserPermissions();
const {
issue: { getIssueIdByIdentifier, getIssueById },
@ -176,28 +172,14 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
if (!hasAccess) return null;
return (
<Tooltip
key={item.name}
isMobile={isMobile}
tooltipContent={`${project?.name}: ${t(item.i18n_key)}`}
position="right"
className="ml-2"
disabled={!isSidebarCollapsed}
>
<Link href={item.href} onClick={handleProjectClick}>
<SidebarNavItem
className={`pl-[18px] ${isSidebarCollapsed ? "p-0 size-7 justify-center mx-auto" : ""}`}
isActive={!!isActive(item)}
>
<Link key={item.key} href={item.href} onClick={handleProjectClick}>
<SidebarNavItem className="pl-[18px]" isActive={!!isActive(item)}>
<div className="flex items-center gap-1.5 py-[1px]">
<item.icon
className={`flex-shrink-0 size-4 ${item.name === "Intake" ? "stroke-1" : "stroke-[1.5]"}`}
/>
{!isSidebarCollapsed && <span className="text-xs font-medium">{t(item.i18n_key)}</span>}
<item.icon className={`flex-shrink-0 size-4 ${item.name === "Intake" ? "stroke-1" : "stroke-[1.5]"}`} />
<span className="text-xs font-medium">{t(item.i18n_key)}</span>
</div>
</SidebarNavItem>
</Link>
</Tooltip>
);
})}
</>

View file

@ -57,12 +57,13 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
renderInExtendedSidebar = false,
} = props;
// store hooks
const { sidebarCollapsed } = useAppTheme();
const { t } = useTranslation();
const { getPartialProjectById } = useProject();
const { isMobile } = usePlatformOS();
const { allowPermissions } = useUserPermissions();
const { getIsProjectListOpen, toggleProjectListOpen } = useCommandPalette();
const { toggleAnySidebarDropdown } = useAppTheme();
// states
const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false);
const [publishModalOpen, setPublishModal] = useState(false);
@ -99,8 +100,6 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
setLeaveProjectModal(true);
};
const isSidebarCollapsed = sidebarCollapsed && !renderInExtendedSidebar;
useEffect(() => {
const element = projectRef.current;
const dragHandleElement = dragHandleRef.current;
@ -110,7 +109,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
return combine(
draggable({
element,
canDrag: () => !disableDrag && !isSidebarCollapsed,
canDrag: () => !disableDrag,
dragHandle: dragHandleElement ?? undefined,
getInitialData: () => ({ id: projectId, dragInstanceId: "PROJECTS" }),
onDragStart: () => {
@ -190,6 +189,11 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
);
}, [projectId, isLastChild, projectListType, handleOnProjectDrop]);
useEffect(() => {
if (isMenuActive) toggleAnySidebarDropdown(true);
else toggleAnySidebarDropdown(false);
}, [isMenuActive]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
useOutsideClickDetector(projectRef, () => projectRef?.current?.classList?.remove(HIGHLIGHT_CLASS));
@ -218,7 +222,6 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
"group/project-item relative w-full px-2 py-1.5 flex items-center rounded-md text-custom-sidebar-text-100 hover:bg-custom-sidebar-background-90",
{
"bg-custom-sidebar-background-90": isMenuActive,
"p-0 size-8 aspect-square justify-center mx-auto": isSidebarCollapsed,
}
)}
id={`${project?.id}`}
@ -240,7 +243,6 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
"cursor-not-allowed opacity-60": project.sort_order === null,
"cursor-grabbing": isDragging,
flex: isMenuActive || renderInExtendedSidebar,
"!hidden": isSidebarCollapsed,
}
)}
ref={dragHandleRef}
@ -249,28 +251,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
</button>
</Tooltip>
)}
{isSidebarCollapsed ? (
<ControlLink
href={`/${workspaceSlug}/projects/${project.id}/issues`}
className={cn("flex-grow flex items-center gap-1.5 truncate text-left select-none", {
"justify-center": isSidebarCollapsed,
})}
onClick={handleItemClick}
>
<Disclosure.Button as="button" className="size-8 aspect-square flex-shrink-0 grid place-items-center">
<div className="size-4 grid place-items-center flex-shrink-0">
<Logo logo={project.logo_props} size={16} />
</div>
</Disclosure.Button>
</ControlLink>
) : (
<>
<Tooltip
tooltipContent={`${project.name}`}
position="right"
disabled={!isSidebarCollapsed}
isMobile={isMobile}
>
<ControlLink
href={`/${workspaceSlug}/projects/${project.id}/issues`}
className="flex-grow flex truncate"
@ -279,9 +260,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
<Disclosure.Button
as="button"
type="button"
className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full", {
"justify-center": isSidebarCollapsed,
})}
className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full", {})}
aria-label={
isProjectListOpen
? t("aria_labels.projects_sidebar.close_project_menu")
@ -294,7 +273,6 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{project.name}</p>
</Disclosure.Button>
</ControlLink>
</Tooltip>
<CustomMenu
customButton={
<span
@ -316,6 +294,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
useCaptureForOutsideClick
closeOnSelect
onMenuClose={() => setIsMenuActive(false)}
>
{/* TODO: Removed is_favorite logic due to the optimization in projects API */}
{/* {isAuthorized && (
@ -408,7 +387,6 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
/>
</Disclosure.Button>
</>
)}
</div>
<Transition
show={isProjectListOpen}
@ -421,11 +399,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
>
{isProjectListOpen && (
<Disclosure.Panel as="div" className="flex flex-col gap-0.5 mt-1">
<ProjectNavigationRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isSidebarCollapsed={!!isSidebarCollapsed}
/>
<ProjectNavigationRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
</Disclosure.Panel>
)}
</Transition>

View file

@ -5,7 +5,7 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { Briefcase, ChevronRight, Plus } from "lucide-react";
import { ChevronRight, Plus } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
@ -17,7 +17,7 @@ import { CreateProjectModal } from "@/components/project";
import { SidebarProjectsListItem } from "@/components/workspace";
// helpers
// hooks
import { useAppTheme, useCommandPalette, useProject, useUserPermissions } from "@/hooks/store";
import { useCommandPalette, useProject, useUserPermissions } from "@/hooks/store";
// plane web types
import { TProject } from "@/plane-web/types";
@ -32,7 +32,6 @@ export const SidebarProjectsList: FC = observer(() => {
// store hooks
const { t } = useTranslation();
const { toggleCreateProjectModal } = useCommandPalette();
const { sidebarCollapsed } = useAppTheme();
const { allowPermissions } = useUserPermissions();
const { loader, getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject();
@ -86,8 +85,6 @@ export const SidebarProjectsList: FC = observer(() => {
});
};
const isCollapsed = sidebarCollapsed || false;
/**
* Implementing scroll animation styles based on the scroll length of the container
*/
@ -151,24 +148,11 @@ export const SidebarProjectsList: FC = observer(() => {
>
<>
<Disclosure as="div" className="flex flex-col" defaultOpen={isAllProjectsListOpen}>
<div
className={cn(
"group w-full flex items-center justify-between px-2 py-1.5 rounded text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90",
{
"p-0 justify-center w-fit mx-auto bg-custom-sidebar-background-90 hover:bg-custom-sidebar-background-80":
isCollapsed,
}
)}
>
<div className="group w-full flex items-center justify-between px-2 py-1.5 rounded text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90">
<Disclosure.Button
as="button"
type="button"
className={cn(
"w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400",
{
"!text-center w-8 px-2 py-1.5 justify-center": isCollapsed,
}
)}
className="w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400"
onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
aria-label={t(
isAllProjectsListOpen
@ -176,17 +160,8 @@ export const SidebarProjectsList: FC = observer(() => {
: "aria_labels.projects_sidebar.open_projects_menu"
)}
>
<Tooltip tooltipHeading={t("projects")} tooltipContent="" position="right" disabled={!isCollapsed}>
<>
{isCollapsed ? (
<Briefcase className="flex-shrink-0 size-3" />
) : (
<span className="text-sm font-semibold">{t("projects")}</span>
)}
</>
</Tooltip>
</Disclosure.Button>
{!isCollapsed && (
<div className="flex items-center opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
{isAuthorizedUser && (
<Tooltip tooltipHeading={t("create_project")} tooltipContent="">
@ -221,7 +196,6 @@ export const SidebarProjectsList: FC = observer(() => {
/>
</Disclosure.Button>
</div>
)}
</div>
<Transition
show={isAllProjectsListOpen}
@ -240,13 +214,7 @@ export const SidebarProjectsList: FC = observer(() => {
</Loader>
)}
{isAllProjectsListOpen && (
<Disclosure.Panel
as="div"
className={cn("flex flex-col gap-0.5", {
"space-y-0 ml-0": isCollapsed,
})}
static
>
<Disclosure.Panel as="div" className="flex flex-col gap-0.5" static>
<>
{joinedProjects.map((projectId, index) => (
<SidebarProjectsListItem
@ -270,18 +238,13 @@ export const SidebarProjectsList: FC = observer(() => {
{isAuthorizedUser && joinedProjects?.length === 0 && (
<button
type="button"
className={cn(
`w-full flex items-center gap-1.5 px-2 py-1.5 text-sm leading-5 font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 rounded-md`,
{
"p-0 size-8 aspect-square justify-center mx-auto": sidebarCollapsed,
}
)}
data-ph-element={PROJECT_TRACKER_ELEMENTS.SIDEBAR_CREATE_PROJECT_BUTTON}
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-sm leading-5 font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 rounded-md"
onClick={() => {
toggleCreateProjectModal(true);
}}
>
{!isCollapsed && t("add_project")}
{t("add_project")}
</button>
)}
</div>

View file

@ -12,7 +12,7 @@ import { CreateUpdateIssueModal } from "@/components/issues";
// constants
// helpers
// hooks
import { useAppTheme, useCommandPalette, useProject, useUserPermissions } from "@/hooks/store";
import { useCommandPalette, useProject, useUserPermissions } from "@/hooks/store";
import useLocalStorage from "@/hooks/use-local-storage";
// plane web components
import { AppSearch } from "@/plane-web/components/workspace";
@ -30,7 +30,6 @@ export const SidebarQuickActions = observer(() => {
const workspaceSlug = routerWorkspaceSlug?.toString();
// store hooks
const { toggleCreateIssueModal } = useCommandPalette();
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
const { joinedProjectIds } = useProject();
const { allowPermissions } = useUserPermissions();
// local storage
@ -73,19 +72,13 @@ export const SidebarQuickActions = observer(() => {
onSubmit={() => removeWorkspaceDraftIssue()}
isDraft
/>
<div
className={cn("flex items-center justify-between gap-1 cursor-pointer", {
"flex-col gap-0": isSidebarCollapsed,
})}
>
<div className={cn("flex items-center justify-between gap-1 cursor-pointer", {})}>
<button
type="button"
className={cn(
"relative flex flex-shrink-0 flex-grow items-center gap-2 h-8 text-custom-sidebar-text-300 rounded outline-none hover:bg-custom-sidebar-background-90",
"relative flex flex-shrink-0 flex-grow items-center gap-2 h-8 text-custom-sidebar-text-300 rounded outline-none hover:bg-custom-sidebar-background-90 px-3 border-[0.5px] border-custom-sidebar-border-300",
{
"justify-center size-8 aspect-square": isSidebarCollapsed,
"cursor-not-allowed opacity-50 ": disabled,
"px-3 border-[0.5px] border-custom-sidebar-border-300": !isSidebarCollapsed,
}
)}
data-ph-element={SIDEBAR_TRACKER_ELEMENTS.CREATE_WORK_ITEM_BUTTON}
@ -97,9 +90,7 @@ export const SidebarQuickActions = observer(() => {
disabled={disabled}
>
<PenSquare className="size-4" />
{!isSidebarCollapsed && (
<span className="text-sm font-medium truncate max-w-[145px]">{t("sidebar.new_work_item")}</span>
)}
</button>
<AppSearch />
</div>

View file

@ -2,11 +2,13 @@
import React, { useMemo } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Ellipsis } from "lucide-react";
import { ChevronRight, Ellipsis } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// plane imports
import {
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS,
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS,
WORKSPACE_SIDEBAR_STATIC_PINNED_NAVIGATION_ITEMS_LINKS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
@ -14,20 +16,30 @@ import { cn } from "@plane/utils";
import { SidebarNavItem } from "@/components/sidebar";
// store hooks
import { useAppTheme, useWorkspace } from "@/hooks/store";
import useLocalStorage from "@/hooks/use-local-storage";
// plane-web imports
import { SidebarItem } from "@/plane-web/components/workspace/sidebar";
export const SidebarMenuItems = observer(() => {
// routers
const { workspaceSlug } = useParams();
const { setValue: toggleWorkspaceMenu, storedValue: isWorkspaceMenuOpen } = useLocalStorage<boolean>(
"is_workspace_menu_open",
true
);
// store hooks
const { sidebarCollapsed, extendedSidebarCollapsed, toggleExtendedSidebar } = useAppTheme();
const { isExtendedSidebarOpened, toggleExtendedSidebar } = useAppTheme();
const { getNavigationPreferences } = useWorkspace();
// translation
const { t } = useTranslation();
// derived values
const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString());
const toggleListDisclosure = (isOpen: boolean) => {
toggleWorkspaceMenu(isOpen);
};
const sortedNavigationItems = useMemo(
() =>
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => {
@ -41,35 +53,86 @@ export const SidebarMenuItems = observer(() => {
);
return (
<div
className={cn("flex flex-col gap-0.5", {
"space-y-0": sidebarCollapsed,
})}
>
<>
<div className="flex flex-col gap-0.5">
{WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS.map((item, _index) => (
<SidebarItem key={`static_${_index}`} item={item} />
))}
</div>
<Disclosure as="div" className="flex flex-col" defaultOpen={!!isWorkspaceMenuOpen}>
<div className="group w-full flex items-center justify-between px-2 py-1.5 rounded text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-90">
<Disclosure.Button
as="button"
type="button"
className="w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400"
onClick={() => toggleListDisclosure(!isWorkspaceMenuOpen)}
aria-label={t(
isWorkspaceMenuOpen
? "aria_labels.app_sidebar.close_workspace_menu"
: "aria_labels.app_sidebar.open_workspace_menu"
)}
>
<span className="text-sm font-semibold">{t("workspace")}</span>
</Disclosure.Button>
<div className="flex items-center opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
<Disclosure.Button
as="button"
type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
onClick={() => toggleListDisclosure(!isWorkspaceMenuOpen)}
aria-label={t(
isWorkspaceMenuOpen
? "aria_labels.app_sidebar.close_workspace_menu"
: "aria_labels.app_sidebar.open_workspace_menu"
)}
>
<ChevronRight
className={cn("flex-shrink-0 size-3 transition-all", {
"rotate-90": isWorkspaceMenuOpen,
})}
/>
</Disclosure.Button>
</div>
</div>
<Transition
show={!!isWorkspaceMenuOpen}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
{isWorkspaceMenuOpen && (
<Disclosure.Panel as="div" className="flex flex-col gap-0.5" static>
<>
{WORKSPACE_SIDEBAR_STATIC_PINNED_NAVIGATION_ITEMS_LINKS.map((item, _index) => (
<SidebarItem key={`static_${_index}`} item={item} />
))}
{sortedNavigationItems.map((item, _index) => (
<SidebarItem key={`dynamic_${_index}`} item={item} />
))}
<SidebarNavItem className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}>
<SidebarNavItem>
<button
type="button"
onClick={() => toggleExtendedSidebar()}
className={cn("flex items-center gap-1.5 text-sm font-medium flex-grow text-custom-text-350", {
"justify-center": sidebarCollapsed,
})}
className="flex items-center gap-1.5 text-sm font-medium flex-grow text-custom-text-350"
id="extended-sidebar-toggle"
aria-label={t(
extendedSidebarCollapsed
? "aria_labels.projects_sidebar.open_extended_sidebar"
: "aria_labels.projects_sidebar.close_extended_sidebar"
isExtendedSidebarOpened
? "aria_labels.app_sidebar.close_extended_sidebar"
: "aria_labels.app_sidebar.open_extended_sidebar"
)}
>
<Ellipsis className="flex-shrink-0 size-4" />
{!sidebarCollapsed && <span>More</span>}
<span>{isExtendedSidebarOpened ? "Hide" : "More"}</span>
</button>
</SidebarNavItem>
</div>
</>
</Disclosure.Panel>
)}
</Transition>
</Disclosure>
</>
);
});

View file

@ -4,10 +4,9 @@ import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
// plane imports
import { EUserPermissionsLevel, SIDEBAR_TRACKER_ELEMENTS } from "@plane/constants";
import { usePlatformOS } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { EUserWorkspaceRoles } from "@plane/types";
import { Tooltip } from "@plane/ui";
// components
import { SidebarNavItem } from "@/components/sidebar";
import { NotificationAppSidebarOption } from "@/components/workspace-notifications";
@ -36,8 +35,7 @@ export const SidebarUserMenuItem: FC<SidebarUserMenuItemProps> = observer((props
const { t } = useTranslation();
// store hooks
const { allowPermissions } = useUserPermissions();
const { toggleSidebar, sidebarCollapsed } = useAppTheme();
const { isMobile } = usePlatformOS();
const { toggleSidebar } = useAppTheme();
const isActive = pathname === item.href;
@ -59,30 +57,14 @@ export const SidebarUserMenuItem: FC<SidebarUserMenuItemProps> = observer((props
};
return (
<Tooltip
tooltipContent={t(item.labelTranslationKey)}
position="right"
className="ml-2"
disabled={!sidebarCollapsed}
isMobile={isMobile}
>
<Link href={item.href} onClick={() => handleLinkClick(item.key)}>
<SidebarNavItem
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
isActive={isActive}
>
<SidebarNavItem isActive={isActive}>
<div className="flex items-center gap-1.5 py-[1px]">
<item.Icon className="size-4 flex-shrink-0" />
{!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>}
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
</div>
{item.key === "notifications" && (
<NotificationAppSidebarOption
workspaceSlug={workspaceSlug.toString()}
isSidebarCollapsed={sidebarCollapsed ?? false}
/>
)}
{item.key === "notifications" && <NotificationAppSidebarOption workspaceSlug={workspaceSlug.toString()} />}
</SidebarNavItem>
</Link>
</Tooltip>
);
});

View file

@ -0,0 +1,159 @@
"use client";
import { Fragment, Ref, useState, useEffect } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { usePopper } from "react-popper";
// icons
import { LogOut, PanelLeftDashed, Settings } from "lucide-react";
// ui
import { Menu, Transition } from "@headlessui/react";
// plane imports
import { GOD_MODE_URL } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Avatar, TOAST_TYPE, setToast } from "@plane/ui";
import { getFileURL } from "@plane/utils";
// hooks
import { useAppTheme, useUser } from "@/hooks/store";
import { useAppRail } from "@/hooks/use-app-rail";
type Props = {
size?: "sm" | "md";
};
export const UserMenuRoot = observer((props: Props) => {
const { size = "sm" } = props;
const { workspaceSlug } = useParams();
// store hooks
const { toggleAnySidebarDropdown, sidebarPeek, toggleSidebarPeek } = useAppTheme();
const { isEnabled, shouldRenderAppRail, toggleAppRail } = useAppRail();
const { data: currentUser } = useUser();
const { signOut } = useUser();
// derived values
const isUserInstanceAdmin = false;
// translation
const { t } = useTranslation();
// local state
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// popper-js init
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "right",
modifiers: [{ name: "preventOverflow", options: { padding: 12 } }],
});
const handleSignOut = async () => {
await signOut().catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: t("sign_out.toast.error.title"),
message: t("sign_out.toast.error.message"),
})
);
};
// Toggle sidebar dropdown state when either menu is open
useEffect(() => {
if (isUserMenuOpen) toggleAnySidebarDropdown(true);
else toggleAnySidebarDropdown(false);
}, [isUserMenuOpen]);
return (
<Menu as="div" className="relative flex-shrink-0">
{({ open, close }: { open: boolean; close: () => void }) => {
// Update local state directly
if (isUserMenuOpen !== open) {
setIsUserMenuOpen(open);
}
return (
<>
<Menu.Button
className="grid place-items-center outline-none"
ref={setReferenceElement}
aria-label={t("aria_labels.projects_sidebar.open_user_menu")}
>
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={size === "sm" ? 24 : 28}
shape="circle"
className="!text-base"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute left-0 z-[21] mt-1 flex w-44 origin-top-left flex-col divide-y
divide-custom-sidebar-border-200 rounded-md border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 text-xs shadow-lg outline-none"
ref={setPopperElement as Ref<HTMLDivElement>}
style={styles.popper}
{...attributes.popper}
>
<div className="flex flex-col gap-2.5 pb-2">
<span className="px-2 text-custom-sidebar-text-200">{currentUser?.email}</span>
<Link href={`/${workspaceSlug}/settings/account`}>
<Menu.Item as="div">
<span className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80">
<Settings className="h-4 w-4 stroke-[1.5]" />
<span>{t("settings")}</span>
</span>
</Menu.Item>
</Link>
{isEnabled && (
<Menu.Item
as="button"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
onClick={() => {
if (sidebarPeek) toggleSidebarPeek(false);
toggleAppRail();
}}
>
<PanelLeftDashed className="h-4 w-4 stroke-[1.5]" />
<span>{shouldRenderAppRail ? "Undock AppRail" : "Dock AppRail"}</span>
</Menu.Item>
)}
</div>
<div className={`pt-2 ${isUserInstanceAdmin || false ? "pb-2" : ""}`}>
<Menu.Item
as="button"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
onClick={handleSignOut}
>
<LogOut className="size-4 stroke-[1.5]" />
{t("sign_out")}
</Menu.Item>
</div>
{isUserInstanceAdmin && (
<div className="p-2 pb-0">
<Link href={GOD_MODE_URL}>
<Menu.Item as="button" type="button" className="w-full">
<span className="flex w-full items-center justify-center rounded bg-custom-primary-100/20 px-2 py-1 text-sm font-medium text-custom-primary-100 hover:bg-custom-primary-100/30 hover:text-custom-primary-200">
{t("enter_god_mode")}
</span>
</Menu.Item>
</Link>
</div>
)}
</Menu.Items>
</Transition>
</>
);
}}
</Menu>
);
});

View file

@ -8,15 +8,12 @@ import { EUserWorkspaceRoles } from "@plane/types";
// plane imports
import { UserActivityIcon } from "@plane/ui";
// components
import { cn } from "@plane/utils";
import { SidebarUserMenuItem } from "@/components/workspace/sidebar";
// helpers
// hooks
import { useAppTheme, useUserPermissions, useUser } from "@/hooks/store";
import { useUserPermissions, useUser } from "@/hooks/store";
export const SidebarUserMenu = observer(() => {
const { workspaceSlug } = useParams();
const { sidebarCollapsed } = useAppTheme();
const { workspaceUserInfo } = useUserPermissions();
const { data: currentUser } = useUser();
@ -54,11 +51,7 @@ export const SidebarUserMenu = observer(() => {
const draftIssueCount = workspaceUserInfo[workspaceSlug.toString()]?.draft_issue_count;
return (
<div
className={cn("flex flex-col gap-0.5", {
"space-y-0": sidebarCollapsed,
})}
>
<div className="flex flex-col gap-0.5">
{SIDEBAR_USER_MENU_ITEMS.map((item) => (
<SidebarUserMenuItem key={item.key} item={item} draftIssueCount={draftIssueCount} />
))}

View file

@ -12,7 +12,7 @@ import { EUserWorkspaceRoles } from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
// store hooks
import { useAppTheme, useUserPermissions } from "@/hooks/store";
import { useUserPermissions } from "@/hooks/store";
export type SidebarWorkspaceMenuHeaderProps = {
isWorkspaceMenuOpen: boolean;
@ -27,7 +27,6 @@ export const SidebarWorkspaceMenuHeader: FC<SidebarWorkspaceMenuHeaderProps> = o
const actionSectionRef = useRef<HTMLDivElement | null>(null);
// hooks
const { workspaceSlug } = useParams();
const { sidebarCollapsed } = useAppTheme();
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
@ -37,19 +36,8 @@ export const SidebarWorkspaceMenuHeader: FC<SidebarWorkspaceMenuHeaderProps> = o
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isAdmin = allowPermissions([EUserWorkspaceRoles.ADMIN] as any, EUserPermissionsLevel.WORKSPACE);
if (sidebarCollapsed) {
return <></>;
}
return (
<div
className={cn(
"flex px-2 bg-custom-sidebar-background-100 group/workspace-button hover:bg-custom-sidebar-background-90 rounded",
{
"mt-2.5": !sidebarCollapsed,
}
)}
>
<div className="flex px-2 bg-custom-sidebar-background-100 group/workspace-button hover:bg-custom-sidebar-background-90 rounded mt-2.5">
<Disclosure.Button
as="button"
className="flex-1 sticky top-0 z-10 w-full py-1.5 flex items-center justify-between gap-1 text-custom-sidebar-text-400 text-sm font-semibold"

View file

@ -4,10 +4,8 @@ import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
// plane imports
import { EUserPermissionsLevel } from "@plane/constants";
import { usePlatformOS } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { EUserWorkspaceRoles } from "@plane/types";
import { Tooltip } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { SidebarNavItem } from "@/components/sidebar";
@ -35,8 +33,7 @@ export const SidebarWorkspaceMenuItem: FC<SidebarWorkspaceMenuItemProps> = obser
const { workspaceSlug } = useParams();
const { allowPermissions } = useUserPermissions();
// store hooks
const { toggleSidebar, sidebarCollapsed } = useAppTheme();
const { isMobile } = usePlatformOS();
const { toggleSidebar } = useAppTheme();
const handleLinkClick = () => {
if (window.innerWidth < 768) {
@ -51,33 +48,20 @@ export const SidebarWorkspaceMenuItem: FC<SidebarWorkspaceMenuItemProps> = obser
const isActive = item.href === pathname;
return (
<Tooltip
tooltipContent={t(item.labelTranslationKey)}
position="right"
className="ml-2"
disabled={!sidebarCollapsed}
isMobile={isMobile}
>
<Link href={item.href} onClick={() => handleLinkClick()}>
<SidebarNavItem
className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}
isActive={isActive}
>
<SidebarNavItem isActive={isActive}>
<div className="flex items-center gap-1.5 py-[1px]">
<item.Icon
className={cn("size-4", {
"rotate-180": item.key === "active_cycles",
})}
/>
{!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>}
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
</div>
{!sidebarCollapsed && item.key === "active_cycles" && (
<div className="flex-shrink-0">
<UpgradeBadge />
</div>
)}
</SidebarNavItem>
</Link>
</Tooltip>
);
});

View file

@ -0,0 +1,216 @@
"use client";
import React, { Fragment, useState, useEffect } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
// icons
import { ChevronDown, CirclePlus, LogOut, Mails } from "lucide-react";
// ui
import { Menu, Transition } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { IWorkspace } from "@plane/types";
import { Loader, TOAST_TYPE, setToast } from "@plane/ui";
import { orderWorkspacesList, cn } from "@plane/utils";
// helpers
import { AppSidebarItem } from "@/components/sidebar";
// hooks
import { useAppTheme, useUser, useUserProfile, useWorkspace } from "@/hooks/store";
// plane web helpers
import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper";
// components
import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item";
type WorkspaceMenuRootProps = {
renderLogoOnly?: boolean;
};
export const WorkspaceMenuRoot = observer((props: WorkspaceMenuRootProps) => {
const { renderLogoOnly } = props;
// store hooks
const { toggleSidebar, toggleAnySidebarDropdown } = useAppTheme();
const { data: currentUser } = useUser();
const { signOut } = useUser();
const { updateUserProfile } = useUserProfile();
const { currentWorkspace: activeWorkspace, workspaces } = useWorkspace();
// derived values
const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false;
// translation
const { t } = useTranslation();
// local state
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false);
const handleWorkspaceNavigation = (workspace: IWorkspace) => updateUserProfile({ last_workspace_id: workspace?.id });
const handleSignOut = async () => {
await signOut().catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: t("sign_out.toast.error.title"),
message: t("sign_out.toast.error.message"),
})
);
};
const handleItemClick = () => {
if (window.innerWidth < 768) {
toggleSidebar();
}
};
const workspacesList = orderWorkspacesList(Object.values(workspaces ?? {}));
// TODO: fix workspaces list scroll
// Toggle sidebar dropdown state when either menu is open
useEffect(() => {
if (isWorkspaceMenuOpen) toggleAnySidebarDropdown(true);
else toggleAnySidebarDropdown(false);
}, [isWorkspaceMenuOpen]);
const logo = activeWorkspace?.logo_url;
const name = activeWorkspace?.name;
return (
<Menu
as="div"
className={cn("relative h-full flex ", {
"justify-center text-center": renderLogoOnly,
"flex-grow justify-stretch text-left truncate": !renderLogoOnly,
})}
>
{({ open, close }: { open: boolean; close: () => void }) => {
// Update local state directly
if (isWorkspaceMenuOpen !== open) {
setIsWorkspaceMenuOpen(open);
}
return (
<>
{renderLogoOnly ? (
<Menu.Button className="flex items-center justify-center size-8">
<AppSidebarItem
variant="button"
item={{
icon: (
<WorkspaceLogo
logo={activeWorkspace?.logo_url}
name={activeWorkspace?.name}
classNames="size-8 rounded-md"
/>
),
}}
/>
</Menu.Button>
) : (
<Menu.Button
className={cn(
"group/menu-button flex items-center gap-1 p-1 truncate rounded text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:outline-none ",
{
"justify-center text-center": renderLogoOnly,
"justify-between flex-grow": !renderLogoOnly,
}
)}
aria-label={t("aria_labels.projects_sidebar.open_workspace_switcher")}
>
<div className="flex-grow flex items-center gap-2 truncate">
<WorkspaceLogo logo={activeWorkspace?.logo_url} name={activeWorkspace?.name} />
<h4 className="truncate text-base font-medium text-custom-text-100">
{activeWorkspace?.name ?? t("loading")}
</h4>
</div>
<ChevronDown
className={cn(
"flex-shrink-0 mx-1 hidden size-4 group-hover/menu-button:block text-custom-sidebar-text-400 duration-300",
{ "rotate-180": open }
)}
/>
</Menu.Button>
)}
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="trnsform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items as={Fragment}>
<div className="fixed top-12 left-4 z-[21] mt-1 flex w-[19rem] origin-top-left flex-col divide-y divide-custom-border-100 rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg outline-none">
<div className="overflow-x-hidden vertical-scrollbar scrollbar-sm flex max-h-96 flex-col items-start justify-start overflow-y-scroll">
<span className="rounded-md text-left px-4 sticky top-0 z-[21] h-full w-full bg-custom-sidebar-background-100 pb-1 pt-3 text-sm font-medium text-custom-text-400 truncate flex-shrink-0">
{currentUser?.email}
</span>
{workspacesList ? (
<div className="size-full flex flex-col items-start justify-start">
{(activeWorkspace
? [
activeWorkspace,
...workspacesList.filter((workspace) => workspace.id !== activeWorkspace?.id),
]
: workspacesList
).map((workspace) => (
<SidebarDropdownItem
key={workspace.id}
workspace={workspace}
activeWorkspace={activeWorkspace}
handleItemClick={handleItemClick}
handleWorkspaceNavigation={handleWorkspaceNavigation}
handleClose={close}
/>
))}
</div>
) : (
<div className="w-full">
<Loader className="space-y-2">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
</div>
)}
</div>
<div className="w-full flex flex-col items-start justify-start gap-2 px-4 py-2 text-sm">
{isWorkspaceCreationEnabled && (
<Link href="/create-workspace" className="w-full">
<Menu.Item
as="div"
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
>
<CirclePlus className="size-4 flex-shrink-0" />
{t("create_workspace")}
</Menu.Item>
</Link>
)}
<Link href="/invitations" className="w-full" onClick={handleItemClick}>
<Menu.Item
as="div"
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
>
<Mails className="h-4 w-4 flex-shrink-0" />
{t("workspace_invites")}
</Menu.Item>
</Link>
<div className="w-full">
<Menu.Item
as="button"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1 text-sm font-medium text-red-600 hover:bg-custom-sidebar-background-80"
onClick={handleSignOut}
>
<LogOut className="size-4 flex-shrink-0" />
{t("sign_out")}
</Menu.Item>
</div>
</div>
</div>
</Menu.Items>
</Transition>
</>
);
}}
</Menu>
);
});

View file

@ -1,6 +1,6 @@
"use client";
import React, { useEffect } from "react";
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { BarChart2, Briefcase, Layers } from "lucide-react";
@ -11,25 +11,17 @@ import { ContrastIcon } from "@plane/ui";
// components
import { cn } from "@plane/utils";
import { SidebarWorkspaceMenuHeader, SidebarWorkspaceMenuItem } from "@/components/workspace/sidebar";
// helpers
// hooks
import { useAppTheme } from "@/hooks/store";
import useLocalStorage from "@/hooks/use-local-storage";
export const SidebarWorkspaceMenu = observer(() => {
// router params
const { workspaceSlug } = useParams();
// store hooks
const { sidebarCollapsed } = useAppTheme();
// local storage
const { setValue: toggleWorkspaceMenu, storedValue } = useLocalStorage<boolean>("is_workspace_menu_open", true);
// derived values
const isWorkspaceMenuOpen = !!storedValue;
useEffect(() => {
if (sidebarCollapsed) toggleWorkspaceMenu(true);
}, [sidebarCollapsed, toggleWorkspaceMenu]);
const SIDEBAR_WORKSPACE_MENU_ITEMS = [
{
key: "projects",
@ -74,13 +66,7 @@ export const SidebarWorkspaceMenu = observer(() => {
leaveTo="transform scale-95 opacity-0"
>
{isWorkspaceMenuOpen && (
<Disclosure.Panel
as="div"
className={cn("flex flex-col mt-0.5 gap-0.5", {
"space-y-0 mt-0 ml-0": sidebarCollapsed,
})}
static
>
<Disclosure.Panel as="div" className="flex flex-col mt-0.5 gap-0.5" static>
{SIDEBAR_WORKSPACE_MENU_ITEMS.map((item) => (
<SidebarWorkspaceMenuItem key={item.key} item={item} />
))}

View file

@ -0,0 +1,47 @@
"use client";
import React, { createContext, ReactNode } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// hooks
import useLocalStorage from "@/hooks/use-local-storage";
export interface AppRailContextType {
isEnabled: boolean;
shouldRenderAppRail: boolean;
toggleAppRail: (value?: boolean) => void;
}
const AppRailContext = createContext<AppRailContextType | undefined>(undefined);
export { AppRailContext };
interface AppRailProviderProps {
children: ReactNode;
}
export const AppRailProvider = observer(({ children }: AppRailProviderProps) => {
const { workspaceSlug } = useParams();
const { storedValue: isAppRailVisible, setValue: setIsAppRailVisible } = useLocalStorage<boolean>(
`APP_RAIL_${workspaceSlug}`,
false
);
const isEnabled = false;
const toggleAppRail = (value?: boolean) => {
if (value === undefined) {
setIsAppRailVisible(!isAppRailVisible);
} else {
setIsAppRailVisible(value);
}
};
const contextValue: AppRailContextType = {
isEnabled,
shouldRenderAppRail: !!isAppRailVisible && isEnabled,
toggleAppRail,
};
return <AppRailContext.Provider value={contextValue}>{children}</AppRailContext.Provider>;
});

View file

@ -0,0 +1,10 @@
import { useContext } from "react";
import { AppRailContext } from "./context/app-rail-context";
export const useAppRail = () => {
const context = useContext(AppRailContext);
if (context === undefined) {
throw new Error("useAppRail must be used within AppRailProvider");
}
return context;
};

View file

@ -0,0 +1,24 @@
"use client";
import { useParams, usePathname } from "next/navigation";
/**
* Custom hook to detect different workspace paths
* @returns Object containing boolean flags for different workspace paths
*/
export const useWorkspacePaths = () => {
const { workspaceSlug } = useParams();
const pathname = usePathname();
const isSettingsPath = pathname.includes(`/${workspaceSlug}/settings`);
const isWikiPath = pathname.includes(`/${workspaceSlug}/pages`);
const isAiPath = pathname.includes(`/${workspaceSlug}/pi-chat`);
const isProjectsPath = pathname.includes(`/${workspaceSlug}/`) && !isWikiPath && !isAiPath && !isSettingsPath;
return {
isSettingsPath,
isWikiPath,
isAiPath,
isProjectsPath,
};
};

View file

@ -165,7 +165,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
// check if the project member apis is loading
if (isParentLoading || (!projectMemberInfo && projectId && hasPermissionToCurrentProject === null))
return (
<div className="grid h-screen place-items-center bg-custom-background-100 p-4">
<div className="grid h-full place-items-center bg-custom-background-100 p-4 rounded-lg border border-custom-border-200">
<div className="flex flex-col items-center gap-3 text-center">
<LogoSpinner />
</div>
@ -183,7 +183,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
// check if the project info is not found.
if (loader === "loaded" && projectId && !!hasPermissionToCurrentProject === false)
return (
<div className="grid h-screen place-items-center bg-custom-background-100">
<div className="grid h-full place-items-center bg-custom-background-100">
<DetailedEmptyState
title={t("workspace_projects.empty_state.general.title")}
description={t("workspace_projects.empty_state.general.description")}

View file

@ -21,7 +21,6 @@ import { useFavorite } from "@/hooks/store/use-favorite";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local
import { persistence } from "@/local-db/storage.sqlite";
// constants
// images
import PlaneBlackLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png";
import PlaneWhiteLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png";
@ -136,7 +135,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
// if list of workspaces are not there then we have to render the spinner
if (isParentLoading || allWorkspaces === undefined || loader || isDBInitializing) {
return (
<div className="grid h-screen place-items-center bg-custom-background-100 p-4">
<div className="grid h-full place-items-center bg-custom-background-100 p-4 rounded-lg border border-custom-border-200">
<div className="flex flex-col items-center gap-3 text-center">
<LogoSpinner />
</div>
@ -147,7 +146,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
// if workspaces are there and we are trying to access the workspace that we are not part of then show the existing workspaces
if (currentWorkspace === undefined && !currentWorkspaceInfo) {
return (
<div className="relative flex h-screen w-full flex-col items-center justify-center bg-custom-background-90 ">
<div className="relative flex h-full w-full flex-col items-center justify-center bg-custom-background-90 ">
<div className="container relative mx-auto flex h-full w-full flex-col overflow-hidden overflow-y-auto px-5 py-14 md:px-0">
<div className="relative flex flex-shrink-0 items-center justify-between gap-4">
<div className="z-10 flex-shrink-0 bg-custom-background-90 py-4">

View file

@ -2,9 +2,11 @@ import { action, observable, makeObservable, runInAction } from "mobx";
export interface IThemeStore {
// observables
isAnySidebarDropdownOpen: boolean | undefined;
sidebarCollapsed: boolean | undefined;
extendedSidebarCollapsed: boolean | undefined;
extendedProjectSidebarCollapsed: boolean | undefined;
sidebarPeek: boolean | undefined;
isExtendedSidebarOpened: boolean | undefined;
isExtendedProjectSidebarOpened: boolean | undefined;
profileSidebarCollapsed: boolean | undefined;
workspaceAnalyticsSidebarCollapsed: boolean | undefined;
issueDetailSidebarCollapsed: boolean | undefined;
@ -12,7 +14,9 @@ export interface IThemeStore {
initiativesSidebarCollapsed: boolean | undefined;
projectOverviewSidebarCollapsed: boolean | undefined;
// actions
toggleAnySidebarDropdown: (open?: boolean) => void;
toggleSidebar: (collapsed?: boolean) => void;
toggleSidebarPeek: (peek?: boolean) => void;
toggleExtendedSidebar: (collapsed?: boolean) => void;
toggleExtendedProjectSidebar: (collapsed?: boolean) => void;
toggleProfileSidebar: (collapsed?: boolean) => void;
@ -25,9 +29,11 @@ export interface IThemeStore {
export class ThemeStore implements IThemeStore {
// observables
isAnySidebarDropdownOpen: boolean | undefined = undefined;
sidebarCollapsed: boolean | undefined = undefined;
extendedSidebarCollapsed: boolean | undefined = true;
extendedProjectSidebarCollapsed: boolean | undefined = undefined;
sidebarPeek: boolean | undefined = undefined;
isExtendedSidebarOpened: boolean | undefined = undefined;
isExtendedProjectSidebarOpened: boolean | undefined = undefined;
profileSidebarCollapsed: boolean | undefined = undefined;
workspaceAnalyticsSidebarCollapsed: boolean | undefined = undefined;
issueDetailSidebarCollapsed: boolean | undefined = undefined;
@ -38,9 +44,11 @@ export class ThemeStore implements IThemeStore {
constructor() {
makeObservable(this, {
// observable
isAnySidebarDropdownOpen: observable.ref,
sidebarCollapsed: observable.ref,
extendedSidebarCollapsed: observable.ref,
extendedProjectSidebarCollapsed: observable.ref,
sidebarPeek: observable.ref,
isExtendedSidebarOpened: observable.ref,
isExtendedProjectSidebarOpened: observable.ref,
profileSidebarCollapsed: observable.ref,
workspaceAnalyticsSidebarCollapsed: observable.ref,
issueDetailSidebarCollapsed: observable.ref,
@ -48,7 +56,9 @@ export class ThemeStore implements IThemeStore {
initiativesSidebarCollapsed: observable.ref,
projectOverviewSidebarCollapsed: observable.ref,
// action
toggleAnySidebarDropdown: action,
toggleSidebar: action,
toggleSidebarPeek: action,
toggleExtendedSidebar: action,
toggleExtendedProjectSidebar: action,
toggleProfileSidebar: action,
@ -60,6 +70,14 @@ export class ThemeStore implements IThemeStore {
});
}
toggleAnySidebarDropdown = (open?: boolean) => {
if (open === undefined) {
this.isAnySidebarDropdownOpen = !this.isAnySidebarDropdownOpen;
} else {
this.isAnySidebarDropdownOpen = open;
}
};
/**
* Toggle the sidebar collapsed state
* @param collapsed
@ -73,14 +91,26 @@ export class ThemeStore implements IThemeStore {
localStorage.setItem("app_sidebar_collapsed", this.sidebarCollapsed.toString());
};
/**
* Toggle the sidebar peek state
* @param peek
*/
toggleSidebarPeek = (peek?: boolean) => {
if (peek === undefined) {
this.sidebarPeek = !this.sidebarPeek;
} else {
this.sidebarPeek = peek;
}
};
/**
* Toggle the extended sidebar collapsed state
* @param collapsed
*/
toggleExtendedSidebar = (collapsed?: boolean) => {
const updatedState = collapsed ?? !this.extendedSidebarCollapsed;
const updatedState = collapsed ?? !this.isExtendedSidebarOpened;
runInAction(() => {
this.extendedSidebarCollapsed = updatedState;
this.isExtendedSidebarOpened = updatedState;
});
localStorage.setItem("extended_sidebar_collapsed", updatedState.toString());
};
@ -91,11 +121,11 @@ export class ThemeStore implements IThemeStore {
*/
toggleExtendedProjectSidebar = (collapsed?: boolean) => {
if (collapsed === undefined) {
this.extendedProjectSidebarCollapsed = !this.extendedProjectSidebarCollapsed;
this.isExtendedProjectSidebarOpened = !this.isExtendedProjectSidebarOpened;
} else {
this.extendedProjectSidebarCollapsed = collapsed;
this.isExtendedProjectSidebarOpened = collapsed;
}
localStorage.setItem("extended_project_sidebar_collapsed", this.extendedProjectSidebarCollapsed.toString());
localStorage.setItem("extended_project_sidebar_collapsed", this.isExtendedProjectSidebarOpened.toString());
};
/**

View file

@ -0,0 +1 @@
export * from "ce/components/app-rail";

View file

@ -3,3 +3,4 @@ export * from "./upgrade-badge";
export * from "./billing";
export * from "./delete-workspace-section";
export * from "./sidebar";
export * from "ce/components/workspace/app-switcher";

View file

@ -1 +1,2 @@
export * from "ce/components/workspace/upgrade-badge";
export * from "ce/components/workspace/content-wrapper";

View file

@ -35,3 +35,4 @@ export * from "./settings";
export * from "./icon";
export * from "./estimates";
export * from "./analytics";
export * from "./sidebar";

View file

@ -0,0 +1,2 @@
export const SIDEBAR_WIDTH = 250;
export const EXTENDED_SIDEBAR_WIDTH = 300;

View file

@ -317,6 +317,9 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspac
export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["home"],
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["inbox"],
];
export const WORKSPACE_SIDEBAR_STATIC_PINNED_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["projects"],
];

View file

@ -0,0 +1,30 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const AiIcon: React.FC<ISvgIcons> = ({ width = "16", height = "16", className, color = "currentColor" }) => (
<svg
width={width}
height={height}
viewBox="0 0 16 16"
fill={color}
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g clip-path="url(#clip0_888_35571)">
<path
d="M14.2082 0H1.79185C0.801553 0 0 0.801553 0 1.79185V14.2093C0 15.1984 0.801553 16.0012 1.79185 16.0012H14.2093C15.1984 16.0012 16.0012 15.1996 16.0012 14.2093V1.79185C16.0012 0.802748 15.1996 0 14.2093 0H14.2082ZM13.1032 11.5276C13.1032 12.3984 12.3972 13.1032 11.5276 13.1032H4.47245C3.60161 13.1032 2.89682 12.3972 2.89682 11.5276V4.47245C2.89682 3.60161 3.60281 2.89682 4.47245 2.89682H11.5276C12.3984 2.89682 13.1032 3.60281 13.1032 4.47245V11.5276Z"
fill={color}
/>
<path
d="M9.61291 4.94336H6.38759C5.58996 4.94336 4.94336 5.58996 4.94336 6.38759V9.61291C4.94336 10.4105 5.58996 11.0571 6.38759 11.0571H9.61291C10.4105 11.0571 11.0571 10.4105 11.0571 9.61291V6.38759C11.0571 5.58996 10.4105 4.94336 9.61291 4.94336Z"
fill={color}
/>
</g>
<defs>
<clipPath id="clip0_888_35571">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);

View file

@ -52,3 +52,6 @@ export * from "./sticky-note-icon";
export * from "./bar-icon";
export * from "./tree-map-icon";
export * from "./display-properties";
export * from "./ai-icon";
export * from "./plane-icon";
export * from "./wiki-icon";

View file

@ -0,0 +1,35 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const PlaneNewIcon: React.FC<ISvgIcons> = ({
width = "16",
height = "16",
className,
color = "currentColor",
}) => (
<svg
width={width}
height={height}
viewBox="0 0 16 16"
fill={color}
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g clip-path="url(#clip0_888_35560)">
<path
d="M5.15383 9.50566V5.15381H1.34152C0.601228 5.15381 0 5.75399 0 6.49533V14.6595C0 15.3998 0.600183 16.001 1.34152 16.001H9.50568C10.246 16.001 10.8472 15.4008 10.8472 14.6595V10.8461H6.49536C5.75506 10.8461 5.15383 10.246 5.15383 9.50461V9.50566Z"
fill={color}
/>
<path
d="M14.66 0H6.49582C5.75553 0 5.1543 0.600183 5.1543 1.34152V5.15488H9.50615C10.2464 5.15488 10.8477 5.75506 10.8477 6.49641V10.8483H14.661C15.4013 10.8483 16.0026 10.2481 16.0026 9.50673V1.34152C16.0026 0.601229 15.4024 0 14.661 0H14.66Z"
fill={color}
/>
</g>
<defs>
<clipPath id="clip0_888_35560">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);

View file

@ -0,0 +1,26 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const WikiIcon: React.FC<ISvgIcons> = ({ width = "16", height = "16", className, color = "currentColor" }) => (
<svg
width={width}
height={height}
viewBox="0 0 16 16"
fill={color}
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g clip-path="url(#clip0_888_35566)">
<path
d="M15.558 6.93332L9.06623 0.441504C8.47755 -0.147168 7.5229 -0.147168 6.93332 0.441504L0.441504 6.93332C-0.147168 7.52199 -0.147168 8.47664 0.441504 9.06623L6.93332 15.558C7.52199 16.1467 8.47664 16.1467 9.06623 15.558L15.558 9.06623C16.1467 8.47755 16.1467 7.5229 15.558 6.93332ZM10.7629 9.65855C10.7629 10.2682 10.2691 10.762 9.65946 10.762H6.341C5.73133 10.762 5.23758 10.2682 5.23758 9.65855V6.34008C5.23758 5.73042 5.73133 5.23667 6.341 5.23667H9.65946C10.2691 5.23667 10.7629 5.73042 10.7629 6.34008V9.65855Z"
fill={color}
/>
</g>
<defs>
<clipPath id="clip0_888_35566">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);