feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
import type { FC } from "react";
import React from "react";
import { cn } from "@plane/utils";
type Props = React.ComponentProps<"button"> & {
label: React.ReactNode;
onClick: () => void;
};
export const SidebarAddButton: FC<Props> = (props) => {
const { label, onClick, disabled, ...rest } = props;
return (
<button
type="button"
className={cn(
"flex-grow text-custom-text-300 text-sm font-medium border-[0.5px] border-custom-sidebar-border-300 text-left rounded-md shadow-sm h-8 px-2 flex items-center gap-1.5",
!disabled && "hover:bg-custom-sidebar-background-90"
)}
onClick={onClick}
disabled={disabled}
{...rest}
>
{label}
</button>
);
};

View File

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

View File

@@ -0,0 +1,28 @@
import type { FC } from "react";
import React from "react";
import { Search } from "lucide-react";
import { cn } from "@plane/utils";
type Props = {
isActive?: boolean;
};
export const SidebarSearchButton: FC<Props> = (props) => {
const { isActive } = props;
return (
<div
className={cn(
"flex-shrink-0 size-8 aspect-square grid place-items-center rounded-md shadow-sm hover:bg-custom-sidebar-background-90 outline-none border-[0.5px] border-custom-sidebar-border-300",
{
"bg-custom-primary-100/10 hover:bg-custom-primary-100/10 border-custom-primary-200": isActive,
}
)}
>
<Search
className={cn("size-4 text-custom-sidebar-text-300", {
"text-custom-primary-200": isActive,
})}
/>
</div>
);
};

View File

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

View File

@@ -0,0 +1,30 @@
"use client";
import type { FC } from "react";
import React from "react";
// helpers
import { cn } from "@plane/utils";
type TSidebarNavItem = {
className?: string;
isActive?: boolean;
children?: React.ReactNode;
};
export const SidebarNavItem: FC<TSidebarNavItem> = (props) => {
const { className, isActive, children } = props;
return (
<div
className={cn(
"cursor-pointer relative group w-full flex items-center justify-between gap-1.5 rounded px-2 py-1 outline-none",
{
"text-custom-text-200 bg-custom-background-80/75": isActive,
"text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 active:bg-custom-sidebar-background-90":
!isActive,
},
className
)}
>
{children}
</div>
);
};

View File

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

View File

@@ -0,0 +1,81 @@
import type { FC } from "react";
import { useEffect, useRef } from "react";
import { observer } from "mobx-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
import { ScrollArea } from "@plane/propel/scrollarea";
// components
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
import { SidebarDropdown } from "@/components/workspace/sidebar/dropdown";
import { HelpMenu } from "@/components/workspace/sidebar/help-menu";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useAppRail } from "@/hooks/use-app-rail";
import useSize from "@/hooks/use-window-size";
// plane web components
import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge";
type TSidebarWrapperProps = {
title: string;
children: React.ReactNode;
quickActions?: React.ReactNode;
};
export const SidebarWrapper: FC<TSidebarWrapperProps> = observer((props) => {
const { children, title, quickActions } = props;
// store hooks
const { toggleSidebar, sidebarCollapsed } = useAppTheme();
const { shouldRenderAppRail, isEnabled: isAppRailEnabled } = useAppRail();
const windowSize = useSize();
// refs
const ref = useRef<HTMLDivElement>(null);
useOutsideClickDetector(ref, () => {
if (sidebarCollapsed === false && window.innerWidth < 768) {
toggleSidebar();
}
});
useEffect(() => {
if (windowSize[0] < 768 && !sidebarCollapsed) toggleSidebar();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [windowSize]);
return (
<div ref={ref} className="flex flex-col h-full w-full">
<div className="flex flex-col gap-3 px-3">
{/* Workspace switcher and settings */}
{!shouldRenderAppRail && <SidebarDropdown />}
{isAppRailEnabled && (
<div className="flex items-center justify-between gap-2">
<span className="text-md text-custom-text-200 font-medium pt-1">{title}</span>
<div className="flex items-center gap-2">
<AppSidebarToggleButton />
</div>
</div>
)}
{/* Quick actions */}
{quickActions}
</div>
<ScrollArea
orientation="vertical"
scrollType="hover"
size="sm"
rootClassName="size-full overflow-x-hidden overflow-y-auto"
viewportClassName="flex flex-col gap-3 overflow-x-hidden h-full w-full overflow-y-auto px-3 pt-3 pb-0.5"
>
{children}
</ScrollArea>
{/* Help Section */}
<div className="flex items-center justify-between p-3 border-t border-custom-border-200 bg-custom-sidebar-background-100 h-12">
<WorkspaceEditionBadge />
<div className="flex items-center gap-2">
{!shouldRenderAppRail && <HelpMenu />}
{!isAppRailEnabled && <AppSidebarToggleButton />}
</div>
</div>
</div>
);
});