Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled

Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
chuan
2025-11-07 00:00:52 +08:00
commit 8ebde8aa05
4886 changed files with 462270 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
import type { ReactNode } from "react";
import { observer } from "mobx-react";
import { cn } from "@plane/utils";
type TProps = {
children: ReactNode;
size?: "lg" | "md";
};
export const SettingsContentWrapper = observer((props: TProps) => {
const { children, size = "md" } = props;
return (
<div
className={cn("flex flex-col w-full items-center mx-auto py-4 md:py-0", {
"md:px-4 max-w-[800px] 2xl:max-w-[1000px]": size === "md",
"md:px-16": size === "lg",
})}
>
<div className="pb-10 w-full">{children}</div>
</div>
);
});

View File

@@ -0,0 +1,88 @@
"use client";
import { observer } from "mobx-react";
import Link from "next/link";
import { useTheme } from "next-themes";
import { ChevronLeftIcon } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { getButtonStyling } from "@plane/propel/button";
import { cn } from "@plane/utils";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserSettings } from "@/hooks/store/user";
// local imports
import { WorkspaceLogo } from "../workspace/logo";
import SettingsTabs from "./tabs";
export const SettingsHeader = observer(() => {
// hooks
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const { isScrolled } = useUserSettings();
// resolved theme
const { resolvedTheme } = useTheme();
// redirect url for normal mode
return (
<div
className={cn("bg-custom-background-90 p-page-x transition-all duration-300 ease-in-out relative", {
"!pt-4 flex md:flex-col": isScrolled,
"bg-custom-background-90/50": resolvedTheme === "dark",
})}
>
<Link
href={`/${currentWorkspace?.slug}`}
className={cn(
getButtonStyling("neutral-primary", "sm"),
"md:absolute left-2 top-9 group flex gap-2 text-custom-text-300 mb-4 border border-transparent w-fit rounded-lg ",
"h-6 w-6 rounded-lg p-1 bg-custom-background-100 border-custom-border-200 ",
isScrolled ? "-mt-2 " : "hidden p-0 overflow-hidden items-center pr-2 border-none"
)}
>
<ChevronLeftIcon className={cn("h-4 w-4", !isScrolled ? "my-auto h-0" : "")} />
</Link>
{/* Breadcrumb */}
<Link
href={`/${currentWorkspace?.slug}`}
className={cn(
"group flex gap-2 text-custom-text-300 mb-3 border border-transparent w-fit rounded-lg",
!isScrolled ? "hover:bg-custom-background-100 hover:border-custom-border-200 items-center pr-2 " : " h-0 m-0"
)}
>
<button
className={cn(
getButtonStyling("neutral-primary", "sm"),
"h-6 w-6 rounded-lg p-1 hover:bg-custom-background-100 hover:border-custom-border-200",
"group-hover:bg-custom-background-100 group-hover:border-transparent",
{ "h-0 hidden": isScrolled }
)}
>
<ChevronLeftIcon className={cn("h-4 w-4", !isScrolled ? "my-auto" : "")} />
</button>
<div
className={cn("flex gap-2 h-full w-full transition-[height] duration-300 ease-in-out", {
"h-0 w-0 overflow-hidden": isScrolled,
})}
>
<div className="text-sm my-auto font-semibold text-custom-text-200">{t("back_to_workspace")}</div>
{/* Last workspace */}
<div className="flex items-center gap-1">
<WorkspaceLogo
name={currentWorkspace?.name || ""}
logo={currentWorkspace?.logo_url || ""}
classNames="my-auto size-4 text-xs"
/>
<div className="text-xs my-auto text-custom-text-100 font-semibold">{currentWorkspace?.name}</div>
</div>
</div>
</Link>
<div className="flex flex-col gap-1.5">
{/* Description */}
<div className="text-custom-text-100 font-semibold text-2xl">{t("settings")}</div>
{/* Actions */}
<SettingsTabs />
</div>
</div>
);
});

View File

