[WEB-3291] dev: app sidebar revamp (#6578)

* chore: workspace constant and types updated

* chore: workspace service, store and app theme store updated

* dev: extended sidebar implementation and code refactor

* chore: ux improvements

* chore: sidebar preference endpoint updated

* chore: sidebar preference endpoint updated

* chore: sidebar preference endpoint updated

* chore: code refactor

* chore: code refactor

* chore: radix-ui react-scroll-area added to plane ui package

* chore: scrollbar color token added to tailwind config

* dev: scroll area component

* chore-scroll-area-component-improvement

* fix: build error

* chore: code refactor

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
This commit is contained in:
Anmol Singh Bhatia 2025-02-17 23:46:55 +05:30 committed by GitHub
parent a9aeeb6707
commit 473932af0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1155 additions and 253 deletions

View file

@ -30,7 +30,6 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
keys = [
key
for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices
if key not in ["projects"]
]
for preference in keys:
@ -40,20 +39,28 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
preference = WorkspaceUserPreference.objects.bulk_create(
[
WorkspaceUserPreference(
key=key, user=request.user, workspace=workspace
key=key, user=request.user, workspace=workspace, sort_order=(65535 + (i*10000))
)
for key in create_preference_keys
for i, key in enumerate(create_preference_keys)
],
batch_size=10,
ignore_conflicts=True,
)
preference = WorkspaceUserPreference.objects.filter(
preferences = WorkspaceUserPreference.objects.filter(
user=request.user, workspace_id=workspace.id
)
).order_by("sort_order").values("key", "is_pinned", "sort_order")
user_preferences = {}
for preference in preferences:
user_preferences[(str(preference["key"]))] = {
"is_pinned": preference["is_pinned"],
"sort_order": preference["sort_order"],
}
return Response(
preference.values("key", "is_pinned", "sort_order"),
user_preferences,
status=status.HTTP_200_OK,
)

View file

@ -391,13 +391,13 @@ class WorkspaceHomePreference(BaseModel):
class WorkspaceUserPreference(BaseModel):
"""Preference for the workspace for a user"""
class UserPreferenceKeys(models.TextChoices):
PROJECTS = "projects", "Projects"
ANALYTICS = "analytics", "Analytics"
CYCLES = "cycles", "Cycles"
class UserPreferenceKeys(models.TextChoices):
VIEWS = "views", "Views"
ACTIVE_CYCLES = "active_cycles", "Active Cycles"
ANALYTICS = "analytics", "Analytics"
DRAFTS = "drafts", "Drafts"
YOUR_WORK = "your_work", "Your Work"
ARCHIVES = "archives", "Archives"
workspace = models.ForeignKey(
"db.Workspace",

View file

@ -84,48 +84,42 @@ export const WORKSPACE_SETTINGS = {
i18n_label: "workspace_settings.settings.general.title",
href: `/settings`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) =>
pathname === `${baseUrl}/settings/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`,
},
members: {
key: "members",
i18n_label: "workspace_settings.settings.members.title",
href: `/settings/members`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) =>
pathname === `${baseUrl}/settings/members/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
},
"billing-and-plans": {
key: "billing-and-plans",
i18n_label: "workspace_settings.settings.billing_and_plans.title",
href: `/settings/billing`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) =>
pathname === `${baseUrl}/settings/billing/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing/`,
},
export: {
key: "export",
i18n_label: "workspace_settings.settings.exports.title",
href: `/settings/exports`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) =>
pathname === `${baseUrl}/settings/exports/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports/`,
},
webhooks: {
key: "webhooks",
i18n_label: "workspace_settings.settings.webhooks.title",
href: `/settings/webhooks`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) =>
pathname === `${baseUrl}/settings/webhooks/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`,
},
"api-tokens": {
key: "api-tokens",
i18n_label: "workspace_settings.settings.api_tokens.title",
href: `/settings/api-tokens`,
access: [EUserWorkspaceRoles.ADMIN],
highlight: (pathname: string, baseUrl: string) =>
pathname === `${baseUrl}/settings/api-tokens/`,
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`,
},
};
@ -256,3 +250,84 @@ export const DEFAULT_GLOBAL_VIEWS_LIST: {
i18n_label: "default_global_view.subscribed",
},
];
export interface IWorkspaceSidebarNavigationItem {
key: string;
labelTranslationKey: string;
href: string;
access: EUserWorkspaceRoles[];
}
export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspaceSidebarNavigationItem> = {
"your-work": {
key: "your_work",
labelTranslationKey: "your_work",
href: `/profile/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
},
views: {
key: "views",
labelTranslationKey: "views",
href: `/workspace-views/all-issues/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
},
"active-cycles": {
key: "active_cycles",
labelTranslationKey: "cycles",
href: `/active-cycles/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
},
analytics: {
key: "analytics",
labelTranslationKey: "analytics",
href: `/analytics/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
},
drafts: {
key: "drafts",
labelTranslationKey: "drafts",
href: `/drafts/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
},
archives: {
key: "archives",
labelTranslationKey: "archives",
href: `/projects/archives/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
},
};
export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["views"],
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["active-cycles"],
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["analytics"],
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["your-work"],
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["drafts"],
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["archives"],
];
export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspaceSidebarNavigationItem> = {
home: {
key: "home",
labelTranslationKey: "home.title",
href: `/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
},
notifications: {
key: "notifications",
labelTranslationKey: "notification.label",
href: `/notifications/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
},
projects: {
key: "projects",
labelTranslationKey: "projects",
href: `/projects/`,
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
},
};
export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["home"],
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["notifications"],
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["projects"],
];

View file

