diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx new file mode 100644 index 00000000000..cd91d1c28d3 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx @@ -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(storedValue ?? SIDEBAR_WIDTH); + // hooks + const { shouldRenderAppRail } = useAppRail(); + // derived values + const isAnyExtendedSidebarOpen = isExtendedSidebarOpened; + + // handlers + const handleWidthChange = (width: number) => setValue(width); + + return ( + <> + + + + } + isAnyExtendedSidebarExpanded={isAnyExtendedSidebarOpen} + isAnySidebarDropdownOpen={isAnySidebarDropdownOpen} + disablePeekTrigger={shouldRenderAppRail} + > + + + + ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx index d33acbeb4fd..ac3e3262ad8 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx @@ -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()} /> )} -
@@ -159,7 +145,7 @@ export const ExtendedProjectSidebar = observer(() => { /> ))}
-
+ ); }); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx new file mode 100644 index 00000000000..22d2b2c48a8 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx @@ -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; + isExtendedSidebarOpened: boolean; + handleClose: () => void; + excludedElementId: string; +}; + +export const ExtendedSidebarWrapper: FC = observer((props) => { + const { children, extendedSidebarRef, isExtendedSidebarOpened, handleClose, excludedElementId } = props; + // store hooks + const { storedValue } = useLocalStorage("sidebarWidth", SIDEBAR_WIDTH); + + useExtendedSidebarOutsideClickDetector(extendedSidebarRef, handleClose, excludedElementId); + + return ( +
+ {children} +
+ ); +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx index 4adfa4b8a8a..6af0e0b244c 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx @@ -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 ( -
{sortedNavigationItems.map((item, index) => ( { handleOnNavigationItemDrop={handleOnNavigationItemDrop} /> ))} -
+ ); }); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx index bc5e2211506..cf1be8ae27b 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx @@ -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 ( <>
-
+
{
); -}; +}); diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx index 340ec57d0d0..860242dc5bf 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx @@ -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 ( -
- -
- {children} -
+
+
+ +
+ {children} +
+
diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx index 39d6313ba5d..b5dbec81cef 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx @@ -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 ( <> -
-
-
- {/* Workspace switcher and settings */} - -
- {/* App switcher */} - {canPerformWorkspaceMemberActions && } - {/* Quick actions */} - -
-
-
- - {sidebarCollapsed && ( -
- )} - {/* Favorites Menu */} - {canPerformWorkspaceMemberActions && !isFavoriteEmpty && } - {/* Teams List */} - - {/* Projects List */} - +
+ {/* Workspace switcher and settings */} + {!shouldRenderAppRail && } + + {isAppRailEnabled && ( +
+ Projects +
+ +
- {/* Help Section */} - + )} + {/* Quick actions */} + +
+
+ + {/* Favorites Menu */} + {canPerformWorkspaceMemberActions && !isFavoriteEmpty && } + {/* Teams List */} + + {/* Projects List */} + +
+ {/* Help Section */} +
+ +
+ {!shouldRenderAppRail && } + {!isAppRailEnabled && }
- - ); }); diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx index 4bd4a534044..593a1b0931a 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/layout.tsx @@ -11,14 +11,16 @@ export default function SettingsLayout({ children }: { children: React.ReactNode -
- {/* Header */} - - {/* Content */} - -
{children}
-
-
+
+
+ {/* Header */} + + {/* Content */} + +
{children}
+
+
+
); diff --git a/apps/web/app/(all)/[workspaceSlug]/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/layout.tsx new file mode 100644 index 00000000000..7cecc697fa4 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/apps/web/app/(all)/profile/layout.tsx b/apps/web/app/(all)/profile/layout.tsx index 21bc6566d8e..21e02480d30 100644 --- a/apps/web/app/(all)/profile/layout.tsx +++ b/apps/web/app/(all)/profile/layout.tsx @@ -19,7 +19,7 @@ export default function ProfileSettingsLayout(props: Props) { <> -
+
{children}
diff --git a/apps/web/ce/components/app-rail/index.ts b/apps/web/ce/components/app-rail/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/apps/web/ce/components/app-rail/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/apps/web/ce/components/app-rail/root.tsx b/apps/web/ce/components/app-rail/root.tsx new file mode 100644 index 00000000000..259764b2695 --- /dev/null +++ b/apps/web/ce/components/app-rail/root.tsx @@ -0,0 +1,4 @@ +"use client"; +import React from "react"; + +export const AppRailRoot = () => <>; diff --git a/apps/web/ce/components/sidebar/project-navigation-root.tsx b/apps/web/ce/components/sidebar/project-navigation-root.tsx index 6b269a0a014..25a0dd9d8ca 100644 --- a/apps/web/ce/components/sidebar/project-navigation-root.tsx +++ b/apps/web/ce/components/sidebar/project-navigation-root.tsx @@ -7,12 +7,9 @@ import { ProjectNavigation } from "@/components/workspace"; type TProjectItemsRootProps = { workspaceSlug: string; projectId: string; - isSidebarCollapsed: boolean; }; export const ProjectNavigationRoot: FC = (props) => { - const { workspaceSlug, projectId, isSidebarCollapsed } = props; - return ( - - ); + const { workspaceSlug, projectId } = props; + return ; }; diff --git a/apps/web/ce/components/workspace/app-switcher.tsx b/apps/web/ce/components/workspace/app-switcher.tsx new file mode 100644 index 00000000000..8650d2a56bf --- /dev/null +++ b/apps/web/ce/components/workspace/app-switcher.tsx @@ -0,0 +1,5 @@ +"use client"; + +import React from "react"; + +export const WorkspaceAppSwitcher = () => <>; diff --git a/apps/web/ce/components/workspace/content-wrapper.tsx b/apps/web/ce/components/workspace/content-wrapper.tsx new file mode 100644 index 00000000000..242db454206 --- /dev/null +++ b/apps/web/ce/components/workspace/content-wrapper.tsx @@ -0,0 +1,9 @@ +"use client"; +import React from "react"; +import { observer } from "mobx-react"; + +export const WorkspaceContentWrapper = observer(({ children }: { children: React.ReactNode }) => ( +
+
{children}
+
+)); diff --git a/apps/web/ce/components/workspace/index.ts b/apps/web/ce/components/workspace/index.ts index d68c6eca2f5..489ef6352cb 100644 --- a/apps/web/ce/components/workspace/index.ts +++ b/apps/web/ce/components/workspace/index.ts @@ -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"; diff --git a/apps/web/ce/components/workspace/sidebar/app-search.tsx b/apps/web/ce/components/workspace/sidebar/app-search.tsx index 01d7f014759..6b8a94f6ba2 100644 --- a/apps/web/ce/components/workspace/sidebar/app-search.tsx +++ b/apps/web/ce/components/workspace/sidebar/app-search.tsx @@ -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 ( ); }); diff --git a/apps/web/core/components/settings/header.tsx b/apps/web/core/components/settings/header.tsx index 0beb28c93ee..832f7f39b88 100644 --- a/apps/web/core/components/settings/header.tsx +++ b/apps/web/core/components/settings/header.tsx @@ -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 (
{ diff --git a/apps/web/core/components/settings/sidebar/root.tsx b/apps/web/core/components/settings/sidebar/root.tsx index c681a8cc11c..7a8c915edeb 100644 --- a/apps/web/core/components/settings/sidebar/root.tsx +++ b/apps/web/core/components/settings/sidebar/root.tsx @@ -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 */} {/* Navigation */} -
+ {categories.map((category) => { if (groupedSettings[category].length === 0) return null; return (
- {t(category)} + {t(category)}
{groupedSettings[category].map( (setting) => @@ -70,7 +74,7 @@ export const SettingsSidebar = observer((props: SettingsSidebarProps) => {
); })} -
+
); }); diff --git a/apps/web/core/components/sidebar/index.ts b/apps/web/core/components/sidebar/index.ts index b2e6f7602d8..c639b0bac80 100644 --- a/apps/web/core/components/sidebar/index.ts +++ b/apps/web/core/components/sidebar/index.ts @@ -1 +1,4 @@ -export * from "./sidebar-navigation"; \ No newline at end of file +export * from "./sidebar-navigation"; +export * from "./resizable-sidebar"; +export * from "./sidebar-item"; +export * from "./sidebar-toggle-button"; diff --git a/apps/web/core/components/sidebar/resizable-sidebar.tsx b/apps/web/core/components/sidebar/resizable-sidebar.tsx new file mode 100644 index 00000000000..9f0741297b4 --- /dev/null +++ b/apps/web/core/components/sidebar/resizable-sidebar.tsx @@ -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>; + 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>(); + + // 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 */} +
+ +
+ + {/* Peek Trigger Area */} + {isCollapsed && !disablePeekTrigger && ( +
+ )} + + {/* Peek View */} +
+ +
+ + {/* Extended Sidebar */} + {extendedSidebar && extendedSidebar} + + ); +} diff --git a/apps/web/core/components/sidebar/sidebar-item.tsx b/apps/web/core/components/sidebar/sidebar-item.tsx new file mode 100644 index 00000000000..48d0b0ccff9 --- /dev/null +++ b/apps/web/core/components/sidebar/sidebar-item.tsx @@ -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 = ({ highlight = false, label }) => { + if (!label) return null; + + return ( + + {label} + + ); +}; + +const AppSidebarItemIcon: React.FC = ({ icon, highlight }) => { + if (!icon) return null; + + return ( +
+ {icon} +
+ ); +}; + +const AppSidebarLinkItem: React.FC = ({ href, children, className }) => { + if (!href) return null; + + return ( + + {children} + + ); +}; + +const AppSidebarButtonItem: React.FC = ({ + children, + onClick, + disabled = false, + className, +}) => ( + +); + +// ============================================================================ +// MAIN COMPONENT +// ============================================================================ + +type AppSidebarItemComponent = React.FC & { + Label: React.FC; + Icon: React.FC; + Link: React.FC; + Button: React.FC; +}; + +const AppSidebarItem: AppSidebarItemComponent = ({ variant = "link", item }) => { + if (!item) return null; + + const { icon, isActive, label, href, onClick, disabled } = item; + + const commonItems = ( + <> + + + + ); + + if (variant === "link") { + return {commonItems}; + } + + return ( + + {commonItems} + + ); +}; + +// ============================================================================ +// COMPOUND COMPONENT ASSIGNMENT +// ============================================================================ + +AppSidebarItem.Label = AppSidebarItemLabel; +AppSidebarItem.Icon = AppSidebarItemIcon; +AppSidebarItem.Link = AppSidebarLinkItem; +AppSidebarItem.Button = AppSidebarButtonItem; + +export { AppSidebarItem }; +export type { AppSidebarItemData, AppSidebarItemProps }; diff --git a/apps/web/core/components/sidebar/sidebar-toggle-button.tsx b/apps/web/core/components/sidebar/sidebar-toggle-button.tsx new file mode 100644 index 00000000000..45caea0c6ae --- /dev/null +++ b/apps/web/core/components/sidebar/sidebar-toggle-button.tsx @@ -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 ( + + ); +}); diff --git a/apps/web/core/components/workspace-notifications/notification-app-sidebar-option.tsx b/apps/web/core/components/workspace-notifications/notification-app-sidebar-option.tsx index 358026806de..3a2e9071cfa 100644 --- a/apps/web/core/components/workspace-notifications/notification-app-sidebar-option.tsx +++ b/apps/web/core/components/workspace-notifications/notification-app-sidebar-option.tsx @@ -12,11 +12,10 @@ import { useWorkspaceNotifications } from "@/hooks/store"; type TNotificationAppSidebarOption = { workspaceSlug: string; - isSidebarCollapsed: boolean | undefined; }; export const NotificationAppSidebarOption: FC = observer((props) => { - const { workspaceSlug, isSidebarCollapsed } = props; + const { workspaceSlug } = props; // hooks const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications(); @@ -33,9 +32,6 @@ export const NotificationAppSidebarOption: FC = o if (totalNotifications <= 0) return <>; - if (isSidebarCollapsed) - return
; - return (
diff --git a/apps/web/core/components/workspace-notifications/sidebar/header/root.tsx b/apps/web/core/components/workspace-notifications/sidebar/header/root.tsx index 7ed9ea5289d..bd935b66570 100644 --- a/apps/web/core/components/workspace-notifications/sidebar/header/root.tsx +++ b/apps/web/core/components/workspace-notifications/sidebar/header/root.tsx @@ -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 = observer((props) => { const { workspaceSlug } = props; const { t } = useTranslation(); + const { sidebarCollapsed } = useAppTheme(); if (!workspaceSlug) return <>; return (
-
- -
+ {sidebarCollapsed && } + {
{workspace.name}
diff --git a/apps/web/core/components/workspace/sidebar/dropdown.tsx b/apps/web/core/components/workspace/sidebar/dropdown.tsx index d1d5e524974..e9f3adee0a2 100644 --- a/apps/web/core/components/workspace/sidebar/dropdown.tsx +++ b/apps/web/core/components/workspace/sidebar/dropdown.tsx @@ -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(null); - const [popperElement, setPopperElement] = useState(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 ( -
- - {({ open, close }) => ( - <> - -
- - {!sidebarCollapsed && ( -

- {activeWorkspace?.name ?? t("loading")} -

- )} -
- {!sidebarCollapsed && ( -
- - -
-
- - {currentUser?.email} - - {workspacesList ? ( -
- {(activeWorkspace - ? [ - activeWorkspace, - ...workspacesList.filter((workspace) => workspace.id !== activeWorkspace?.id), - ] - : workspacesList - ).map((workspace) => ( - - ))} -
- ) : ( -
- - - - -
- )} -
-
- {isWorkspaceCreationEnabled && ( - - - - {t("create_workspace")} - - - )} - - - - - {t("workspace_invites")} - - - -
- - - {t("sign_out")} - -
-
-
-
-
- - )} -
- - - - - - } - style={styles.popper} - {...attributes.popper} - > -
- {currentUser?.email} - - - - - {t("settings")} - - - -
-
- - - {t("sign_out")} - -
- {isUserInstanceAdmin && ( -
- - - - {t("enter_god_mode")} - - - -
- )} -
-
-
+
+ + {isAppRailEnabled && !shouldRenderAppRail && } +
); }); diff --git a/apps/web/core/components/workspace/sidebar/favorites/favorite-folder.tsx b/apps/web/core/components/workspace/sidebar/favorites/favorite-folder.tsx index 91f8addd865..bc9f1e9d930 100644 --- a/apps/web/core/components/workspace/sidebar/favorites/favorite-folder.tsx +++ b/apps/web/core/components/workspace/sidebar/favorites/favorite-folder.tsx @@ -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) => { 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) => { "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) => {
- {isSidebarCollapsed ? ( -
- + <> + +
-
+ + + +
+

{favorite.name}

- -
- ) : ( - <> - -
- - - - -
- -
-

{favorite.name}

-
+
+
+ + + + } + 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")} + > + handleRemoveFromFavorites(favorite)}> + + + Remove from favorites + + + setFolderToRename(favorite.id)}> +
+ + Rename Folder
- - - - +
+
+ 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")} - > - handleRemoveFromFavorites(favorite)}> - - - Remove from favorites - - - setFolderToRename(favorite.id)}> -
- - Rename Folder -
-
- - - - - - )} + )} + aria-label={t( + open ? "aria_labels.projects_sidebar.close_folder" : "aria_labels.projects_sidebar.open_folder" + )} + > + +
+
{favorite.children && favorite.children.length > 0 && ( = (props) => { leaveFrom="transform scale-100 opacity-100" leaveTo="transform scale-95 opacity-0" > - + {orderBy(favorite.children, "sequence", "desc").map((child, index) => ( = 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 ( - - + + {icon} - {!isSidebarCollapsed && {title}} + {title} ); diff --git a/apps/web/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-wrapper.tsx b/apps/web/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-wrapper.tsx index 9c3c64b8d06..7b96060850f 100644 --- a/apps/web/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-wrapper.tsx +++ b/apps/web/core/components/workspace/sidebar/favorites/favorite-items/common/favorite-item-wrapper.tsx @@ -7,28 +7,23 @@ type Props = { children: React.ReactNode; elementRef: React.RefObject; isMenuActive?: boolean; - sidebarCollapsed?: boolean; }; export const FavoriteItemWrapper: FC = (props) => { - const { children, elementRef, isMenuActive = false, sidebarCollapsed = false } = props; + const { children, elementRef, isMenuActive = false } = props; return ( <> - {sidebarCollapsed ? ( -
{children}
- ) : ( -
- {children} -
- )} +
+ {children} +
); }; diff --git a/apps/web/core/components/workspace/sidebar/favorites/favorite-items/root.tsx b/apps/web/core/components/workspace/sidebar/favorites/favorite-items/root.tsx index 49931802e84..1b5c9436e4c 100644 --- a/apps/web/core/components/workspace/sidebar/favorites/favorite-items/root.tsx +++ b/apps/web/core/components/workspace/sidebar/favorites/favorite-items/root.tsx @@ -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 = 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 = observer((props) => { const root = createRoot(container); root.render(
- +
); return () => root.unmount(); @@ -138,18 +131,16 @@ export const FavoriteRoot: FC = observer((props) => { return ( <> - - {!sidebarCollapsed && } - - {!sidebarCollapsed && ( - - )} + + + + {isLastChild && } diff --git a/apps/web/core/components/workspace/sidebar/favorites/favorites-menu.tsx b/apps/web/core/components/workspace/sidebar/favorites/favorites-menu.tsx index d2d30a37df9..607e572c9a3 100644 --- a/apps/web/core/components/workspace/sidebar/favorites/favorites-menu.tsx +++ b/apps/web/core/components/workspace/sidebar/favorites/favorites-menu.tsx @@ -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(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 ( <> - {!sidebarCollapsed && ( -
+ toggleFavoriteMenu(!isFavoriteMenuOpen)} + aria-label={t( + isFavoriteMenuOpen + ? "aria_labels.projects_sidebar.close_favorites_menu" + : "aria_labels.projects_sidebar.open_favorites_menu" + )} > + {t("favorites")} + +
+ + + toggleFavoriteMenu(!isFavoriteMenuOpen)} aria-label={t( isFavoriteMenuOpen @@ -217,42 +223,14 @@ export const SidebarFavoritesMenu = observer(() => { : "aria_labels.projects_sidebar.open_favorites_menu" )} > - {t("favorites")} + -
- - - - toggleFavoriteMenu(!isFavoriteMenuOpen)} - aria-label={t( - isFavoriteMenuOpen - ? "aria_labels.projects_sidebar.close_favorites_menu" - : "aria_labels.projects_sidebar.open_favorites_menu" - )} - > - - -
- )} +
{ leaveTo="transform scale-95 opacity-0" > {isFavoriteMenuOpen && ( - + {createNewFolder && } {Object.keys(groupedFavorites).length === 0 ? ( <> - {!sidebarCollapsed && ( - - {t("no_favorites_yet")} - - )} + {t("no_favorites_yet")} ) : ( orderBy(Object.values(groupedFavorites), "sequence", "desc") .filter((fav) => !fav.parent) .map((fav, index, { length }) => ( <> - {fav?.id && ( - - {fav?.is_folder ? ( - - ) : ( - - )} - + {fav?.is_folder ? ( + + ) : ( + )} )) @@ -320,10 +277,6 @@ export const SidebarFavoritesMenu = observer(() => { )}
- - {sidebarCollapsed && favoriteIds.length > 0 && ( -
- )} ); }); diff --git a/apps/web/core/components/workspace/sidebar/help-menu.tsx b/apps/web/core/components/workspace/sidebar/help-menu.tsx new file mode 100644 index 00000000000..3a1dc8320a2 --- /dev/null +++ b/apps/web/core/components/workspace/sidebar/help-menu.tsx @@ -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>; +} + +export const HelpMenu: React.FC = 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 ( + <> + setProductUpdatesModalOpen(false)} /> +
+ + + + +
+ } + 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 + > + + + + {t("documentation")} + + + {config?.intercom_app_id && config?.is_intercom_enabled && ( + + + + )} + + + + {t("contact_sales")} + + +
+ +
{ + e.preventDefault(); + e.stopPropagation(); + }} + className="flex w-full items-center justify-between text-xs hover:bg-custom-background-80" + > + {t("hyper_mode")} + toggleLocalDB(workspaceSlug?.toString(), projectId?.toString())} + /> +
+
+ + + + + + + + + Discord + + +
+ +
+ +
+ + ); +}); diff --git a/apps/web/core/components/workspace/sidebar/help-section.tsx b/apps/web/core/components/workspace/sidebar/help-section.tsx index 73f036cd412..5aff74d568c 100644 --- a/apps/web/core/components/workspace/sidebar/help-section.tsx +++ b/apps/web/core/components/workspace/sidebar/help-section.tsx @@ -26,7 +26,7 @@ export const SidebarHelpSection: React.FC = 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 = observer( toggleIntercom(!isIntercomToggle); }; - const isCollapsed = sidebarCollapsed || false; - return ( <> setProductUpdatesModalOpen(false)} /> -
-
+
+
= observer(
} - 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 = observer(
-
+
-
+
+ + )} + + + + {t("contact_sales")} + + +
+ +
{ + e.preventDefault(); + e.stopPropagation(); + }} + className="flex w-full items-center justify-between text-xs hover:bg-custom-background-80" + > + {t("hyper_mode")} + toggleLocalDB(workspaceSlug?.toString(), projectId?.toString())} + /> +
+
+ + + + + + + + + Discord + + +
+ +
+ + + ); +}); diff --git a/apps/web/core/components/workspace/sidebar/index.ts b/apps/web/core/components/workspace/sidebar/index.ts index 0a1f2a920c7..8c18ceae6b3 100644 --- a/apps/web/core/components/workspace/sidebar/index.ts +++ b/apps/web/core/components/workspace/sidebar/index.ts @@ -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"; diff --git a/apps/web/core/components/workspace/sidebar/project-navigation.tsx b/apps/web/core/components/workspace/sidebar/project-navigation.tsx index a6176bed834..754f45de5c7 100644 --- a/apps/web/core/components/workspace/sidebar/project-navigation.tsx +++ b/apps/web/core/components/workspace/sidebar/project-navigation.tsx @@ -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 = 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 = observer((props) => { if (!hasAccess) return null; return ( - - - -
- - {!isSidebarCollapsed && {t(item.i18n_key)}} -
-
- -
+ + +
+ + {t(item.i18n_key)} +
+
+ ); })} diff --git a/apps/web/core/components/workspace/sidebar/projects-list-item.tsx b/apps/web/core/components/workspace/sidebar/projects-list-item.tsx index 37a1ebb45ba..714c36eca5a 100644 --- a/apps/web/core/components/workspace/sidebar/projects-list-item.tsx +++ b/apps/web/core/components/workspace/sidebar/projects-list-item.tsx @@ -57,12 +57,13 @@ export const SidebarProjectsListItem: React.FC = 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 = 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 = 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 = 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 = 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 = 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 = observer((props) => { )} - {isSidebarCollapsed ? ( + <> - +
+

{project.name}

- ) : ( - <> - - setIsMenuActive(!isMenuActive)} > - -
- -
-

{project.name}

-
-
-
- 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, } - 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 && ( @@ -333,82 +312,81 @@ export const SidebarProjectsListItem: React.FC = observer((props) => { )} */} - {/* publish project settings */} - {isAdmin && ( - setPublishModal(true)}> -
-
- -
-
{t("publish_project")}
+ {/* publish project settings */} + {isAdmin && ( + setPublishModal(true)}> +
+
+
- - )} - - - - {t("copy_link")} - +
{t("publish_project")}
+
- {isAuthorized && ( - { - router.push(`/${workspaceSlug}/projects/${project?.id}/archives/issues`); - }} - > -
- - {t("archives")} -
-
- )} + )} + + + + {t("copy_link")} + + + {isAuthorized && ( { - router.push(`/${workspaceSlug}/settings/projects/${project?.id}`); + router.push(`/${workspaceSlug}/projects/${project?.id}/archives/issues`); }} >
- - {t("settings")} + + {t("archives")}
- {/* leave project */} - {!isAuthorized && ( - -
- - {t("leave_project")} -
-
- )} - - setIsProjectListOpen(!isProjectListOpen)} - aria-label={t( - isProjectListOpen - ? "aria_labels.projects_sidebar.close_project_menu" - : "aria_labels.projects_sidebar.open_project_menu" - )} + )} + { + router.push(`/${workspaceSlug}/settings/projects/${project?.id}`); + }} > - - - - )} +
+ + {t("settings")} +
+ + {/* leave project */} + {!isAuthorized && ( + +
+ + {t("leave_project")} +
+
+ )} + + setIsProjectListOpen(!isProjectListOpen)} + aria-label={t( + isProjectListOpen + ? "aria_labels.projects_sidebar.close_project_menu" + : "aria_labels.projects_sidebar.open_project_menu" + )} + > + + +
= observer((props) => { > {isProjectListOpen && ( - + )} diff --git a/apps/web/core/components/workspace/sidebar/projects-list.tsx b/apps/web/core/components/workspace/sidebar/projects-list.tsx index 14419ffe882..1f4a8f71ff0 100644 --- a/apps/web/core/components/workspace/sidebar/projects-list.tsx +++ b/apps/web/core/components/workspace/sidebar/projects-list.tsx @@ -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(() => { > <> -
+
toggleListDisclosure(!isAllProjectsListOpen)} aria-label={t( isAllProjectsListOpen @@ -176,52 +160,42 @@ export const SidebarProjectsList: FC = observer(() => { : "aria_labels.projects_sidebar.open_projects_menu" )} > - - <> - {isCollapsed ? ( - - ) : ( - {t("projects")} - )} - - + {t("projects")} - {!isCollapsed && ( -
- {isAuthorizedUser && ( - - - +
+ {isAuthorizedUser && ( + + + + )} + toggleListDisclosure(!isAllProjectsListOpen)} + aria-label={t( + isAllProjectsListOpen + ? "aria_labels.projects_sidebar.close_projects_menu" + : "aria_labels.projects_sidebar.open_projects_menu" )} - toggleListDisclosure(!isAllProjectsListOpen)} - aria-label={t( - isAllProjectsListOpen - ? "aria_labels.projects_sidebar.close_projects_menu" - : "aria_labels.projects_sidebar.open_projects_menu" - )} - > - - -
- )} + > + + +
{ )} {isAllProjectsListOpen && ( - + <> {joinedProjects.map((projectId, index) => ( { {isAuthorizedUser && joinedProjects?.length === 0 && ( )}
diff --git a/apps/web/core/components/workspace/sidebar/quick-actions.tsx b/apps/web/core/components/workspace/sidebar/quick-actions.tsx index b553c40a480..8784472331a 100644 --- a/apps/web/core/components/workspace/sidebar/quick-actions.tsx +++ b/apps/web/core/components/workspace/sidebar/quick-actions.tsx @@ -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 /> -
+
diff --git a/apps/web/core/components/workspace/sidebar/sidebar-menu-items.tsx b/apps/web/core/components/workspace/sidebar/sidebar-menu-items.tsx index 89c0f150eaa..3a2676c816a 100644 --- a/apps/web/core/components/workspace/sidebar/sidebar-menu-items.tsx +++ b/apps/web/core/components/workspace/sidebar/sidebar-menu-items.tsx @@ -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( + "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 ( -
- {WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS.map((item, _index) => ( - - ))} - {sortedNavigationItems.map((item, _index) => ( - - ))} - - - -
+ {isWorkspaceMenuOpen && ( + + <> + {WORKSPACE_SIDEBAR_STATIC_PINNED_NAVIGATION_ITEMS_LINKS.map((item, _index) => ( + + ))} + {sortedNavigationItems.map((item, _index) => ( + + ))} + + + + + + )} + + + ); }); diff --git a/apps/web/core/components/workspace/sidebar/user-menu-item.tsx b/apps/web/core/components/workspace/sidebar/user-menu-item.tsx index 34e056400b4..5d0b555dc1b 100644 --- a/apps/web/core/components/workspace/sidebar/user-menu-item.tsx +++ b/apps/web/core/components/workspace/sidebar/user-menu-item.tsx @@ -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 = 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 = observer((props }; return ( - - handleLinkClick(item.key)}> - -
- - {!sidebarCollapsed &&

{t(item.labelTranslationKey)}

} -
- {item.key === "notifications" && ( - - )} -
- -
+ handleLinkClick(item.key)}> + +
+ +

{t(item.labelTranslationKey)}

+
+ {item.key === "notifications" && } +
+ ); }); diff --git a/apps/web/core/components/workspace/sidebar/user-menu-root.tsx b/apps/web/core/components/workspace/sidebar/user-menu-root.tsx new file mode 100644 index 00000000000..1dda949959c --- /dev/null +++ b/apps/web/core/components/workspace/sidebar/user-menu-root.tsx @@ -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(null); + const [popperElement, setPopperElement] = useState(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 ( + + {({ open, close }: { open: boolean; close: () => void }) => { + // Update local state directly + if (isUserMenuOpen !== open) { + setIsUserMenuOpen(open); + } + + return ( + <> + + + + + } + style={styles.popper} + {...attributes.popper} + > +
+ {currentUser?.email} + + + + + {t("settings")} + + + + {isEnabled && ( + { + if (sidebarPeek) toggleSidebarPeek(false); + toggleAppRail(); + }} + > + + {shouldRenderAppRail ? "Undock AppRail" : "Dock AppRail"} + + )} +
+
+ + + {t("sign_out")} + +
+ {isUserInstanceAdmin && ( +
+ + + + {t("enter_god_mode")} + + + +
+ )} +
+
+ + ); + }} +
+ ); +}); diff --git a/apps/web/core/components/workspace/sidebar/user-menu.tsx b/apps/web/core/components/workspace/sidebar/user-menu.tsx index 762d55a3afe..9826a77b91a 100644 --- a/apps/web/core/components/workspace/sidebar/user-menu.tsx +++ b/apps/web/core/components/workspace/sidebar/user-menu.tsx @@ -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 ( -
+
{SIDEBAR_USER_MENU_ITEMS.map((item) => ( ))} diff --git a/apps/web/core/components/workspace/sidebar/workspace-menu-header.tsx b/apps/web/core/components/workspace/sidebar/workspace-menu-header.tsx index 26e0951c909..0d2a736f3a2 100644 --- a/apps/web/core/components/workspace/sidebar/workspace-menu-header.tsx +++ b/apps/web/core/components/workspace/sidebar/workspace-menu-header.tsx @@ -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 = o const actionSectionRef = useRef(null); // hooks const { workspaceSlug } = useParams(); - const { sidebarCollapsed } = useAppTheme(); const { allowPermissions } = useUserPermissions(); const { t } = useTranslation(); @@ -37,19 +36,8 @@ export const SidebarWorkspaceMenuHeader: FC = o // eslint-disable-next-line @typescript-eslint/no-explicit-any const isAdmin = allowPermissions([EUserWorkspaceRoles.ADMIN] as any, EUserPermissionsLevel.WORKSPACE); - if (sidebarCollapsed) { - return <>; - } - return ( -
+
= 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 = obser const isActive = item.href === pathname; return ( - - handleLinkClick()}> - -
- - {!sidebarCollapsed &&

{t(item.labelTranslationKey)}

} -
- {!sidebarCollapsed && item.key === "active_cycles" && ( -
- -
- )} -
- -
+ handleLinkClick()}> + +
+ +

{t(item.labelTranslationKey)}

+
+
+ +
+
+ ); }); diff --git a/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx b/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx new file mode 100644 index 00000000000..c6f1d3ec2ce --- /dev/null +++ b/apps/web/core/components/workspace/sidebar/workspace-menu-root.tsx @@ -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 ( + + {({ open, close }: { open: boolean; close: () => void }) => { + // Update local state directly + if (isWorkspaceMenuOpen !== open) { + setIsWorkspaceMenuOpen(open); + } + + return ( + <> + {renderLogoOnly ? ( + + + ), + }} + /> + + ) : ( + +
+ +

+ {activeWorkspace?.name ?? t("loading")} +

+
+
+ )} + + + +
+
+ + {currentUser?.email} + + {workspacesList ? ( +
+ {(activeWorkspace + ? [ + activeWorkspace, + ...workspacesList.filter((workspace) => workspace.id !== activeWorkspace?.id), + ] + : workspacesList + ).map((workspace) => ( + + ))} +
+ ) : ( +
+ + + + +
+ )} +
+
+ {isWorkspaceCreationEnabled && ( + + + + {t("create_workspace")} + + + )} + + + + + {t("workspace_invites")} + + + +
+ + + {t("sign_out")} + +
+
+
+
+
+ + ); + }} +
+ ); +}); diff --git a/apps/web/core/components/workspace/sidebar/workspace-menu.tsx b/apps/web/core/components/workspace/sidebar/workspace-menu.tsx index 069b781def1..a43224d8df0 100644 --- a/apps/web/core/components/workspace/sidebar/workspace-menu.tsx +++ b/apps/web/core/components/workspace/sidebar/workspace-menu.tsx @@ -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("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 && ( - + {SIDEBAR_WORKSPACE_MENU_ITEMS.map((item) => ( ))} diff --git a/apps/web/core/hooks/context/app-rail-context.tsx b/apps/web/core/hooks/context/app-rail-context.tsx new file mode 100644 index 00000000000..6fe902cdd88 --- /dev/null +++ b/apps/web/core/hooks/context/app-rail-context.tsx @@ -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(undefined); + +export { AppRailContext }; + +interface AppRailProviderProps { + children: ReactNode; +} + +export const AppRailProvider = observer(({ children }: AppRailProviderProps) => { + const { workspaceSlug } = useParams(); + const { storedValue: isAppRailVisible, setValue: setIsAppRailVisible } = useLocalStorage( + `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 {children}; +}); diff --git a/apps/web/core/hooks/use-app-rail.tsx b/apps/web/core/hooks/use-app-rail.tsx new file mode 100644 index 00000000000..5318f80df7e --- /dev/null +++ b/apps/web/core/hooks/use-app-rail.tsx @@ -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; +}; diff --git a/apps/web/core/hooks/use-workspace-paths.ts b/apps/web/core/hooks/use-workspace-paths.ts new file mode 100644 index 00000000000..fd0e742191f --- /dev/null +++ b/apps/web/core/hooks/use-workspace-paths.ts @@ -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, + }; +}; diff --git a/apps/web/core/layouts/auth-layout/project-wrapper.tsx b/apps/web/core/layouts/auth-layout/project-wrapper.tsx index a11732b20d9..0bee5655c22 100644 --- a/apps/web/core/layouts/auth-layout/project-wrapper.tsx +++ b/apps/web/core/layouts/auth-layout/project-wrapper.tsx @@ -165,7 +165,7 @@ export const ProjectAuthWrapper: FC = observer((props) => { // check if the project member apis is loading if (isParentLoading || (!projectMemberInfo && projectId && hasPermissionToCurrentProject === null)) return ( -
+
@@ -183,7 +183,7 @@ export const ProjectAuthWrapper: FC = observer((props) => { // check if the project info is not found. if (loader === "loaded" && projectId && !!hasPermissionToCurrentProject === false) return ( -
+
= observer((props) // if list of workspaces are not there then we have to render the spinner if (isParentLoading || allWorkspaces === undefined || loader || isDBInitializing) { return ( -
+
@@ -147,7 +146,7 @@ export const WorkspaceAuthWrapper: FC = 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 ( -
+
diff --git a/apps/web/core/store/theme.store.ts b/apps/web/core/store/theme.store.ts index b37fb0ef5f3..e788089e10e 100644 --- a/apps/web/core/store/theme.store.ts +++ b/apps/web/core/store/theme.store.ts @@ -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()); }; /** diff --git a/apps/web/ee/components/app-rail/index.ts b/apps/web/ee/components/app-rail/index.ts new file mode 100644 index 00000000000..68889686dcf --- /dev/null +++ b/apps/web/ee/components/app-rail/index.ts @@ -0,0 +1 @@ +export * from "ce/components/app-rail"; diff --git a/apps/web/ee/components/workspace/index.ts b/apps/web/ee/components/workspace/index.ts index b0b8ffd3ad0..4e148736d6a 100644 --- a/apps/web/ee/components/workspace/index.ts +++ b/apps/web/ee/components/workspace/index.ts @@ -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"; diff --git a/apps/web/ee/components/workspace/upgrade-badge.tsx b/apps/web/ee/components/workspace/upgrade-badge.tsx index 1c7fcfb644c..d12716555fc 100644 --- a/apps/web/ee/components/workspace/upgrade-badge.tsx +++ b/apps/web/ee/components/workspace/upgrade-badge.tsx @@ -1 +1,2 @@ export * from "ce/components/workspace/upgrade-badge"; +export * from "ce/components/workspace/content-wrapper"; diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index d7ccebd319e..045538f3a03 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -35,3 +35,4 @@ export * from "./settings"; export * from "./icon"; export * from "./estimates"; export * from "./analytics"; +export * from "./sidebar"; diff --git a/packages/constants/src/sidebar.ts b/packages/constants/src/sidebar.ts new file mode 100644 index 00000000000..7468597549c --- /dev/null +++ b/packages/constants/src/sidebar.ts @@ -0,0 +1,2 @@ +export const SIDEBAR_WIDTH = 250; +export const EXTENDED_SIDEBAR_WIDTH = 300; diff --git a/packages/constants/src/workspace.ts b/packages/constants/src/workspace.ts index 03493253843..24bcd8fa893 100644 --- a/packages/constants/src/workspace.ts +++ b/packages/constants/src/workspace.ts @@ -317,6 +317,9 @@ export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record = ({ width = "16", height = "16", className, color = "currentColor" }) => ( + + + + + + + + + + + +); diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index 143c3d79a7c..500925e3c6c 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -52,3 +52,6 @@ export * from "./sticky-note-icon"; export * from "./bar-icon"; export * from "./tree-map-icon"; export * from "./display-properties"; +export * from "./ai-icon"; +export * from "./plane-icon"; +export * from "./wiki-icon"; diff --git a/packages/ui/src/icons/plane-icon.tsx b/packages/ui/src/icons/plane-icon.tsx new file mode 100644 index 00000000000..f56e8e03ee0 --- /dev/null +++ b/packages/ui/src/icons/plane-icon.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const PlaneNewIcon: React.FC = ({ + width = "16", + height = "16", + className, + color = "currentColor", +}) => ( + + + + + + + + + + + +); diff --git a/packages/ui/src/icons/wiki-icon.tsx b/packages/ui/src/icons/wiki-icon.tsx new file mode 100644 index 00000000000..7ef090714c7 --- /dev/null +++ b/packages/ui/src/icons/wiki-icon.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const WikiIcon: React.FC = ({ width = "16", height = "16", className, color = "currentColor" }) => ( + + + + + + + + + + +);