[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,20 +2,21 @@
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 />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
{children}
</main>
<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,60 +52,38 @@ 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 className="flex flex-col gap-3 px-3">
{/* Workspace switcher and settings */}
{!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>
)}
>
<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,
})}
>
{/* Workspace switcher and settings */}
<SidebarDropdown />
<div className="flex-shrink-0 h-4" />
{/* App switcher */}
{canPerformWorkspaceMemberActions && <SidebarAppSwitcher />}
{/* 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,
})}
>
<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 */}
<SidebarTeamsList />
{/* Projects List */}
<SidebarProjectsList />
</div>
{/* Help Section */}
<SidebarHelpSection />
{/* Quick actions */}
<SidebarQuickActions />
</div>
<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 />
{/* Favorites Menu */}
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
{/* Teams List */}
<SidebarTeamsList />
{/* Projects List */}
<SidebarProjectsList />
</div>
{/* Help Section */}
<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">
{/* Header */}
<SettingsHeader />
{/* Content */}
<ContentWrapper className="px-4 md:pl-12 md:flex w-full">
<div className="w-full h-full overflow-hidden">{children}</div>
</ContentWrapper>
</main>
<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="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}
>
<div className="flex items-center gap-1.5 py-[1px]">
{icon}
{!sidebarCollapsed && <p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>}
</div>
{item.key === "inbox" && (
<NotificationAppSidebarOption
workspaceSlug={workspaceSlug?.toString()}
isSidebarCollapsed={sidebarCollapsed ?? false}
/>
)}
</SidebarNavItem>
</Link>
</Tooltip>
<Link href={itemHref} onClick={() => handleLinkClick()}>
<SidebarNavItem isActive={isActive}>
<div className="flex items-center gap-1.5 py-[1px]">
{icon}
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
</div>
{item.key === "inbox" && <NotificationAppSidebarOption workspaceSlug={workspaceSlug?.toString()} />}
</SidebarNavItem>
</Link>
);
});

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",
{
"!pt-4 flex md:flex-col": isScrolled,
}
)}
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 "./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,117 +166,95 @@ 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}>
<>
<Tooltip tooltipContent={`${favorite.name}`} position="right" className="ml-8" isMobile={isMobile}>
<div className="flex-grow flex truncate">
<Disclosure.Button
as="button"
className="size-8 aspect-square flex-shrink-0 grid place-items-center"
type="button"
className="flex-grow flex items-center gap-1.5 text-left select-none w-full"
>
<div className="size-4 grid place-items-center flex-shrink-0">
<Tooltip
isMobile={isMobile}
tooltipContent={
favorite.sort_order === null ? "Join the project to rearrange" : "Drag to rearrange"
}
position="top-right"
disabled={isDragging}
>
<button
type="button"
className={cn(
"hidden group-hover/project-item: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": favorite.sort_order === null,
"cursor-grabbing": isDragging,
}
)}
>
<DragHandle className="bg-transparent" />
</button>
</Tooltip>
<div className="size-5 grid place-items-center flex-shrink-0">
<FavoriteFolderIcon />
</div>
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{favorite.name}</p>
</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,
})}
>
<Tooltip
isMobile={isMobile}
tooltipContent={
favorite.sort_order === null ? "Join the project to rearrange" : "Drag to rearrange"
}
position="top-right"
disabled={isDragging}
>
<button
type="button"
className={cn(
"hidden group-hover/project-item: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": favorite.sort_order === null,
"cursor-grabbing": isDragging,
"!hidden": isSidebarCollapsed,
}
)}
>
<DragHandle className="bg-transparent" />
</button>
</Tooltip>
<div className="size-5 grid place-items-center flex-shrink-0">
<FavoriteFolderIcon />
</div>
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{favorite.name}</p>
</Disclosure.Button>
</div>
</Tooltip>
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
>
<MoreHorizontal className="size-3" />
</span>
</div>
</Tooltip>
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
>
<MoreHorizontal className="size-3" />
</span>
}
menuButtonOnClick={() => setIsMenuActive(!isMenuActive)}
className={cn(
"opacity-0 pointer-events-none flex-shrink-0 group-hover/project-item:opacity-100 group-hover/project-item:pointer-events-auto",
{
"opacity-100 pointer-events-auto": isMenuActive,
}
menuButtonOnClick={() => setIsMenuActive(!isMenuActive)}
className={cn(
"opacity-0 pointer-events-none flex-shrink-0 group-hover/project-item:opacity-100 group-hover/project-item:pointer-events-auto",
{
"opacity-100 pointer-events-auto": isMenuActive,
}
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
>
<CustomMenu.MenuItem onClick={() => handleRemoveFromFavorites(favorite)}>
<span className="flex items-center justify-start gap-2">
<Star className="h-3.5 w-3.5 fill-yellow-500 stroke-yellow-500" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => setFolderToRename(favorite.id)}>
<div className="flex items-center justify-start gap-2">
<PenSquare className="h-3.5 w-3.5 stroke-[1.5] text-custom-text-300" />
<span>Rename Folder</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
<Disclosure.Button
as="button"
type="button"
className={cn(
"hidden group-hover/project-item:inline-block p-0.5 rounded hover:bg-custom-sidebar-background-80",
{
"inline-block": isMenuActive,
}
)}
aria-label={t(
open ? "aria_labels.projects_sidebar.close_folder" : "aria_labels.projects_sidebar.open_folder"
)}
>
<ChevronRight
className={cn("size-3 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
"rotate-90": open,
})}
/>
</Disclosure.Button>
</>
)}
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
>
<CustomMenu.MenuItem onClick={() => handleRemoveFromFavorites(favorite)}>
<span className="flex items-center justify-start gap-2">
<Star className="h-3.5 w-3.5 fill-yellow-500 stroke-yellow-500" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => setFolderToRename(favorite.id)}>
<div className="flex items-center justify-start gap-2">
<PenSquare className="h-3.5 w-3.5 stroke-[1.5] text-custom-text-300" />
<span>Rename Folder</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
<Disclosure.Button
as="button"
type="button"
className={cn(
"hidden group-hover/project-item:inline-block p-0.5 rounded hover:bg-custom-sidebar-background-80",
{
"inline-block": isMenuActive,
}
)}
aria-label={t(
open ? "aria_labels.projects_sidebar.close_folder" : "aria_labels.projects_sidebar.open_folder"
)}
>
<ChevronRight
className={cn("size-3 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
"rotate-90": open,
})}
/>
</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,28 +7,23 @@ 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(
"group/project-item cursor-pointer relative group flex items-center justify-between w-full 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",
{
"bg-custom-sidebar-background-90": isMenuActive,
}
)}
>
{children}
</div>
)}
<div
ref={elementRef}
className={cn(
"group/project-item cursor-pointer relative group flex items-center justify-between w-full 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",
{
"bg-custom-sidebar-background-90": isMenuActive,
}
)}
>
{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,18 +131,16 @@ 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 && (
<FavoriteItemQuickAction
favorite={favorite}
ref={actionSectionRef}
isMenuActive={isMenuActive}
onChange={handleQuickAction}
handleRemoveFromFavorites={handleRemoveFromFavorites}
/>
)}
<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}
isMenuActive={isMenuActive}
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,27 +174,48 @@ export const SidebarFavoritesMenu = observer(() => {
return (
<>
<Disclosure as="div" defaultOpen ref={containerRef}>
{!sidebarCollapsed && (
<div
ref={elementRef}
<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"
)}
>
<Disclosure.Button
as="button"
type="button"
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",
"w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400",
{
"p-0 justify-center w-fit mx-auto bg-custom-sidebar-background-90 hover:bg-custom-sidebar-background-80":
sidebarCollapsed,
"bg-custom-sidebar-background-80 opacity-60": isDragging,
}
)}
onClick={() => toggleFavoriteMenu(!isFavoriteMenuOpen)}
aria-label={t(
isFavoriteMenuOpen
? "aria_labels.projects_sidebar.close_favorites_menu"
: "aria_labels.projects_sidebar.open_favorites_menu"
)}
>
<span className="text-sm font-semibold">{t("favorites")}</span>
</Disclosure.Button>
<div className="flex items-center opacity-0 pointer-events-none group-hover/favorites-button:opacity-100 group-hover/favorites-button:pointer-events-auto">
<Tooltip tooltipHeading={t("create_folder")} tooltipContent="">
<button
type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0 grid place-items-center"
onClick={() => {
setCreateNewFolder(true);
if (!isFavoriteMenuOpen) toggleFavoriteMenu(!isFavoriteMenuOpen);
}}
aria-label={t("aria_labels.projects_sidebar.create_favorites_folder")}
>
<FolderPlus className="size-3" />
</button>
</Tooltip>
<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": sidebarCollapsed,
"bg-custom-sidebar-background-80 opacity-60": isDragging,
}
)}
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0 grid place-items-center"
onClick={() => toggleFavoriteMenu(!isFavoriteMenuOpen)}
aria-label={t(
isFavoriteMenuOpen
@ -217,42 +223,14 @@ export const SidebarFavoritesMenu = observer(() => {
: "aria_labels.projects_sidebar.open_favorites_menu"
)}
>
<span className="text-sm font-semibold">{t("favorites")}</span>
<ChevronRight
className={cn("flex-shrink-0 size-3 transition-all", {
"rotate-90": isFavoriteMenuOpen,
})}
/>
</Disclosure.Button>
<div className="flex items-center opacity-0 pointer-events-none group-hover/favorites-button:opacity-100 group-hover/favorites-button:pointer-events-auto">
<Tooltip tooltipHeading={t("create_folder")} tooltipContent="">
<button
type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0 grid place-items-center"
onClick={() => {
setCreateNewFolder(true);
if (!isFavoriteMenuOpen) toggleFavoriteMenu(!isFavoriteMenuOpen);
}}
aria-label={t("aria_labels.projects_sidebar.create_favorites_folder")}
>
<FolderPlus className="size-3" />
</button>
</Tooltip>
<Disclosure.Button
as="button"
type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0 grid place-items-center"
onClick={() => toggleFavoriteMenu(!isFavoriteMenuOpen)}
aria-label={t(
isFavoriteMenuOpen
? "aria_labels.projects_sidebar.close_favorites_menu"
: "aria_labels.projects_sidebar.open_favorites_menu"
)}
>
<ChevronRight
className={cn("flex-shrink-0 size-3 transition-all", {
"rotate-90": isFavoriteMenuOpen,
})}
/>
</Disclosure.Button>
</div>
</div>
)}
</div>
<Transition
show={isFavoriteMenuOpen}
enter="transition duration-100 ease-out"
@ -263,55 +241,34 @@ 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}
isLastChild={index === length - 1}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder}
handleDrop={handleDrop}
/>
) : (
<FavoriteRoot
workspaceSlug={workspaceSlug.toString()}
favorite={fav}
isLastChild={index === length - 1}
parentId={undefined}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleDrop={handleDrop}
/>
)}
</Tooltip>
{fav?.is_folder ? (
<FavoriteFolder
favorite={fav}
isLastChild={index === length - 1}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleRemoveFromFavoritesFolder={handleRemoveFromFavoritesFolder}
handleDrop={handleDrop}
/>
) : (
<FavoriteRoot
workspaceSlug={workspaceSlug.toString()}
favorite={fav}
isLastChild={index === length - 1}
parentId={undefined}
handleRemoveFromFavorites={handleRemoveFromFavorites}
handleDrop={handleDrop}
/>
)}
</>
))
@ -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)}
>
<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>}
</div>
</SidebarNavItem>
</Link>
</Tooltip>
<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]"}`} />
<span className="text-xs font-medium">{t(item.i18n_key)}</span>
</div>
</SidebarNavItem>
</Link>
);
})}
</>

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,76 +251,53 @@ 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,
})}
className="flex-grow flex truncate"
onClick={handleItemClick}
>
<Disclosure.Button as="button" className="size-8 aspect-square flex-shrink-0 grid place-items-center">
<Disclosure.Button
as="button"
type="button"
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")
: t("aria_labels.projects_sidebar.open_project_menu")
}
>
<div className="size-4 grid place-items-center flex-shrink-0">
<Logo logo={project.logo_props} size={16} />
</div>
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{project.name}</p>
</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"
onClick={handleItemClick}
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<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,
})}
aria-label={
isProjectListOpen
? t("aria_labels.projects_sidebar.close_project_menu")
: t("aria_labels.projects_sidebar.open_project_menu")
}
>
<div className="size-4 grid place-items-center flex-shrink-0">
<Logo logo={project.logo_props} size={16} />
</div>
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{project.name}</p>
</Disclosure.Button>
</ControlLink>
</Tooltip>
<CustomMenu
customButton={
<span
ref={actionSectionRef}
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontal className="size-4" />
</span>
<MoreHorizontal className="size-4" />
</span>
}
className={cn(
"opacity-0 pointer-events-none flex-shrink-0 group-hover/project-item:opacity-100 group-hover/project-item:pointer-events-auto",
{
"opacity-100 pointer-events-auto": isMenuActive,
}
className={cn(
"opacity-0 pointer-events-none flex-shrink-0 group-hover/project-item:opacity-100 group-hover/project-item:pointer-events-auto",
{
"opacity-100 pointer-events-auto": isMenuActive,
}
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")}
useCaptureForOutsideClick
closeOnSelect
>
{/* TODO: Removed is_favorite logic due to the optimization in projects API */}
{/* {isAuthorized && (
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
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 && (
<CustomMenu.MenuItem
onClick={project.is_favorite ? handleRemoveFromFavorites : handleAddToFavorites}
>
@ -333,82 +312,81 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
</CustomMenu.MenuItem>
)} */}
{/* publish project settings */}
{isAdmin && (
<CustomMenu.MenuItem onClick={() => setPublishModal(true)}>
<div className="relative flex flex-shrink-0 items-center justify-start gap-2">
<div className="flex h-4 w-4 cursor-pointer items-center justify-center rounded text-custom-sidebar-text-200 transition-all duration-300 hover:bg-custom-sidebar-background-80">
<Share2 className="h-3.5 w-3.5 stroke-[1.5]" />
</div>
<div>{t("publish_project")}</div>
{/* publish project settings */}
{isAdmin && (
<CustomMenu.MenuItem onClick={() => setPublishModal(true)}>
<div className="relative flex flex-shrink-0 items-center justify-start gap-2">
<div className="flex h-4 w-4 cursor-pointer items-center justify-center rounded text-custom-sidebar-text-200 transition-all duration-300 hover:bg-custom-sidebar-background-80">
<Share2 className="h-3.5 w-3.5 stroke-[1.5]" />
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("copy_link")}</span>
</span>
<div>{t("publish_project")}</div>
</div>
</CustomMenu.MenuItem>
{isAuthorized && (
<CustomMenu.MenuItem
onClick={() => {
router.push(`/${workspaceSlug}/projects/${project?.id}/archives/issues`);
}}
>
<div className="flex items-center justify-start gap-2 cursor-pointer">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("archives")}</span>
</div>
</CustomMenu.MenuItem>
)}
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("copy_link")}</span>
</span>
</CustomMenu.MenuItem>
{isAuthorized && (
<CustomMenu.MenuItem
onClick={() => {
router.push(`/${workspaceSlug}/settings/projects/${project?.id}`);
router.push(`/${workspaceSlug}/projects/${project?.id}/archives/issues`);
}}
>
<div className="flex items-center justify-start gap-2 cursor-pointer">
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("settings")}</span>
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("archives")}</span>
</div>
</CustomMenu.MenuItem>
{/* leave project */}
{!isAuthorized && (
<CustomMenu.MenuItem
onClick={handleLeaveProject}
data-ph-element={MEMBER_TRACKER_ELEMENTS.SIDEBAR_PROJECT_QUICK_ACTIONS}
>
<div className="flex items-center justify-start gap-2">
<LogOut className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("leave_project")}</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
<Disclosure.Button
as="button"
type="button"
className={cn(
"hidden group-hover/project-item:inline-block p-0.5 rounded hover:bg-custom-sidebar-background-80",
{
"inline-block": isMenuActive,
}
)}
onClick={() => setIsProjectListOpen(!isProjectListOpen)}
aria-label={t(
isProjectListOpen
? "aria_labels.projects_sidebar.close_project_menu"
: "aria_labels.projects_sidebar.open_project_menu"
)}
)}
<CustomMenu.MenuItem
onClick={() => {
router.push(`/${workspaceSlug}/settings/projects/${project?.id}`);
}}
>
<ChevronRight
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
"rotate-90": isProjectListOpen,
})}
/>
</Disclosure.Button>
</>
)}
<div className="flex items-center justify-start gap-2 cursor-pointer">
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("settings")}</span>
</div>
</CustomMenu.MenuItem>
{/* leave project */}
{!isAuthorized && (
<CustomMenu.MenuItem
onClick={handleLeaveProject}
data-ph-element={MEMBER_TRACKER_ELEMENTS.SIDEBAR_PROJECT_QUICK_ACTIONS}
>
<div className="flex items-center justify-start gap-2">
<LogOut className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("leave_project")}</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
<Disclosure.Button
as="button"
type="button"
className={cn(
"hidden group-hover/project-item:inline-block p-0.5 rounded hover:bg-custom-sidebar-background-80",
{
"inline-block": isMenuActive,
}
)}
onClick={() => setIsProjectListOpen(!isProjectListOpen)}
aria-label={t(
isProjectListOpen
? "aria_labels.projects_sidebar.close_project_menu"
: "aria_labels.projects_sidebar.open_project_menu"
)}
>
<ChevronRight
className={cn("size-4 flex-shrink-0 text-custom-sidebar-text-400 transition-transform", {
"rotate-90": isProjectListOpen,
})}
/>
</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,52 +160,42 @@ 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>
<span className="text-sm font-semibold">{t("projects")}</span>
</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="">
<button
type="button"
data-ph-element={PROJECT_TRACKER_ELEMENTS.SIDEBAR_CREATE_PROJECT_TOOLTIP}
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
onClick={() => {
setIsProjectModalOpen(true);
}}
aria-label={t("aria_labels.projects_sidebar.create_new_project")}
>
<Plus className="size-3" />
</button>
</Tooltip>
<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="">
<button
type="button"
data-ph-element={PROJECT_TRACKER_ELEMENTS.SIDEBAR_CREATE_PROJECT_TOOLTIP}
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
onClick={() => {
setIsProjectModalOpen(true);
}}
aria-label={t("aria_labels.projects_sidebar.create_new_project")}
>
<Plus className="size-3" />
</button>
</Tooltip>
)}
<Disclosure.Button
as="button"
type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
aria-label={t(
isAllProjectsListOpen
? "aria_labels.projects_sidebar.close_projects_menu"
: "aria_labels.projects_sidebar.open_projects_menu"
)}
<Disclosure.Button
as="button"
type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
onClick={() => toggleListDisclosure(!isAllProjectsListOpen)}
aria-label={t(
isAllProjectsListOpen
? "aria_labels.projects_sidebar.close_projects_menu"
: "aria_labels.projects_sidebar.open_projects_menu"
)}
>
<ChevronRight
className={cn("flex-shrink-0 size-3 transition-all", {
"rotate-90": isAllProjectsListOpen,
})}
/>
</Disclosure.Button>
</div>
)}
>
<ChevronRight
className={cn("flex-shrink-0 size-3 transition-all", {
"rotate-90": isAllProjectsListOpen,
})}
/>
</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>
)}
<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,
})}
>
{WORKSPACE_SIDEBAR_STATIC_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" : ""}`}>
<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,
})}
id="extended-sidebar-toggle"
aria-label={t(
extendedSidebarCollapsed
? "aria_labels.projects_sidebar.open_extended_sidebar"
: "aria_labels.projects_sidebar.close_extended_sidebar"
)}
<>
<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"
>
<Ellipsis className="flex-shrink-0 size-4" />
{!sidebarCollapsed && <span>More</span>}
</button>
</SidebarNavItem>
</div>
{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>
<button
type="button"
onClick={() => toggleExtendedSidebar()}
className="flex items-center gap-1.5 text-sm font-medium flex-grow text-custom-text-350"
id="extended-sidebar-toggle"
aria-label={t(
isExtendedSidebarOpened
? "aria_labels.app_sidebar.close_extended_sidebar"
: "aria_labels.app_sidebar.open_extended_sidebar"
)}
>
<Ellipsis className="flex-shrink-0 size-4" />
<span>{isExtendedSidebarOpened ? "Hide" : "More"}</span>
</button>
</SidebarNavItem>
</>
</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}
>
<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>}
</div>
{item.key === "notifications" && (
<NotificationAppSidebarOption
workspaceSlug={workspaceSlug.toString()}
isSidebarCollapsed={sidebarCollapsed ?? false}
/>
)}
</SidebarNavItem>
</Link>
</Tooltip>
<Link href={item.href} onClick={() => handleLinkClick(item.key)}>
<SidebarNavItem isActive={isActive}>
<div className="flex items-center gap-1.5 py-[1px]">
<item.Icon className="size-4 flex-shrink-0" />
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
</div>
{item.key === "notifications" && <NotificationAppSidebarOption workspaceSlug={workspaceSlug.toString()} />}
</SidebarNavItem>
</Link>
);
});

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}
>
<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>}
</div>
{!sidebarCollapsed && item.key === "active_cycles" && (
<div className="flex-shrink-0">
<UpgradeBadge />
</div>
)}
</SidebarNavItem>
</Link>
</Tooltip>
<Link href={item.href} onClick={() => handleLinkClick()}>
<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",
})}
/>
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
</div>
<div className="flex-shrink-0">
<UpgradeBadge />
</div>
</SidebarNavItem>
</Link>
);
});

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";