@ -8,9 +8,9 @@ export const useOutsideClickDetector = (
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as any)) {
// check for the closest element with attribute name data-prevent-outside-click
const preventOutsideClickElement = (
event.target as unknown as HTMLElement | undefined
)?.closest("[data-prevent-outside-click]");
const preventOutsideClickElement = (event.target as unknown as HTMLElement | undefined)?.closest(
"[data-prevent-outside-click]"
);
// if the closest element with attribute name data-prevent-outside-click is found, return
if (preventOutsideClickElement) {
return;

View file

@ -1,10 +1,4 @@
import type {
ICycle,
IProjectMember,
IUser,
IUserLite,
IWorkspaceViewProps,
} from "@plane/types";
import type { ICycle, IProjectMember, IUser, IUserLite, IWorkspaceViewProps, TPaginationInfo } from "@plane/types";
import { EUserWorkspaceRoles } from "@plane/constants"; // TODO: check if importing this over here causes circular dependency
import { TUserPermissions } from "./enums";
@ -229,3 +223,13 @@ export interface IWorkspaceAnalyticsResponse {
export type TWorkspacePaginationInfo = TPaginationInfo & {
results: IWorkspace[];
};
export interface IWorkspaceSidebarNavigationItem {
key?: string;
is_pinned: boolean;
sort_order: number;
}
export interface IWorkspaceSidebarNavigation {
[key: string]: IWorkspaceSidebarNavigationItem;
}

View file

@ -2,20 +2,13 @@ import * as React from "react";
import { ISvgIcons } from "./type";
export const OverviewIcon: React.FC<ISvgIcons> = ({ width = "16", height = "16", className = "" }) => (
<svg
width={width}
height={height}
className={className}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
export const OverviewIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg viewBox="0 0 16 16" className={className} xmlns="http://www.w3.org/2000/svg" {...rest}>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M2.5 3C2.5 2.86739 2.55268 2.74021 2.64645 2.64645C2.74021 2.55268 2.86739 2.5 3 2.5H3.5C9.02267 2.5 13.5 6.97733 13.5 12.5V13C13.5 13.1326 13.4473 13.2598 13.3536 13.3536C13.2598 13.4473 13.1326 13.5 13 13.5H12.5C12.3674 13.5 12.2402 13.4473 12.1464 13.3536C12.0527 13.2598 12 13.1326 12 13V12.5C12 7.80533 8.19467 4 3.5 4H3C2.86739 4 2.74021 3.94732 2.64645 3.85355C2.55268 3.75979 2.5 3.63261 2.5 3.5V3ZM2.5 7.5C2.5 7.36739 2.55268 7.24022 2.64645 7.14645C2.74021 7.05268 2.86739 7 3 7H3.5C4.22227 7 4.93747 7.14226 5.60476 7.41866C6.27205 7.69506 6.87837 8.10019 7.38909 8.61091C7.89981 9.12164 8.30494 9.72795 8.58134 10.3952C8.85774 11.0625 9 11.7777 9 12.5V13C9 13.1326 8.94732 13.2598 8.85355 13.3536C8.75978 13.4473 8.63261 13.5 8.5 13.5H8C7.86739 13.5 7.74022 13.4473 7.64645 13.3536C7.55268 13.2598 7.5 13.1326 7.5 13V12.5C7.5 11.4391 7.07857 10.4217 6.32843 9.67157C5.57828 8.92143 4.56087 8.5 3.5 8.5H3C2.86739 8.5 2.74021 8.44732 2.64645 8.35355C2.55268 8.25978 2.5 8.13261 2.5 8V7.5ZM2.5 12.5C2.5 12.2348 2.60536 11.9804 2.79289 11.7929C2.98043 11.6054 3.23478 11.5 3.5 11.5C3.76522 11.5 4.01957 11.6054 4.20711 11.7929C4.39464 11.9804 4.5 12.2348 4.5 12.5C4.5 12.7652 4.39464 13.0196 4.20711 13.2071C4.01957 13.3946 3.76522 13.5 3.5 13.5C3.23478 13.5 2.98043 13.3946 2.79289 13.2071C2.60536 13.0196 2.5 12.7652 2.5 12.5Z"
fill="#455068"
fill="currentColor"
/>
</svg>
);

View file

@ -0,0 +1,155 @@
"use client";
import React, { useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { Plus, Search } from "lucide-react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { CreateProjectModal } from "@/components/project";
import { SidebarProjectsListItem } from "@/components/workspace";
// hooks
import { orderJoinedProjects } from "@/helpers/project.helper";
import { useAppTheme, useProject, useUserPermissions } from "@/hooks/store";
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
import { TProject } from "@/plane-web/types";
export const ExtendedProjectSidebar = observer(() => {
// refs
const extendedProjectSidebarRef = useRef<HTMLDivElement | null>(null);
const [searchQuery, setSearchQuery] = useState<string>("");
// states
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
// routers
const { workspaceSlug } = useParams();
// store hooks
const { t } = useTranslation();
const { sidebarCollapsed, extendedProjectSidebarCollapsed, toggleExtendedProjectSidebar } = useAppTheme();
const { getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject();
const { allowPermissions } = useUserPermissions();
const handleOnProjectDrop = (
sourceId: string | undefined,
destinationId: string | undefined,
shouldDropAtEnd: boolean
) => {
if (!sourceId || !destinationId || !workspaceSlug) return;
if (sourceId === destinationId) return;
const joinedProjectsList: TProject[] = [];
joinedProjects.map((projectId) => {
const projectDetails = getPartialProjectById(projectId);
if (projectDetails) joinedProjectsList.push(projectDetails);
});
const sourceIndex = joinedProjects.indexOf(sourceId);
const destinationIndex = shouldDropAtEnd ? joinedProjects.length : joinedProjects.indexOf(destinationId);
if (joinedProjectsList.length <= 0) return;
const updatedSortOrder = orderJoinedProjects(sourceIndex, destinationIndex, sourceId, joinedProjectsList);
if (updatedSortOrder != undefined)
updateProjectView(workspaceSlug.toString(), sourceId, { sort_order: updatedSortOrder }).catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("error"),
message: t("something_went_wrong"),
});
});
};
// filter projects based on search query
const filteredProjects = joinedProjects.filter((projectId) => {
const project = getPartialProjectById(projectId);
if (!project) return false;
return project.name.toLowerCase().includes(searchQuery.toLowerCase()) || project.identifier.includes(searchQuery);
});
// auth
const isAuthorizedUser = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
useExtendedSidebarOutsideClickDetector(
extendedProjectSidebarRef,
() => {
if (!isProjectModalOpen) {
toggleExtendedProjectSidebar(false);
}
},
"extended-project-sidebar-toggle"
);
return (
<>
{workspaceSlug && (
<CreateProjectModal
isOpen={isProjectModalOpen}
onClose={() => setIsProjectModalOpen(false)}
setToFavorite={false}
workspaceSlug={workspaceSlug.toString()}
/>
)}
<div
ref={extendedProjectSidebarRef}
className={cn(
"fixed 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 p-4 shadow-md",
{
"translate-x-0 opacity-100": extendedProjectSidebarCollapsed,
"-translate-x-full opacity-0": !extendedProjectSidebarCollapsed,
"left-[70px]": sidebarCollapsed,
"left-[250px]": !sidebarCollapsed,
}
)}
>
<div className="flex flex-col gap-1 w-full">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-custom-text-300 py-1.5">Projects</span>
{isAuthorizedUser && (
<Tooltip tooltipHeading={t("create_project")} tooltipContent="">
<button
type="button"
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
onClick={() => {
setIsProjectModalOpen(true);
}}
>
<Plus className="size-3" />
</button>
</Tooltip>
)}
</div>
<div className="ml-auto flex items-center gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1 w-full">
<Search className="h-3.5 w-3.5 text-custom-text-400" />
<input
className="w-full max-w-[234px] border-none bg-transparent text-sm outline-none placeholder:text-custom-text-400"
placeholder={t("search")}
value={searchQuery}
autoFocus
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-0.5">
{filteredProjects.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
handleCopyText={() => {}}
projectListType={"JOINED"}
disableDrag={false}
disableDrop={false}
isLastChild={index === joinedProjects.length - 1}
handleOnProjectDrop={handleOnProjectDrop}
/>
))}
</div>
</div>
</>
);
});