@@ -0,0 +1,46 @@
import { Button } from "@plane/propel/button";
import { cn } from "@plane/ui";
type Props = {
title: string | React.ReactNode;
description?: string;
appendToRight?: React.ReactNode;
showButton?: boolean;
customButton?: React.ReactNode;
button?: {
label: string;
onClick: () => void;
};
className?: string;
};
export const SettingsHeading = ({
title,
description,
button,
appendToRight,
customButton,
showButton = true,
className,
}: Props) => (
<div
className={cn(
"flex flex-col md:flex-row gap-2 items-start md:items-center justify-between border-b border-custom-border-100 pb-3.5",
className
)}
>
<div className="flex flex-col items-start gap-1">
{typeof title === "string" ? <h3 className="text-xl font-medium">{title}</h3> : title}
{description && <div className="text-sm text-custom-text-300">{description}</div>}
</div>
{showButton && customButton}
{button && showButton && (
<Button variant="primary" onClick={button.onClick} size="sm" className="w-fit">
{button.label}
</Button>
)}
{appendToRight}
</div>
);
export default SettingsHeading;

View File

@@ -0,0 +1,56 @@
import { GROUPED_PROFILE_SETTINGS, GROUPED_WORKSPACE_SETTINGS } from "@plane/constants";
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
const hrefToLabelMap = (options: Record<string, Array<{ href: string; i18n_label: string; [key: string]: any }>>) =>
Object.values(options)
.flat()
.reduce(
(acc, setting) => {
acc[setting.href] = setting.i18n_label;
return acc;
},
{} as Record<string, string>
);
const workspaceHrefToLabelMap = hrefToLabelMap(GROUPED_WORKSPACE_SETTINGS);
const profiletHrefToLabelMap = hrefToLabelMap(GROUPED_PROFILE_SETTINGS);
const projectHrefToLabelMap = PROJECT_SETTINGS_LINKS.reduce(
(acc, setting) => {
acc[setting.href] = setting.i18n_label;
return acc;
},
{} as Record<string, string>
);
export const pathnameToAccessKey = (pathname: string) => {
const pathArray = pathname.replace(/^\/|\/$/g, "").split("/"); // Regex removes leading and trailing slashes
const workspaceSlug = pathArray[0];
const accessKey = pathArray.slice(1, 3).join("/");
return { workspaceSlug, accessKey: `/${accessKey}` || "" };
};
export const getWorkspaceActivePath = (pathname: string) => {
const parts = pathname.split("/").filter(Boolean);
const settingsIndex = parts.indexOf("settings");
if (settingsIndex === -1) return null;
const subPath = "/" + parts.slice(settingsIndex, settingsIndex + 2).join("/");
return workspaceHrefToLabelMap[subPath];
};
export const getProfileActivePath = (pathname: string) => {
const parts = pathname.split("/").filter(Boolean);
const settingsIndex = parts.indexOf("settings");
if (settingsIndex === -1) return null;
const subPath = "/" + parts.slice(settingsIndex, settingsIndex + 3).join("/");
return profiletHrefToLabelMap[subPath];
};
export const getProjectActivePath = (pathname: string) => {
const parts = pathname.split("/").filter(Boolean);
const settingsIndex = parts.indexOf("settings");
if (settingsIndex === -1) return null;
const subPath = parts.slice(settingsIndex + 3, settingsIndex + 4).join("/");
return subPath ? projectHrefToLabelMap["/" + subPath] : projectHrefToLabelMap[subPath];
};

View File

@@ -0,0 +1,13 @@
import { useRef } from "react";
import { observer } from "mobx-react";
export const SettingsContentLayout = observer(({ children }: { children: React.ReactNode }) => {
// refs
const ref = useRef<HTMLDivElement>(null);
return (
<div className="w-full h-full min-h-full overflow-y-scroll " ref={ref}>
{children}
</div>
);
});

View File

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

View File

@@ -0,0 +1,47 @@
import { useRef } from "react";
import { observer } from "mobx-react";
import { Menu } from "lucide-react";
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { ChevronRightIcon } from "@plane/propel/icons";
import { useUserSettings } from "@/hooks/store/user";
type Props = {
hamburgerContent: React.ComponentType<{ isMobile: boolean }>;
activePath: string;
};
export const SettingsMobileNav = observer((props: Props) => {
const { hamburgerContent: HamburgerContent, activePath } = props;
// refs
const sidebarRef = useRef<HTMLDivElement>(null);
// store hooks
const { sidebarCollapsed, toggleSidebar } = useUserSettings();
const { t } = useTranslation();
useOutsideClickDetector(sidebarRef, () => {
if (!sidebarCollapsed) toggleSidebar(true);
});
return (
<div className="md:hidden">
<div className="border-b border-custom-border-100 py-3 flex items-center gap-4">
<div ref={sidebarRef} className="relative w-fit">
{!sidebarCollapsed && <HamburgerContent isMobile />}
<button
type="button"
className="z-50 group flex-shrink-0 size-6 grid place-items-center rounded border border-custom-border-200 transition-all bg-custom-background md:hidden"
onClick={() => toggleSidebar()}
>
<Menu className="size-3.5 text-custom-text-200 transition-all group-hover:text-custom-text-100" />
</button>
</div>
{/* path */}
<div className="flex items-center gap-2">
<ChevronRightIcon className="size-4 text-custom-text-300" />
<span className="text-sm font-medium text-custom-text-200">{t(activePath)}</span>
</div>
</div>
</div>
);
});

