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
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:
22
apps/web/core/components/settings/content-wrapper.tsx
Normal file
22
apps/web/core/components/settings/content-wrapper.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
88
apps/web/core/components/settings/header.tsx
Normal file
88
apps/web/core/components/settings/header.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
46
apps/web/core/components/settings/heading.tsx
Normal file
46
apps/web/core/components/settings/heading.tsx
Normal 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;
|
||||
56
apps/web/core/components/settings/helper.ts
Normal file
56
apps/web/core/components/settings/helper.ts
Normal 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];
|
||||
};
|
||||
13
apps/web/core/components/settings/layout.tsx
Normal file
13
apps/web/core/components/settings/layout.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
1
apps/web/core/components/settings/mobile/index.ts
Normal file
1
apps/web/core/components/settings/mobile/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./nav";
|
||||
47
apps/web/core/components/settings/mobile/nav.tsx
Normal file
47
apps/web/core/components/settings/mobile/nav.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
52
apps/web/core/components/settings/project/sidebar/root.tsx
Normal file
52
apps/web/core/components/settings/project/sidebar/root.tsx
Normal 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} />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
38
apps/web/core/components/settings/sidebar/header.tsx
Normal file
38
apps/web/core/components/settings/sidebar/header.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
1
apps/web/core/components/settings/sidebar/index.ts
Normal file
1
apps/web/core/components/settings/sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
95
apps/web/core/components/settings/sidebar/nav-item.tsx
Normal file
95
apps/web/core/components/settings/sidebar/nav-item.tsx
Normal 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;
|
||||
77
apps/web/core/components/settings/sidebar/root.tsx
Normal file
77
apps/web/core/components/settings/sidebar/root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
63
apps/web/core/components/settings/tabs.tsx
Normal file
63
apps/web/core/components/settings/tabs.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user