View file

@ -0,0 +1,126 @@
"use client";
import React, { useMemo, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserWorkspaceRoles, WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants";
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";
export const ExtendedAppSidebar = observer(() => {
// refs
const extendedSidebarRef = useRef<HTMLDivElement | null>(null);
// routers
const { workspaceSlug } = useParams();
// store hooks
const { sidebarCollapsed, extendedSidebarCollapsed, toggleExtendedSidebar } = useAppTheme();
const { updateSidebarPreference, getNavigationPreferences } = useWorkspace();
// derived values
const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString());
const sortedNavigationItems = useMemo(
() =>
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => {
const preference = currentWorkspaceNavigationPreferences?.[item.key];
return {
...item,
sort_order: preference ? preference.sort_order : 0,
};
}).sort((a, b) => a.sort_order - b.sort_order),
[currentWorkspaceNavigationPreferences]
);
const sortedNavigationItemsKeys = sortedNavigationItems.map((item) => item.key);
const orderNavigationItem = (
sourceIndex: number,
destinationIndex: number,
navigationList: {
sort_order: number;
key: string;
labelTranslationKey: string;
href: string;
access: EUserWorkspaceRoles[];
}[]
): number | undefined => {
if (sourceIndex < 0 || destinationIndex < 0 || navigationList.length <= 0) return undefined;
let updatedSortOrder: number | undefined = undefined;
const sortOrderDefaultValue = 10000;
if (destinationIndex === 0) {
// updating project at the top of the project
const currentSortOrder = navigationList[destinationIndex].sort_order || 0;
updatedSortOrder = currentSortOrder - sortOrderDefaultValue;
} else if (destinationIndex === navigationList.length) {
// updating project at the bottom of the project
const currentSortOrder = navigationList[destinationIndex - 1].sort_order || 0;
updatedSortOrder = currentSortOrder + sortOrderDefaultValue;
} else {
// updating project in the middle of the project
const destinationTopProjectSortOrder = navigationList[destinationIndex - 1].sort_order || 0;
const destinationBottomProjectSortOrder = navigationList[destinationIndex].sort_order || 0;
const updatedValue = (destinationTopProjectSortOrder + destinationBottomProjectSortOrder) / 2;
updatedSortOrder = updatedValue;
}
return updatedSortOrder;
};
const handleOnNavigationItemDrop = (
sourceId: string | undefined,
destinationId: string | undefined,
shouldDropAtEnd: boolean
) => {
if (!sourceId || !destinationId || !workspaceSlug) return;
if (sourceId === destinationId) return;
const sourceIndex = sortedNavigationItemsKeys.indexOf(sourceId);
const destinationIndex = shouldDropAtEnd
? sortedNavigationItemsKeys.length
: sortedNavigationItemsKeys.indexOf(destinationId);
const updatedSortOrder = orderNavigationItem(sourceIndex, destinationIndex, sortedNavigationItems);
if (updatedSortOrder != undefined)
updateSidebarPreference(workspaceSlug.toString(), sourceId, {
sort_order: updatedSortOrder,
});
};
useExtendedSidebarOutsideClickDetector(
extendedSidebarRef,
() => toggleExtendedSidebar(false),
"extended-sidebar-toggle"
);
return (
<div
ref={extendedSidebarRef}
className={cn(
"fixed top-0 h-full z-[19] flex flex-col 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",
{
"translate-x-0 opacity-100": extendedSidebarCollapsed,
"-translate-x-full opacity-0": !extendedSidebarCollapsed,
"left-[70px]": sidebarCollapsed,
"left-[250px]": !sidebarCollapsed,
}
)}
>
{sortedNavigationItems.map((item, index) => (
<ExtendedSidebarItem
key={item.key}
item={item}
isLastChild={index === sortedNavigationItems.length - 1}
handleOnNavigationItemDrop={handleOnNavigationItemDrop}
/>
))}
</div>
);
});

View file

@ -5,16 +5,10 @@ import { observer } from "mobx-react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useOutsideClickDetector } from "@plane/hooks";
// components
import {
SidebarDropdown,
SidebarHelpSection,
SidebarProjectsList,
SidebarQuickActions,
SidebarUserMenu,
SidebarWorkspaceMenu,
} from "@/components/workspace";
// helpers
import { SidebarDropdown, SidebarHelpSection, SidebarProjectsList, SidebarQuickActions } from "@/components/workspace";
import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu";
import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme, useUserPermissions } from "@/hooks/store";
@ -23,6 +17,8 @@ import useSize from "@/hooks/use-window-size";
// plane web components
import { SidebarAppSwitcher } from "@/plane-web/components/sidebar";
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
@ -55,62 +51,63 @@ export const AppSidebar: FC = observer(() => {
const isFavoriteEmpty = isEmpty(groupedFavorites);
return (
<div
className={cn(
"fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-custom-sidebar-border-200 bg-custom-sidebar-background-100 duration-300 w-[250px] md:relative md:ml-0",
{
"w-[70px] -ml-[250px]": sidebarCollapsed,
}
)}
>
<>
<div
ref={ref}
className={cn("size-full flex flex-col flex-1 pt-4 pb-0", {
"p-2 pt-4": sidebarCollapsed,
})}
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={cn("px-2", {
"px-4": !sidebarCollapsed,
ref={ref}
className={cn("size-full flex flex-col flex-1 pt-4 pb-0", {
"p-2 pt-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,
})}
>
{/* User Menu */}
<SidebarUserMenu />
{/* Workspace Menu */}
<SidebarWorkspaceMenu />
<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,
})}
/>
{/* Favorites Menu */}
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
{/* Teams List */}
<SidebarTeamsList />
{/* Projects List */}
<SidebarProjectsList />
<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 />
<hr
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1", {
"opacity-0": !sidebarCollapsed,
})}
/>
{/* Favorites Menu */}
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
{/* Teams List */}
<SidebarTeamsList />
{/* Projects List */}
<SidebarProjectsList />
</div>
{/* Help Section */}
<SidebarHelpSection />
</div>
{/* Help Section */}
<SidebarHelpSection />
</div>
</div>
<ExtendedAppSidebar />
<ExtendedProjectSidebar />
</>
);
});

View file