View File

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

View File

@@ -0,0 +1,80 @@
import { range } from "lodash-es";
import { observer } from "mobx-react";
import Link from "next/link";
import { usePathname, useParams } from "next/navigation";
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Loader } from "@plane/ui";
import { cn } from "@plane/utils";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions, useUserSettings } from "@/hooks/store/user";
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings";
export const NavItemChildren = observer((props: { projectId: string }) => {
const { projectId } = props;
const { workspaceSlug } = useParams();
const pathname = usePathname();
// mobx store
const { getProjectById } = useProject();
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
const { toggleSidebar } = useUserSettings();
// derived values
const currentProject = getProjectById(projectId);
if (!currentProject) {
return (
<div className="flex w-[280px] flex-col gap-6">
<div className="flex flex-col gap-2">
<Loader className="flex w-full flex-col gap-2">
{range(8).map((index) => (
<Loader.Item key={index} height="34px" />
))}
</Loader>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<div className="flex w-full flex-col gap-1">
{PROJECT_SETTINGS_LINKS.map((link) => {
const isActive = link.highlight(pathname, `/${workspaceSlug}/settings/projects/${projectId}`);
return (
allowPermissions(
link.access,
EUserPermissionsLevel.PROJECT,
workspaceSlug?.toString() ?? "",
projectId?.toString() ?? ""
) && (
<Link
key={link.key}
href={`/${workspaceSlug}/settings/projects/${projectId}${link.href}`}
onClick={() => toggleSidebar(true)}
>
<div
className={cn(
"cursor-pointer relative group w-full flex items-center justify-between gap-1.5 rounded p-1 px-1.5 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,
},
"text-xs font-medium"
)}
>
{t(getProjectSettingsPageLabelI18nKey(link.key, link.i18n_label))}
</div>
</Link>
)
);
})}
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,52 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { PROJECT_SETTINGS_CATEGORIES, PROJECT_SETTINGS_CATEGORY } from "@plane/constants";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { getUserRole } from "@plane/utils";
// components
// hooks
import { useProject } from "@/hooks/store/use-project";
// local imports
import { SettingsSidebar } from "../../sidebar";
import { NavItemChildren } from "./nav-item-children";
type TProjectSettingsSidebarProps = {
isMobile?: boolean;
};
export const ProjectSettingsSidebar = observer((props: TProjectSettingsSidebarProps) => {
const { isMobile = false } = props;
const { workspaceSlug } = useParams();
// store hooks
const { joinedProjectIds, projectMap } = useProject();
const groupedProject = joinedProjectIds.map((projectId) => ({
key: projectId,
i18n_label: projectMap[projectId].name,
href: `/settings/projects/${projectId}`,
icon: <Logo logo={projectMap[projectId].logo_props} />,
}));
return (
<SettingsSidebar
isMobile={isMobile}
categories={PROJECT_SETTINGS_CATEGORIES}
groupedSettings={{
[PROJECT_SETTINGS_CATEGORY.PROJECTS]: groupedProject,
}}
workspaceSlug={workspaceSlug.toString()}
isActive={false}
appendItemsToTitle={(key: string) => {
const role = projectMap[key].member_role;
return (
<div className="text-xs font-medium text-custom-text-200 capitalize bg-custom-background-90 rounded-md px-1 py-0.5">
{role ? getUserRole(role)?.toLowerCase() : "Guest"}
</div>
);
}}
shouldRender
renderChildren={(key: string) => <NavItemChildren projectId={key} />}
/>
);
});

View File

@@ -0,0 +1,38 @@
import { observer } from "mobx-react";
// plane imports
import { getUserRole } from "@plane/utils";
// components
import { WorkspaceLogo } from "@/components/workspace/logo";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
// plane web imports
import { SubscriptionPill } from "@/plane-web/components/common/subscription/subscription-pill";
export const SettingsSidebarHeader = observer((props: { customHeader?: React.ReactNode }) => {
const { customHeader } = props;
const { currentWorkspace } = useWorkspace();
return customHeader
? customHeader
: currentWorkspace && (
<div className="flex w-full gap-3 items-center justify-between px-2">
<div className="flex w-full gap-3 items-center overflow-hidden">
<WorkspaceLogo
logo={currentWorkspace.logo_url ?? ""}
name={currentWorkspace.name ?? ""}
classNames="size-8 border border-custom-border-200"
/>
<div className="w-full overflow-hidden">
<div className="text-base font-medium text-custom-text-200 truncate text-ellipsis ">
{currentWorkspace.name ?? "Workspace"}
</div>
<div className="text-sm text-custom-text-300 capitalize">
{getUserRole(currentWorkspace.role)?.toLowerCase() || "guest"}
</div>
</div>
</div>
<div className="flex-shrink-0">
<SubscriptionPill />
</div>
</div>
);
});

View File

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

View File

@@ -0,0 +1,95 @@
import React, { useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Disclosure } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { EUserWorkspaceRoles } from "@plane/types";
import { cn, joinUrlPath } from "@plane/utils";
// hooks
import { useUserSettings } from "@/hooks/store/user";
export type TSettingItem = {
key: string;
i18n_label: string;
href: string;
access?: EUserWorkspaceRoles[];
icon?: React.ReactNode;
};
export type TSettingsSidebarNavItemProps = {
workspaceSlug: string;
setting: TSettingItem;
isActive: boolean | ((data: { href: string }) => boolean);
actionIcons?: (props: { type: string; size?: number; className?: string }) => React.ReactNode;
appendItemsToTitle?: (key: string) => React.ReactNode;
renderChildren?: (key: string) => React.ReactNode;
};
const SettingsSidebarNavItem = observer((props: TSettingsSidebarNavItemProps) => {
const { workspaceSlug, setting, isActive, actionIcons, appendItemsToTitle, renderChildren } = props;
// router
const { projectId } = useParams();
// i18n
const { t } = useTranslation();
// state
const [isExpanded, setIsExpanded] = useState(projectId === setting.key);
// hooks
const { toggleSidebar } = useUserSettings();
// derived
const buttonClass = cn(
"flex w-full items-center px-2 py-1.5 rounded text-custom-text-200 justify-between",
"hover:bg-custom-primary-100/10",
{
"text-custom-text-200 bg-custom-background-80/75": typeof isActive === "function" ? isActive(setting) : isActive,
"text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-90 active:bg-custom-sidebar-background-90":
typeof isActive === "function" ? !isActive(setting) : !isActive,
}
);
const titleElement = (
<>
<div className="flex items-center gap-1.5 overflow-hidden">
{setting.icon
? setting.icon
: actionIcons && actionIcons({ type: setting.key, size: 16, className: "w-4 h-4" })}
<div className="text-sm font-medium truncate">{t(setting.i18n_label)}</div>
</div>
{appendItemsToTitle?.(setting.key)}
</>
);
return (
<Disclosure as="div" className="flex flex-col w-full" defaultOpen={isExpanded} key={setting.key}>
<Disclosure.Button
as="button"
type="button"
className={cn(
"group w-full flex items-center gap-1 whitespace-nowrap text-left text-sm font-semibold text-custom-sidebar-text-400"
)}
onClick={() => setIsExpanded(!isExpanded)}
>
{renderChildren ? (
<div className={buttonClass}>{titleElement}</div>
) : (
<Link
href={joinUrlPath(workspaceSlug, setting.href)}
className={buttonClass}
onClick={() => toggleSidebar(true)}
>
{titleElement}
</Link>
)}
</Disclosure.Button>
{/* Nested Navigation */}
{isExpanded && (
<Disclosure.Panel as="div" className={cn("relative flex flex-col gap-0.5 mt-1 pl-6 mb-1.5")} static>
<div className="absolute left-[15px] top-0 bottom-1 w-[1px] bg-custom-border-200" />
{renderChildren?.(setting.key)}
</Disclosure.Panel>
)}
</Disclosure>
);
});
export default SettingsSidebarNavItem;

View File

@@ -0,0 +1,77 @@
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
import { SettingsSidebarHeader } from "./header";
import type { TSettingItem } from "./nav-item";
import SettingsSidebarNavItem from "./nav-item";
interface SettingsSidebarProps {
isMobile?: boolean;
customHeader?: React.ReactNode;
categories: string[];
groupedSettings: {
[key: string]: TSettingItem[];
};
workspaceSlug: string;
isActive: boolean | ((data: { href: string }) => boolean);
shouldRender: boolean | ((setting: TSettingItem) => boolean);
actionIcons?: (props: { type: string; size?: number; className?: string }) => React.ReactNode;
appendItemsToTitle?: (key: string) => React.ReactNode;
renderChildren?: (key: string) => React.ReactNode;
}
export const SettingsSidebar = observer((props: SettingsSidebarProps) => {
const {
isMobile = false,
customHeader,
categories,
groupedSettings,
workspaceSlug,
isActive,
shouldRender,
actionIcons,
appendItemsToTitle,
renderChildren,
} = props;
// hooks
const { t } = useTranslation();
return (
<div
className={cn("flex w-[250px] flex-col gap-2 flex-shrink-0 overflow-y-scroll h-full md:pt-page-y ", {
"absolute left-0 top-[42px] z-50 h-fit max-h-[400px] overflow-scroll bg-custom-background-100 border border-custom-border-100 rounded shadow-sm p-4":
isMobile,
})}
>
{/* Header */}
<SettingsSidebarHeader customHeader={customHeader} />
{/* Navigation */}
<div className="divide-y divide-custom-border-100 overflow-x-hidden w-full h-full overflow-y-scroll vertical-scrollbar scrollbar-sm">
{categories.map((category) => {
if (groupedSettings[category].length === 0) return null;
return (
<div key={category} className="py-3">
<span className="text-sm font-semibold text-custom-text-350 capitalize mb-2 px-2">{t(category)}</span>
<div className="relative flex flex-col gap-0.5 h-full mt-2">
{groupedSettings[category].map(
(setting) =>
(typeof shouldRender === "function" ? shouldRender(setting) : shouldRender) && (
<SettingsSidebarNavItem
key={setting.key}
setting={setting}
workspaceSlug={workspaceSlug}
isActive={isActive}
appendItemsToTitle={appendItemsToTitle}
renderChildren={renderChildren}
actionIcons={actionIcons}
/>
)
)}
</div>
</div>
);
})}
</div>
</div>
);
});

View File

@@ -0,0 +1,63 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@plane/utils";
import { useProject } from "@/hooks/store/use-project";
const TABS = {
account: {
key: "account",
label: "Account",
href: `/settings/account/`,
},
workspace: {
key: "workspace",
label: "Workspace",
href: `/settings/`,
},
projects: {
key: "projects",
label: "Projects",
href: `/settings/projects/`,
},
};
const SettingsTabs = observer(() => {
// router
const pathname = usePathname();
const { workspaceSlug } = useParams();
// store hooks
const { joinedProjectIds } = useProject();
const currentTab = pathname.includes(TABS.projects.href)
? TABS.projects
: pathname.includes(TABS.account.href)
? TABS.account
: TABS.workspace;
return (
<div className="flex w-fit min-w-fit items-center justify-between gap-1.5 rounded-md text-sm p-0.5 bg-custom-background-80">
{Object.values(TABS).map((tab) => {
const isActive = currentTab?.key === tab.key;
const href = tab.key === TABS.projects.key ? `${tab.href}${joinedProjectIds[0] || ""}` : tab.href;
return (
<Link
key={tab.key}
href={`/${workspaceSlug}${href}`}
className={cn(
"flex items-center justify-center p-1 min-w-fit w-full font-medium outline-none focus:outline-none cursor-pointer transition-all rounded text-custom-text-200 ",
{
"bg-custom-background-100 text-custom-text-100 shadow-sm": isActive,
"hover:text-custom-text-100 hover:bg-custom-background-80/60": !isActive,
}
)}
>
<div className="text-xs font-semibold p-1">{tab.label}</div>
</Link>
);
})}
</div>
);
});
export default SettingsTabs;