@ -0,0 +1,220 @@
import { FC, useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { Eye, EyeClosed } from "lucide-react";
// plane imports
import { EUserPermissionsLevel, IWorkspaceSidebarNavigationItem } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { DragHandle, DropIndicator, Tooltip } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { SidebarNavItem } from "@/components/sidebar";
// hooks
import { useAppTheme, useUser, useUserPermissions, useWorkspace } from "@/hooks/store";
// plane web imports
// local imports
import { UpgradeBadge } from "../upgrade-badge";
import { getSidebarNavigationItemIcon } from "./helper";
type TExtendedSidebarItemProps = {
item: IWorkspaceSidebarNavigationItem;
handleOnNavigationItemDrop?: (
sourceId: string | undefined,
destinationId: string | undefined,
shouldDropAtEnd: boolean
) => void;
disableDrag?: boolean;
disableDrop?: boolean;
isLastChild: boolean;
};
export const ExtendedSidebarItem: FC<TExtendedSidebarItemProps> = observer((props) => {
const { item, handleOnNavigationItemDrop, disableDrag = false, disableDrop = false, isLastChild } = props;
const { t } = useTranslation();
// states
const [isDragging, setIsDragging] = useState(false);
const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined);
// refs
const navigationIemRef = useRef<HTMLDivElement | null>(null);
const dragHandleRef = useRef<HTMLButtonElement | null>(null);
// nextjs hooks
const pathname = usePathname();
const { workspaceSlug } = useParams();
// store hooks
const { getNavigationPreferences, updateSidebarPreference } = useWorkspace();
const { toggleExtendedSidebar } = useAppTheme();
const { data } = useUser();
const { allowPermissions } = useUserPermissions();
// derived values
const sidebarPreference = getNavigationPreferences(workspaceSlug.toString());
const isPinned = sidebarPreference?.[item.key]?.is_pinned;
const handleLinkClick = () => toggleExtendedSidebar();
if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) {
return null;
}
const itemHref =
item.key === "your_work"
? `/${workspaceSlug.toString()}${item.href}${data?.id}`
: `/${workspaceSlug.toString()}${item.href}`;
const isActive = itemHref === pathname;
const pinNavigationItem = (workspaceSlug: string, key: string) => {
updateSidebarPreference(workspaceSlug, key, { is_pinned: true });
};
const unPinNavigationItem = (workspaceSlug: string, key: string) => {
updateSidebarPreference(workspaceSlug, key, { is_pinned: false });
};
const icon = getSidebarNavigationItemIcon(item.key);
useEffect(() => {
const element = navigationIemRef.current;
const dragHandleElement = dragHandleRef.current;
if (!element) return;
return combine(
draggable({
element,
canDrag: () => !disableDrag,
dragHandle: dragHandleElement ?? undefined,
getInitialData: () => ({ id: item.key, dragInstanceId: "NAVIGATION" }), // var1
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
}),
dropTargetForElements({
element,
canDrop: ({ source }) =>
!disableDrop && source?.data?.id !== item.key && source?.data?.dragInstanceId === "NAVIGATION",
getData: ({ input, element }) => {
const data = { id: item.key };
// attach instruction for last in list
return attachInstruction(data, {
input,
element,
currentLevel: 0,
indentPerLevel: 0,
mode: isLastChild ? "last-in-group" : "standard",
});
},
onDrag: ({ self }) => {
const extractedInstruction = extractInstruction(self?.data)?.type;
// check if the highlight is to be shown above or below
setInstruction(
extractedInstruction
? extractedInstruction === "reorder-below" && isLastChild
? "DRAG_BELOW"
: "DRAG_OVER"
: undefined
);
},
onDragLeave: () => {
setInstruction(undefined);
},
onDrop: ({ self, source }) => {
setInstruction(undefined);
const extractedInstruction = extractInstruction(self?.data)?.type;
const currentInstruction = extractedInstruction
? extractedInstruction === "reorder-below" && isLastChild
? "DRAG_BELOW"
: "DRAG_OVER"
: undefined;
if (!currentInstruction) return;
const sourceId = source?.data?.id as string | undefined;
const destinationId = self?.data?.id as string | undefined;
if (handleOnNavigationItemDrop)
handleOnNavigationItemDrop(sourceId, destinationId, currentInstruction === "DRAG_BELOW");
},
})
);
}, [isLastChild, handleOnNavigationItemDrop, disableDrag, disableDrop, item.key]);
return (
<div
id={`sidebar-${item.key}`}
className={cn("relative", { "bg-custom-sidebar-background-80 opacity-60": isDragging })}
ref={navigationIemRef}
>
<DropIndicator classNames="absolute top-0" isVisible={instruction === "DRAG_OVER"} />
<div
className={cn(
"group/project-item relative w-full flex items-center rounded-md text-custom-sidebar-text-100 hover:bg-custom-sidebar-background-90"
)}
id={`${item.key}`}
>
{!disableDrag && (
<Tooltip
// isMobile={isMobile}
tooltipContent={t("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": project.sort_order === null,
"cursor-grabbing": isDragging,
// "!hidden": isSidebarCollapsed,
}
)}
ref={dragHandleRef}
>
<DragHandle className="bg-transparent" />
</button>
</Tooltip>
)}
<SidebarNavItem isActive={isActive}>
<Link href={itemHref} onClick={() => handleLinkClick()} className="group flex-grow">
<div className="flex items-center gap-1.5 py-[1px]">
{icon}
<p className="text-sm leading-5 font-medium">{t(item.labelTranslationKey)}</p>
</div>
</Link>
<div className="flex items-center gap-2">
{item.key === "active_cycles" && (
<div className="flex-shrink-0">
<UpgradeBadge />
</div>
)}
{isPinned ? (
<Tooltip tooltipContent="Hide tab">
<Eye
className="size-4 flex-shrink-0 invisible group-hover:visible text-custom-text-300 outline-none"
onClick={() => unPinNavigationItem(workspaceSlug.toString(), item.key)}
/>
</Tooltip>
) : (
<Tooltip tooltipContent="Show tab">
<EyeClosed
className="size-4 flex-shrink-0 invisible group-hover:visible text-custom-text-400 outline-none"
onClick={() => pinNavigationItem(workspaceSlug.toString(), item.key)}
/>
</Tooltip>
)}
</div>
</SidebarNavItem>
</div>
{isLastChild && <DropIndicator isVisible={instruction === "DRAG_BELOW"} />}
</div>
);
});

View file

@ -0,0 +1,26 @@
import { BarChart2, Briefcase, Home, Inbox, Layers, PenSquare } from "lucide-react";
import { ArchiveIcon, ContrastIcon, UserActivityIcon } from "@plane/ui";
import { cn } from "@plane/utils";
export const getSidebarNavigationItemIcon = (key: string, className: string = "") => {
switch (key) {
case "home":
return <Home className={cn("size-4 flex-shrink-0", className)} />;
case "notifications":
return <Inbox className={cn("size-4 flex-shrink-0", className)} />;
case "projects":
return <Briefcase className={cn("size-4 flex-shrink-0", className)} />;
case "views":
return <Layers className={cn("size-4 flex-shrink-0", className)} />;
case "active_cycles":
return <ContrastIcon className={cn("size-4 flex-shrink-0", className)} />;
case "analytics":
return <BarChart2 className={cn("size-4 flex-shrink-0", className)} />;
case "your_work":
return <UserActivityIcon className={cn("size-4 flex-shrink-0", className)} />;
case "drafts":
return <PenSquare className={cn("size-4 flex-shrink-0", className)} />;
case "archives":
return <ArchiveIcon className={cn("size-4 flex-shrink-0", className)} />;
}
};

View file

@ -0,0 +1,3 @@
export * from "./extended-sidebar-item";
export * from "./helper";
export * from "./sidebar-item";

View file

@ -0,0 +1,91 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
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";
// hooks
import { useAppTheme, useUser, useUserPermissions, useWorkspace } from "@/hooks/store";
// plane web imports
import { UpgradeBadge } from "@/plane-web/components/workspace";
// local imports
import { getSidebarNavigationItemIcon } from "./helper";
type TSidebarItemProps = {
item: IWorkspaceSidebarNavigationItem;
};
export const SidebarItem: FC<TSidebarItemProps> = observer((props) => {
const { item } = props;
const { t } = useTranslation();
// nextjs hooks
const pathname = usePathname();
const { workspaceSlug } = useParams();
const { allowPermissions } = useUserPermissions();
const { getNavigationPreferences } = useWorkspace();
const { data } = useUser();
// store hooks
const { toggleSidebar, sidebarCollapsed, extendedSidebarCollapsed, toggleExtendedSidebar } = useAppTheme();
const { isMobile } = usePlatformOS();
const handleLinkClick = () => {
if (window.innerWidth < 768) {
toggleSidebar();
}
if (extendedSidebarCollapsed) toggleExtendedSidebar();
};
const staticItems = ["home", "notifications", "pi-chat", "projects"];
if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) {
return null;
}
const itemHref =
item.key === "your_work"
? `/${workspaceSlug.toString()}${item.href}/${data?.id}`
: `/${workspaceSlug.toString()}${item.href}`;
const isActive = itemHref === pathname;
const sidebarPreference = getNavigationPreferences(workspaceSlug.toString());
const isPinned = sidebarPreference?.[item.key]?.is_pinned;
if (!isPinned && !staticItems.includes(item.key)) return null;
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}
>
{/* <icon className="size-4" /> */}
<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>
{!sidebarCollapsed && item.key === "active_cycles" && (
<div className="flex-shrink-0">
<UpgradeBadge />
</div>
)}
</SidebarNavItem>
</Link>
</Tooltip>
);
});

View file

@ -69,7 +69,11 @@ export const SidebarDropdown = observer(() => {
const workspacesList = orderWorkspacesList(Object.values(workspaces ?? {}));
// TODO: fix workspaces list scroll
return (
<div className="flex items-center justify-center gap-x-3 gap-y-2">
<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", {
@ -185,70 +189,68 @@ export const SidebarDropdown = observer(() => {
</>
)}
</Menu>
{!sidebarCollapsed && (
<Menu as="div" className="relative flex-shrink-0">
<Menu.Button className="grid place-items-center outline-none" ref={setReferenceElement}>
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={24}
shape="square"
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
<Menu as="div" className="relative flex-shrink-0">
<Menu.Button className="grid place-items-center outline-none" ref={setReferenceElement}>
<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="/profile">
<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>
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="/profile">
<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>
<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>
)}
)}
</Menu.Items>
</Transition>
</Menu>
</div>
);
});

View file

@ -8,16 +8,16 @@ import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/el
import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useParams } from "next/navigation";
import { createRoot } from "react-dom/client";
import { LinkIcon, Star, Settings, Share2, LogOut, MoreHorizontal, ChevronRight } from "lucide-react";
import { LinkIcon, Settings, Share2, LogOut, MoreHorizontal, ChevronRight } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// plane helpers
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
// ui
import { CustomMenu, Tooltip, ArchiveIcon, setPromiseToast, DropIndicator, DragHandle, ControlLink } from "@plane/ui";
import { CustomMenu, Tooltip, ArchiveIcon, DropIndicator, DragHandle, ControlLink } from "@plane/ui";
// components
import { Logo } from "@/components/common";
import { LeaveProjectModal, PublishProjectModal } from "@/components/project";
@ -52,7 +52,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
const { t } = useTranslation();
const { setTrackElement } = useEventTracker();
const { addProjectToFavorites, removeProjectFromFavorites, getPartialProjectById } = useProject();
const { getPartialProjectById } = useProject();
const { isMobile } = usePlatformOS();
const { allowPermissions } = useUserPermissions();
// states
@ -67,7 +67,6 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
const projectRef = useRef<HTMLDivElement | null>(null);
const dragHandleRef = useRef<HTMLButtonElement | null>(null);
// router
const router = useRouter();
const { workspaceSlug, projectId: URLProjectId } = useParams();
// derived values
const project = getPartialProjectById(projectId);
@ -85,40 +84,6 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
project?.id
);
const handleAddToFavorites = () => {
if (!workspaceSlug || !project) return;
const addToFavoritePromise = addProjectToFavorites(workspaceSlug.toString(), project.id);
setPromiseToast(addToFavoritePromise, {
loading: t("adding_project_to_favorites"),
success: {
title: t("success"),
message: () => t("project_added_to_favorites"),
},
error: {
title: t("error"),
message: () => t("couldnt_add_the_project_to_favorites"),
},
});
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !project) return;
const removeFromFavoritePromise = removeProjectFromFavorites(workspaceSlug.toString(), project.id);
setPromiseToast(removeFromFavoritePromise, {
loading: t("removing_project_from_favorites"),
success: {
title: t("success"),
message: () => t("project_removed_from_favorites"),
},
error: {
title: t("error"),
message: () => t("couldnt_remove_the_project_from_favorites"),
},
});
};
const handleLeaveProject = () => {
setTrackElement("APP_SIDEBAR_PROJECT_DROPDOWN");
setLeaveProjectModal(true);
@ -222,11 +187,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
if (URLProjectId === project.id) setIsProjectListOpen(true);
}, [URLProjectId]);
const handleItemClick = () => {
if (!isProjectListOpen && !isMobile) router.push(`/${workspaceSlug}/projects/${project.id}/issues`);
setIsProjectListOpen((prev) => !prev);
};
const handleItemClick = () => setIsProjectListOpen((prev) => !prev);
return (
<>
<PublishProjectModal isOpen={publishModalOpen} project={project} onClose={() => setPublishModal(false)} />

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 { Briefcase, ChevronRight, Ellipsis, Plus } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
@ -13,6 +13,7 @@ import { useTranslation } from "@plane/i18n";
import { Loader, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// components
import { CreateProjectModal } from "@/components/project";
import { SidebarNavItem } from "@/components/sidebar";
import { SidebarProjectsListItem } from "@/components/workspace";
// helpers
import { cn } from "@/helpers/common.helper";
@ -37,7 +38,7 @@ export const SidebarProjectsList: FC = observer(() => {
// store hooks
const { t } = useTranslation();
const { toggleCreateProjectModal } = useCommandPalette();
const { sidebarCollapsed } = useAppTheme();
const { sidebarCollapsed, toggleExtendedProjectSidebar } = useAppTheme();
const { setTrackElement } = useEventTracker();
const { allowPermissions } = useUserPermissions();
@ -237,12 +238,12 @@ export const SidebarProjectsList: FC = observer(() => {
{isAllProjectsListOpen && (
<Disclosure.Panel
as="div"
className={cn("space-y-1", {
className={cn("flex flex-col gap-0.5", {
"space-y-0 ml-0": isCollapsed,
})}
static
>
{joinedProjects.map((projectId, index) => (
{joinedProjects.slice(0, 7).map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
@ -277,6 +278,21 @@ export const SidebarProjectsList: FC = observer(() => {
{!isCollapsed && t("add_project")}
</button>
)}
{joinedProjects.length > 7 && (
<SidebarNavItem className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}>
<button
onClick={() => toggleExtendedProjectSidebar()}
id="extended-project-sidebar-toggle"
className={cn("flex items-center gap-1.5 text-sm font-medium flex-grow text-custom-text-350", {
"justify-center": sidebarCollapsed,
})}
>
<Ellipsis className="size-4" />
{!sidebarCollapsed && <span>More</span>}
</button>
</SidebarNavItem>
)}
</div>
</>
);

View file

@ -0,0 +1,69 @@
"use client";
import React, { useMemo } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Ellipsis } from "lucide-react";
// plane imports
import {
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS,
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS,
} from "@plane/constants";
import { cn } from "@plane/utils";
// components
import { SidebarNavItem } from "@/components/sidebar";
// store hooks
import { useAppTheme, useWorkspace } from "@/hooks/store";
// plane-web imports
import { SidebarItem } from "@/plane-web/components/workspace/sidebar";
export const SidebarMenuItems = observer(() => {
// routers
const { workspaceSlug } = useParams();
// store hooks
const { sidebarCollapsed, toggleExtendedSidebar } = useAppTheme();
const { getNavigationPreferences } = useWorkspace();
// derived values
const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString());
const sortedNavigationItems = useMemo(
() =>
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => {
const preference = currentWorkspaceNavigationPreferences?.[item.key];
return {
...item,
sort_order: preference ? preference.sort_order : 0,
};
}).sort((a, b) => a.sort_order - b.sort_order),
[currentWorkspaceNavigationPreferences]
);
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} />
))}
</div>
<SidebarNavItem className={`${sidebarCollapsed ? "p-0 size-8 aspect-square justify-center mx-auto" : ""}`}>
<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"
>
<Ellipsis className="size-4" />
{!sidebarCollapsed && <span>More</span>}
</button>
</SidebarNavItem>
</>
);
});

View file

@ -65,12 +65,12 @@ export const SidebarWorkspaceMenuItem: FC<SidebarWorkspaceMenuItemProps> = obser
<div className="flex items-center gap-1.5 py-[1px]">
<item.Icon
className={cn("size-4", {
"rotate-180": item.key === "active-cycles",
"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" && (
{!sidebarCollapsed && item.key === "active_cycles" && (
<div className="flex-shrink-0">
<UpgradeBadge />
</div>

View file

@ -0,0 +1,49 @@
import React, { useEffect } from "react";
const useExtendedSidebarOutsideClickDetector = (
ref: React.RefObject<HTMLElement>,
callback: () => void,
targetId: string
) => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
// check for the closest element with attribute name data-prevent-outside-click
const preventOutsideClickElement = (event.target as HTMLElement | undefined)?.closest(
"[data-prevent-outside-click]"
);
// if the closest element with attribute name data-prevent-outside-click is found, return
if (preventOutsideClickElement) {
return;
}
// check if the click target is the current issue element or its children
let targetElement = event.target as HTMLElement | null;
while (targetElement) {
if (targetElement.id === targetId) {
// if the click target is the current issue element, return
return;
}
targetElement = targetElement.parentElement;
}
const delayOutsideClickElement = (event.target as HTMLElement | undefined)?.closest("[data-delay-outside-click]");
if (delayOutsideClickElement) {
// if the click target is the closest element with attribute name data-delay-outside-click, delay the callback
setTimeout(() => {
callback();
}, 0);
return;
}
// else, call the callback immediately
callback();
}
};
useEffect(() => {
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("mousedown", handleClick);
};
}, []);
};
export default useExtendedSidebarOutsideClickDetector;

View file

@ -44,7 +44,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
const {
workspace: { fetchWorkspaceMembers },
} = useMember();
const { workspaces } = useWorkspace();
const { workspaces, fetchSidebarNavigationPreferences } = useWorkspace();
const { isMobile } = usePlatformOS();
const { loader, workspaceInfoBySlug, fetchUserWorkspaceInfo, fetchUserProjectPermissions, allowPermissions } =
useUserPermissions();
@ -101,6 +101,13 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// fetch workspace sidebar preferences
useSWR(
workspaceSlug ? `WORKSPACE_SIDEBAR_PREFERENCES_${workspaceSlug}` : null,
workspaceSlug ? () => fetchSidebarNavigationPreferences(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
// initialize the local database
const { isLoading: isDBInitializing } = useSWRImmutable(
workspaceSlug ? `WORKSPACE_DB_${workspaceSlug}` : null,

View file

@ -18,6 +18,7 @@ import {
TWidgetEntityData,
TActivityEntityData,
} from "@plane/types";
import { IWorkspaceSidebarNavigationItem, IWorkspaceSidebarNavigation } from "@plane/types/src/workspace";
import { APIService } from "@/services/api.service";
// helpers
// types
@ -362,4 +363,24 @@ export class WorkspaceService extends APIService {
throw error?.response;
});
}
async fetchSidebarNavigationPreferences(workspaceSlug: string): Promise<IWorkspaceSidebarNavigation> {
return this.get(`/api/workspaces/${workspaceSlug}/sidebar-preferences/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async updateSidebarPreference(
workspaceSlug: string,
key: string,
data: Partial<IWorkspaceSidebarNavigationItem>
): Promise<IWorkspaceSidebarNavigationItem> {
return this.patch(`/api/workspaces/${workspaceSlug}/sidebar-preferences/${key}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
}

View file

@ -3,6 +3,8 @@ import { action, observable, makeObservable } from "mobx";
export interface IThemeStore {
// observables
sidebarCollapsed: boolean | undefined;
extendedSidebarCollapsed: boolean | undefined;
extendedProjectSidebarCollapsed: boolean | undefined;
profileSidebarCollapsed: boolean | undefined;
workspaceAnalyticsSidebarCollapsed: boolean | undefined;
issueDetailSidebarCollapsed: boolean | undefined;
@ -11,6 +13,8 @@ export interface IThemeStore {
projectOverviewSidebarCollapsed: boolean | undefined;
// actions
toggleSidebar: (collapsed?: boolean) => void;
toggleExtendedSidebar: (collapsed?: boolean) => void;
toggleExtendedProjectSidebar: (collapsed?: boolean) => void;
toggleProfileSidebar: (collapsed?: boolean) => void;
toggleWorkspaceAnalyticsSidebar: (collapsed?: boolean) => void;
toggleIssueDetailSidebar: (collapsed?: boolean) => void;
@ -22,6 +26,8 @@ export interface IThemeStore {
export class ThemeStore implements IThemeStore {
// observables
sidebarCollapsed: boolean | undefined = undefined;
extendedSidebarCollapsed: boolean | undefined = undefined;
extendedProjectSidebarCollapsed: boolean | undefined = undefined;
profileSidebarCollapsed: boolean | undefined = undefined;
workspaceAnalyticsSidebarCollapsed: boolean | undefined = undefined;
issueDetailSidebarCollapsed: boolean | undefined = undefined;
@ -33,6 +39,8 @@ export class ThemeStore implements IThemeStore {
makeObservable(this, {
// observable
sidebarCollapsed: observable.ref,
extendedSidebarCollapsed: observable.ref,
extendedProjectSidebarCollapsed: observable.ref,
profileSidebarCollapsed: observable.ref,
workspaceAnalyticsSidebarCollapsed: observable.ref,
issueDetailSidebarCollapsed: observable.ref,
@ -41,6 +49,8 @@ export class ThemeStore implements IThemeStore {
projectOverviewSidebarCollapsed: observable.ref,
// action
toggleSidebar: action,
toggleExtendedSidebar: action,
toggleExtendedProjectSidebar: action,
toggleProfileSidebar: action,
toggleWorkspaceAnalyticsSidebar: action,
toggleIssueDetailSidebar: action,
@ -63,6 +73,32 @@ export class ThemeStore implements IThemeStore {
localStorage.setItem("app_sidebar_collapsed", this.sidebarCollapsed.toString());
};
/**
* Toggle the extended sidebar collapsed state
* @param collapsed
*/
toggleExtendedSidebar = (collapsed?: boolean) => {
if (collapsed === undefined) {
this.extendedSidebarCollapsed = !this.extendedSidebarCollapsed;
} else {
this.extendedSidebarCollapsed = collapsed;
}
localStorage.setItem("extended_sidebar_collapsed", this.extendedSidebarCollapsed.toString());
};
/**
* Toggle the extended project sidebar collapsed state
* @param collapsed
*/
toggleExtendedProjectSidebar = (collapsed?: boolean) => {
if (collapsed === undefined) {
this.extendedProjectSidebarCollapsed = !this.extendedProjectSidebarCollapsed;
} else {
this.extendedProjectSidebarCollapsed = collapsed;
}
localStorage.setItem("extended_project_sidebar_collapsed", this.extendedProjectSidebarCollapsed.toString());
};
/**
* Toggle the profile sidebar collapsed state
* @param collapsed

View file

@ -1,7 +1,8 @@
import set from "lodash/set";
import { action, computed, observable, makeObservable, runInAction } from "mobx";
// types
import { IWorkspace } from "@plane/types";
import { computedFn } from "mobx-utils";
import { IWorkspaceSidebarNavigationItem, IWorkspace, IWorkspaceSidebarNavigation } from "@plane/types";
// services
import { WorkspaceService } from "@/plane-web/services";
// store
@ -18,6 +19,7 @@ export interface IWorkspaceRootStore {
// computed
currentWorkspace: IWorkspace | null;
workspacesCreatedByCurrentUser: IWorkspace[] | null;
navigationPreferencesMap: Record<string, IWorkspaceSidebarNavigation>;
// computed actions
getWorkspaceBySlug: (workspaceSlug: string) => IWorkspace | null;
getWorkspaceById: (workspaceId: string) => IWorkspace | null;
@ -28,6 +30,13 @@ export interface IWorkspaceRootStore {
updateWorkspace: (workspaceSlug: string, data: Partial<IWorkspace>) => Promise<IWorkspace>;
updateWorkspaceLogo: (workspaceSlug: string, logoURL: string) => void;
deleteWorkspace: (workspaceSlug: string) => Promise<void>;
fetchSidebarNavigationPreferences: (workspaceSlug: string) => Promise<void>;
updateSidebarPreference: (
workspaceSlug: string,
key: string,
data: Partial<IWorkspaceSidebarNavigationItem>
) => Promise<IWorkspaceSidebarNavigationItem | undefined>;
getNavigationPreferences: (workspaceSlug: string) => IWorkspaceSidebarNavigation | undefined;
// sub-stores
webhook: IWebhookStore;
apiToken: IApiTokenStore;
@ -38,6 +47,7 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
loader: boolean = false;
// observables
workspaces: Record<string, IWorkspace> = {};
navigationPreferencesMap: Record<string, IWorkspaceSidebarNavigation> = {};
// services
workspaceService;
// root store
@ -53,6 +63,7 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
loader: observable.ref,
// observables
workspaces: observable,
navigationPreferencesMap: observable,
// computed
currentWorkspace: computed,
workspacesCreatedByCurrentUser: computed,
@ -65,6 +76,8 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
updateWorkspace: action,
updateWorkspaceLogo: action,
deleteWorkspace: action,
fetchSidebarNavigationPreferences: action,
updateSidebarPreference: action,
});
// services
@ -183,4 +196,41 @@ export class WorkspaceRootStore implements IWorkspaceRootStore {
this.workspaces = updatedWorkspacesList;
});
});
fetchSidebarNavigationPreferences = async (workspaceSlug: string) => {
try {
const response = await this.workspaceService.fetchSidebarNavigationPreferences(workspaceSlug);
runInAction(() => {
this.navigationPreferencesMap[workspaceSlug] = response;
});
} catch (error) {
console.error("Failed to fetch sidebar preferences:", error);
}
};
updateSidebarPreference = async (
workspaceSlug: string,
key: string,
data: Partial<IWorkspaceSidebarNavigationItem>
) => {
try {
const response = await this.workspaceService.updateSidebarPreference(workspaceSlug, key, data);
runInAction(() => {
this.navigationPreferencesMap[workspaceSlug] = {
...this.navigationPreferencesMap[workspaceSlug],
[key]: response,
};
});
return response;
} catch (error) {
console.error("Failed to update sidebar preference:", error);
}
};
getNavigationPreferences = computedFn(
(workspaceSlug: string): IWorkspaceSidebarNavigation | undefined => this.navigationPreferencesMap[workspaceSlug]
);
}

View file

@ -0,0 +1 @@
export * from "ce/components/workspace/sidebar";

View file

@ -1102,127 +1102,127 @@
"@esbuild/aix-ppc64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz#499600c5e1757a524990d5d92601f0ac3ce87f64"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz#499600c5e1757a524990d5d92601f0ac3ce87f64"
integrity sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==
"@esbuild/android-arm64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz#b9b8231561a1dfb94eb31f4ee056b92a985c324f"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz#b9b8231561a1dfb94eb31f4ee056b92a985c324f"
integrity sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==
"@esbuild/android-arm@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz#ca6e7888942505f13e88ac9f5f7d2a72f9facd2b"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.0.tgz#ca6e7888942505f13e88ac9f5f7d2a72f9facd2b"
integrity sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==
"@esbuild/android-x64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz#e765ea753bac442dfc9cb53652ce8bd39d33e163"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.0.tgz#e765ea753bac442dfc9cb53652ce8bd39d33e163"
integrity sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==
"@esbuild/darwin-arm64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz#fa394164b0d89d4fdc3a8a21989af70ef579fa2c"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz#fa394164b0d89d4fdc3a8a21989af70ef579fa2c"
integrity sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==
"@esbuild/darwin-x64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz#91979d98d30ba6e7d69b22c617cc82bdad60e47a"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz#91979d98d30ba6e7d69b22c617cc82bdad60e47a"
integrity sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==
"@esbuild/freebsd-arm64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz#b97e97073310736b430a07b099d837084b85e9ce"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz#b97e97073310736b430a07b099d837084b85e9ce"
integrity sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==
"@esbuild/freebsd-x64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz#f3b694d0da61d9910ec7deff794d444cfbf3b6e7"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz#f3b694d0da61d9910ec7deff794d444cfbf3b6e7"
integrity sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==
"@esbuild/linux-arm64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz#f921f699f162f332036d5657cad9036f7a993f73"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz#f921f699f162f332036d5657cad9036f7a993f73"
integrity sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==
"@esbuild/linux-arm@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz#cc49305b3c6da317c900688995a4050e6cc91ca3"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz#cc49305b3c6da317c900688995a4050e6cc91ca3"
integrity sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==
"@esbuild/linux-ia32@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz#3e0736fcfab16cff042dec806247e2c76e109e19"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz#3e0736fcfab16cff042dec806247e2c76e109e19"
integrity sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==
"@esbuild/linux-loong64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz#ea2bf730883cddb9dfb85124232b5a875b8020c7"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz#ea2bf730883cddb9dfb85124232b5a875b8020c7"
integrity sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==
"@esbuild/linux-mips64el@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz#4cababb14eede09248980a2d2d8b966464294ff1"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz#4cababb14eede09248980a2d2d8b966464294ff1"
integrity sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==
"@esbuild/linux-ppc64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz#8860a4609914c065373a77242e985179658e1951"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz#8860a4609914c065373a77242e985179658e1951"
integrity sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==
"@esbuild/linux-riscv64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz#baf26e20bb2d38cfb86ee282dff840c04f4ed987"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz#baf26e20bb2d38cfb86ee282dff840c04f4ed987"
integrity sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==
"@esbuild/linux-s390x@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz#8323afc0d6cb1b6dc6e9fd21efd9e1542c3640a4"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz#8323afc0d6cb1b6dc6e9fd21efd9e1542c3640a4"
integrity sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==
"@esbuild/linux-x64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz#08fcf60cb400ed2382e9f8e0f5590bac8810469a"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz#08fcf60cb400ed2382e9f8e0f5590bac8810469a"
integrity sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==
"@esbuild/netbsd-arm64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz#935c6c74e20f7224918fbe2e6c6fe865b6c6ea5b"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz#935c6c74e20f7224918fbe2e6c6fe865b6c6ea5b"
integrity sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==
"@esbuild/netbsd-x64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz#414677cef66d16c5a4d210751eb2881bb9c1b62b"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz#414677cef66d16c5a4d210751eb2881bb9c1b62b"
integrity sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==
"@esbuild/openbsd-arm64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz#8fd55a4d08d25cdc572844f13c88d678c84d13f7"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz#8fd55a4d08d25cdc572844f13c88d678c84d13f7"
integrity sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==
"@esbuild/openbsd-x64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz#0c48ddb1494bbc2d6bcbaa1429a7f465fa1dedde"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz#0c48ddb1494bbc2d6bcbaa1429a7f465fa1dedde"
integrity sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==
"@esbuild/sunos-x64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz#86ff9075d77962b60dd26203d7352f92684c8c92"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz#86ff9075d77962b60dd26203d7352f92684c8c92"
integrity sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==
"@esbuild/win32-arm64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz#849c62327c3229467f5b5cd681bf50588442e96c"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz#849c62327c3229467f5b5cd681bf50588442e96c"
integrity sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==
"@esbuild/win32-ia32@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz#f62eb480cd7cca088cb65bb46a6db25b725dc079"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz#f62eb480cd7cca088cb65bb46a6db25b725dc079"
integrity sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==
"@esbuild/win32-x64@0.25.0":
version "0.25.0"
resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz#c8e119a30a7c8d60b9d2e22d2073722dde3b710b"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz#c8e119a30a7c8d60b9d2e22d2073722dde3b710b"
integrity sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0":
@ -4199,25 +4199,18 @@
resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044"
integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==
"@types/react@*":
version "19.0.8"
resolved "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz#7098e6159f2a61e4f4cef2c1223c044a9bec590e"
integrity sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==
dependencies:
csstype "^3.0.2"
"@types/react@18.3.1":
version "18.3.1"
resolved "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz#fed43985caa834a2084d002e4771e15dfcbdbe8e"
integrity sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==
"@types/react@*", "@types/react@^18.3.11":
version "18.3.18"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.18.tgz#9b382c4cd32e13e463f97df07c2ee3bbcd26904b"
integrity sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==
dependencies:
"@types/prop-types" "*"
csstype "^3.0.2"
"@types/react@^18.3.11":
version "18.3.18"
resolved "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz#9b382c4cd32e13e463f97df07c2ee3bbcd26904b"
integrity sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==
"@types/react@18.3.1":
version "18.3.1"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.1.tgz#fed43985caa834a2084d002e4771e15dfcbdbe8e"
integrity sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==
dependencies:
"@types/prop-types" "*"
csstype "^3.0.2"
@ -6579,7 +6572,7 @@ esbuild-register@^3.5.0:
esbuild@0.25.0, "esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0", esbuild@^0.19.2:
version "0.25.0"
resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz#0de1787a77206c5a79eeb634a623d39b5006ce92"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.0.tgz#0de1787a77206c5a79eeb634a623d39b5006ce92"
integrity sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==
optionalDependencies:
"@esbuild/aix-ppc64" "0.25.0"
@ -9117,7 +9110,7 @@ mz@^2.7.0:
nanoid@3.3.8, nanoid@^3.3.6, nanoid@^3.3.8:
version "3.3.8"
resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf"
integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==
napi-build-utils@^2.0.0: