UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: расширенный home layout и аналитические панели

This commit is contained in:
DCCONSTRUCTIONS 2026-04-30 23:34:34 +03:00
parent d28f83fe5e
commit 7ff7d83b07
39 changed files with 2064 additions and 428 deletions

View File

@ -15,12 +15,15 @@ import { Breadcrumbs, Header } from "@plane/ui";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
// hooks
import { useHome } from "@/hooks/store/use-home";
import { useUserProfile } from "@/hooks/store/user";
export const WorkspaceDashboardHeader = observer(function WorkspaceDashboardHeader() {
// plane hooks
const { t } = useTranslation();
// hooks
const { toggleWidgetSettings } = useHome();
const { data: userProfile } = useUserProfile();
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
return (
<>
@ -36,17 +39,19 @@ export const WorkspaceDashboardHeader = observer(function WorkspaceDashboardHead
</Breadcrumbs>
</div>
</Header.LeftItem>
<Header.RightItem>
<Button
variant="secondary"
size="lg"
onClick={() => toggleWidgetSettings(true)}
className="nodedc-toolbar-pill my-auto mb-0"
prependIcon={<Shapes />}
>
<div className="hidden sm:hidden md:block">{t("home.manage_widgets")}</div>
</Button>
</Header.RightItem>
{isCompactToolbar && (
<Header.RightItem>
<Button
variant="secondary"
size="lg"
onClick={() => toggleWidgetSettings(true)}
className="nodedc-toolbar-pill my-auto mb-0"
prependIcon={<Shapes />}
>
<div className="hidden sm:hidden md:block">{t("home.manage_widgets")}</div>
</Button>
</Header.RightItem>
)}
</Header>
</>
);

View File

@ -7,8 +7,6 @@
*/
import { useMemo } from "react";
import Link from "next/link";
import { Menu } from "@headlessui/react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import useSWR from "swr";
@ -19,157 +17,31 @@ import {
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS,
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { InboxIcon, PlusIcon, ProjectIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import { cn, copyUrlToClipboard, joinUrlPath } from "@plane/utils";
import { TopNavPowerK } from "@/components/navigation";
import { joinUrlPath } from "@plane/utils";
import { openWorkspaceNotificationsModal } from "@/components/workspace-notifications/notifications-modal.utils";
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useUser, useUserPermissions, useUserProfile } from "@/hooks/store/user";
import {
usePersonalNavigationPreferences,
useWorkspaceNavigationPreferences,
} from "@/hooks/use-navigation-preferences";
import { SidebarProjectsListItem } from "@/components/workspace/sidebar/projects-list-item";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { openWorkspaceNotificationsModal } from "@/components/workspace-notifications/notifications-modal.utils";
import { getSidebarNavigationItemIcon } from "@/plane-web/components/workspace/sidebar/helper";
type TToolbarItem = {
key: string;
href?: string;
labelTranslationKey: string;
active: boolean;
icon: React.ReactNode;
onClick?: () => void;
};
const ToolbarIconLink = ({ item }: { item: TToolbarItem }) => {
const { t } = useTranslation();
return (
<Tooltip tooltipContent={t(item.labelTranslationKey)} position="bottom">
<Link
href={item.href ?? "#"}
className="nodedc-toolbar-icon-button flex h-8 w-8 items-center justify-center"
data-active={item.active}
aria-label={t(item.labelTranslationKey)}
>
<span className="nodedc-toolbar-icon-active-dot">{item.icon}</span>
</Link>
</Tooltip>
);
};
const ToolbarIconButton = ({
label,
active = false,
children,
onClick,
disabled = false,
}: {
label: string;
active?: boolean;
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
}) => (
<Tooltip tooltipContent={label} position="bottom">
<button
type="button"
className="nodedc-toolbar-icon-button flex h-8 w-8 items-center justify-center"
data-active={active}
aria-label={label}
onClick={onClick}
disabled={disabled}
>
<span className="nodedc-toolbar-icon-active-dot">{children}</span>
</button>
</Tooltip>
);
const ProjectsToolbarMenu = observer(function ProjectsToolbarMenu() {
const { t } = useTranslation();
const pathname = usePathname();
const { workspaceSlug } = useParams();
const { joinedProjectIds } = useProject();
const { toggleCreateProjectModal } = useCommandPalette();
const handleCopyText = (projectId: string) =>
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("link_copied"),
message: t("project_link_copied_to_clipboard"),
});
});
return (
<Menu as="div" className="relative">
<Menu.Button
type="button"
title={t("workspace_sidebar.projects.main")}
className="nodedc-toolbar-icon-button grid h-8 w-8 place-items-center"
aria-label={t("workspace_sidebar.projects.main")}
>
<span
className={`nodedc-toolbar-icon-active-dot ${
pathname.includes("/projects/")
? "bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]"
: ""
}`}
>
<ProjectIcon className="size-4" />
</span>
</Menu.Button>
<Menu.Items className="absolute top-full -right-2 z-[170] mt-2 origin-top-right">
<div className="nodedc-glass-modal nodedc-glass-popup-surface flex max-h-[70vh] min-w-[26rem] flex-col overflow-hidden rounded-[1.5rem] border-0 p-2 shadow-none outline-none">
<div className="vertical-scrollbar flex scrollbar-sm max-h-[70vh] flex-col gap-0.5 overflow-y-auto pr-1">
{joinedProjectIds.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
handleCopyText={() => handleCopyText(projectId)}
projectListType="JOINED"
disableDrag
disableDrop
isLastChild={index === joinedProjectIds.length - 1}
renderInToolbarMenu
/>
))}
</div>
<div className="mt-2 border-t border-white/8 px-1 pt-2">
<Menu.Item>
<button
type="button"
className="flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-13 font-medium text-secondary transition-colors hover:bg-layer-transparent-hover hover:text-primary"
onClick={() => toggleCreateProjectModal(true)}
>
<span className="grid size-8 flex-shrink-0 place-items-center">
<PlusIcon className="size-4" />
</span>
<span>{t("create_project")}</span>
</button>
</Menu.Item>
</div>
</div>
</Menu.Items>
</Menu>
);
});
import {
DEFAULT_PROJECT_SHELL_TOOLBAR_LAYOUT,
PROJECT_SHELL_TOOLBAR_LAYOUTS,
type TProjectShellToolbarLayout,
type TToolbarItem,
} from "./top-toolbar";
export const ProjectShellTopToolbar = observer(function ProjectShellTopToolbar() {
const { t } = useTranslation();
const pathname = usePathname();
const { workspaceSlug } = useParams();
const { toggleCreateIssueModal } = useCommandPalette();
const { joinedProjectIds } = useProject();
const { data: currentUser } = useUser();
const { data: userProfile } = useUserProfile();
const { allowPermissions } = useUserPermissions();
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
const { preferences: personalPreferences } = usePersonalNavigationPreferences();
@ -186,7 +58,7 @@ export const ProjectShellTopToolbar = observer(function ProjectShellTopToolbar()
);
const isMentionsEnabled = unreadNotificationsCount.mention_unread_notifications_count > 0;
const totalNotifications = isMentionsEnabled
const notificationsCount = isMentionsEnabled
? unreadNotificationsCount.mention_unread_notifications_count
: unreadNotificationsCount.total_unread_notifications_count;
@ -249,62 +121,27 @@ export const ProjectShellTopToolbar = observer(function ProjectShellTopToolbar()
}).sort((a, b) => a.sort_order - b.sort_order),
[pathname, workspacePreferences, workspaceSlug]
);
const workspaceSlugValue = workspaceSlug?.toString();
const isWorkspaceHome = pathname === `/${workspaceSlugValue}` || pathname === `/${workspaceSlugValue}/`;
const toolbarLayout: TProjectShellToolbarLayout =
userProfile?.theme?.nodedcCompactToolbar === true ? "compact" : DEFAULT_PROJECT_SHELL_TOOLBAR_LAYOUT;
const ToolbarLayout = PROJECT_SHELL_TOOLBAR_LAYOUTS[toolbarLayout];
return (
<div
className={cn("z-20 w-full flex-shrink-0 px-4 pt-4 pb-3", {
"nodedc-home-top-toolbar": isWorkspaceHome,
})}
>
<div className="nodedc-glass-modal flex w-full flex-wrap items-center justify-between gap-4 rounded-[1.6rem] px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible">
<WorkspaceMenuRoot variant="toolbar" />
<TopNavPowerK variant="sidebar" />
<UserMenuRoot variant="toolbar" />
<Tooltip tooltipContent={t("notification.label")} position="bottom">
<button
type="button"
className="nodedc-toolbar-icon-button relative flex h-8 w-8 items-center justify-center"
data-active={false}
aria-label={t("notification.label")}
onClick={() => openWorkspaceNotificationsModal()}
>
<span className="nodedc-toolbar-icon-active-dot">
<InboxIcon className="size-4" />
</span>
{totalNotifications > 0 && (
<span className="absolute top-1.5 right-1.5 size-2 rounded-full bg-danger-primary" />
)}
</button>
</Tooltip>
<ToolbarIconButton
label={t("app_header.add_task")}
onClick={() => toggleCreateIssueModal(true)}
disabled={!canCreateIssue || joinedProjectIds.length === 0}
>
<PlusIcon className="size-4" />
</ToolbarIconButton>
</div>
</div>
<div className="flex min-w-0 items-center justify-end gap-3">
<div className="nodedc-toolbar-group flex items-center gap-1">
{primaryItems.map((item) => (
<ToolbarIconLink key={item.key} item={item} />
))}
</div>
<div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible">
<ProjectsToolbarMenu />
{secondaryItems.map((item) => (
<ToolbarIconLink key={item.key} item={item} />
))}
</div>
</div>
</div>
</div>
<ToolbarLayout
canCreateIssue={canCreateIssue}
draftsItem={primaryItems.find((item) => item.key === "drafts")}
homeItem={primaryItems.find((item) => item.key === "home")}
isWorkspaceHome={isWorkspaceHome}
joinedProjectIdsCount={joinedProjectIds.length}
notificationsCount={notificationsCount}
primaryItems={primaryItems}
profileItem={primaryItems.find((item) => item.key === "your_work")}
secondaryItems={secondaryItems}
stickiesItem={primaryItems.find((item) => item.key === "stickies")}
onCreateIssue={() => toggleCreateIssueModal(true)}
onOpenNotifications={() => openWorkspaceNotificationsModal()}
/>
);
});

View File

@ -21,6 +21,7 @@ import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { ExpandedToolbarBreadcrumbs } from "@/plane-web/components/breadcrumbs/expanded-toolbar-breadcrumbs";
import { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature";
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store";
@ -68,7 +69,7 @@ export const PageDetailsHeader = observer(function PageDetailsHeader() {
<Header>
<Header.LeftItem>
<div>
<Breadcrumbs isLoading={loader === "init-loader"}>
<ExpandedToolbarBreadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()}
@ -94,7 +95,7 @@ export const PageDetailsHeader = observer(function PageDetailsHeader() {
/>
}
/>
</Breadcrumbs>
</ExpandedToolbarBreadcrumbs>
</div>
</Header.LeftItem>
<Header.RightItem>

View File

@ -14,13 +14,14 @@ import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TPage } from "@plane/types";
// plane ui
import { Breadcrumbs, Header } from "@plane/ui";
import { Header } from "@plane/ui";
// components
import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
// hooks
import { useProject } from "@/hooks/store/use-project";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { ExpandedToolbarBreadcrumbs } from "@/plane-web/components/breadcrumbs/expanded-toolbar-breadcrumbs";
import { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature";
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
@ -62,7 +63,7 @@ export const PagesListHeader = observer(function PagesListHeader() {
return (
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<ExpandedToolbarBreadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()}
@ -70,7 +71,7 @@ export const PagesListHeader = observer(function PagesListHeader() {
featureKey={EProjectFeatureKey.PAGES}
isLast
/>
</Breadcrumbs>
</ExpandedToolbarBreadcrumbs>
</Header.LeftItem>
{canCurrentUserCreatePage && (
<Header.RightItem>

View File

@ -0,0 +1,77 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useTranslation } from "@plane/i18n";
import { PlusIcon } from "@plane/propel/icons";
import { cn } from "@plane/utils";
// components
import { TopNavPowerK } from "@/components/navigation";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { ProjectsToolbarMenu } from "./projects-toolbar-menu";
import { ToolbarIconButton, ToolbarIconLink, ToolbarNotificationsButton } from "./toolbar-controls";
// types
import type { TProjectShellToolbarLayoutProps } from "./types";
export const CompactProjectShellToolbarLayout = ({
canCreateIssue,
isWorkspaceHome,
joinedProjectIdsCount,
notificationsCount,
primaryItems,
secondaryItems,
onCreateIssue,
onOpenNotifications,
}: TProjectShellToolbarLayoutProps) => {
const { t } = useTranslation();
return (
<div
className={cn("z-20 w-full flex-shrink-0 px-4 pt-4 pb-3", {
"nodedc-home-top-toolbar": isWorkspaceHome,
})}
>
<div className="nodedc-glass-modal flex w-full flex-wrap items-center justify-between gap-4 rounded-[1.6rem] px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible">
<WorkspaceMenuRoot variant="toolbar" />
<TopNavPowerK variant="sidebar" />
<UserMenuRoot variant="toolbar" />
<ToolbarNotificationsButton
label={t("notification.label")}
notificationsCount={notificationsCount}
onClick={onOpenNotifications}
/>
<ToolbarIconButton
label={t("app_header.add_task")}
onClick={onCreateIssue}
disabled={!canCreateIssue || joinedProjectIdsCount === 0}
>
<PlusIcon className="size-4" />
</ToolbarIconButton>
</div>
</div>
<div className="flex min-w-0 items-center justify-end gap-3">
<div className="nodedc-toolbar-group flex items-center gap-1">
{primaryItems.map((item) => (
<ToolbarIconLink key={item.key} item={item} />
))}
</div>
<div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible">
<ProjectsToolbarMenu />
{secondaryItems.map((item) => (
<ToolbarIconLink key={item.key} item={item} />
))}
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,91 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useTranslation } from "@plane/i18n";
import { Shapes } from "lucide-react";
import { cn } from "@plane/utils";
// components
import { TopNavPowerK } from "@/components/navigation";
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
import { useHome } from "@/hooks/store/use-home";
import { ProjectsToolbarMenu } from "./projects-toolbar-menu";
import { ExpandedToolbarLink, ExpandedToolbarToolButton, ToolbarNotificationsButton } from "./toolbar-controls";
// types
import type { TProjectShellToolbarLayoutProps } from "./types";
export const ExpandedProjectShellToolbarLayout = ({
draftsItem,
homeItem,
isWorkspaceHome,
notificationsCount,
profileItem,
stickiesItem,
onOpenNotifications,
}: TProjectShellToolbarLayoutProps) => {
const { t } = useTranslation();
const { toggleWidgetSettings } = useHome();
return (
<div
className={cn("z-20 w-full flex-shrink-0 px-5 pt-4 pb-3", {
"nodedc-home-top-toolbar": isWorkspaceHome,
})}
>
<div className="nodedc-expanded-toolbar">
<div className="nodedc-expanded-toolbar-top">
<div className="nodedc-expanded-toolbar-left">
<img src="/nodedc-logo.svg" alt="NODE DC" className="nodedc-expanded-brand-logo" />
</div>
<div className="nodedc-expanded-toolbar-center">
<WorkspaceMenuRoot variant="expanded-toolbar" />
<div className="nodedc-expanded-nav-group">
<ExpandedToolbarLink item={homeItem} label="Главная" />
<ProjectsToolbarMenu variant="expanded" />
<ExpandedToolbarLink item={stickiesItem} label="Стикеры" />
<ExpandedToolbarLink item={draftsItem} label="Черновики" />
</div>
</div>
<div className="nodedc-expanded-toolbar-right">
<div className="nodedc-expanded-user-group">
<ExpandedToolbarLink item={profileItem} label="Профиль" />
<ToolbarNotificationsButton
label={t("notification.label")}
notificationsCount={notificationsCount}
onClick={onOpenNotifications}
variant="expanded"
/>
<UserMenuRoot variant="expanded-toolbar" />
</div>
</div>
</div>
<div className="nodedc-expanded-toolbar-tools-row">
<div className="nodedc-expanded-breadcrumbs-slot" data-nodedc-expanded-breadcrumbs-slot />
{!isWorkspaceHome && (
<div className="nodedc-expanded-main-tool-cluster">
<TopNavPowerK variant="expanded-toolbar" />
<div className="nodedc-expanded-header-filters-slot" data-nodedc-expanded-header-filters-slot />
</div>
)}
<div className="nodedc-expanded-action-tool-cluster">
<div className="nodedc-expanded-tool-slot" data-nodedc-voice-task-toolbar-slot />
{isWorkspaceHome && (
<ExpandedToolbarToolButton label={t("home.manage_widgets")} onClick={() => toggleWidgetSettings(true)}>
<Shapes className="size-4" />
</ExpandedToolbarToolButton>
)}
<div className="nodedc-expanded-primary-action-slot" data-nodedc-expanded-primary-action-slot />
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,2 @@
export * from "./layout-registry";
export * from "./types";

View File

@ -0,0 +1,22 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ComponentType } from "react";
// components
import { CompactProjectShellToolbarLayout } from "./compact-layout";
import { ExpandedProjectShellToolbarLayout } from "./expanded-layout";
// types
import type { TProjectShellToolbarLayout, TProjectShellToolbarLayoutProps } from "./types";
export const DEFAULT_PROJECT_SHELL_TOOLBAR_LAYOUT: TProjectShellToolbarLayout = "expanded";
export const PROJECT_SHELL_TOOLBAR_LAYOUTS: Record<
TProjectShellToolbarLayout,
ComponentType<TProjectShellToolbarLayoutProps>
> = {
compact: CompactProjectShellToolbarLayout,
expanded: ExpandedProjectShellToolbarLayout,
};

View File

@ -0,0 +1,102 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Menu } from "@headlessui/react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { useTranslation } from "@plane/i18n";
import { PlusIcon, ProjectIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { cn, copyUrlToClipboard } from "@plane/utils";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
// components
import { SidebarProjectsListItem } from "@/components/workspace/sidebar/projects-list-item";
export const ProjectsToolbarMenu = observer(function ProjectsToolbarMenu({
variant = "compact",
}: {
variant?: "compact" | "expanded";
}) {
const { t } = useTranslation();
const pathname = usePathname();
const { workspaceSlug } = useParams();
const { joinedProjectIds } = useProject();
const { toggleCreateProjectModal } = useCommandPalette();
const handleCopyText = (projectId: string) =>
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("link_copied"),
message: t("project_link_copied_to_clipboard"),
});
});
return (
<Menu as="div" className="relative">
<Menu.Button
type="button"
title={t("workspace_sidebar.projects.main")}
className={cn(
variant === "expanded"
? "nodedc-expanded-nav-button"
: "nodedc-toolbar-icon-button grid h-8 w-8 place-items-center"
)}
data-active={pathname.includes("/projects/")}
aria-label={t("workspace_sidebar.projects.main")}
>
{variant === "expanded" ? null : (
<span className="nodedc-toolbar-icon-active-dot">
<ProjectIcon className="size-4" />
</span>
)}
{variant === "expanded" && <span>Проекты</span>}
</Menu.Button>
<Menu.Items
className={cn(
"absolute top-full z-[170] mt-2",
variant === "expanded" ? "left-0 origin-top-left" : "-right-2 origin-top-right"
)}
>
<div className="nodedc-glass-modal nodedc-glass-popup-surface flex max-h-[70vh] min-w-[26rem] flex-col overflow-hidden rounded-[1.5rem] border-0 p-2 shadow-none outline-none">
<div className="vertical-scrollbar flex scrollbar-sm max-h-[70vh] flex-col gap-0.5 overflow-y-auto pr-1">
{joinedProjectIds.map((projectId, index) => (
<SidebarProjectsListItem
key={projectId}
projectId={projectId}
handleCopyText={() => handleCopyText(projectId)}
projectListType="JOINED"
disableDrag
disableDrop
isLastChild={index === joinedProjectIds.length - 1}
renderInToolbarMenu
/>
))}
</div>
<div className="mt-2 border-t border-white/8 px-1 pt-2">
<Menu.Item>
<button
type="button"
className="flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-13 font-medium text-secondary transition-colors hover:bg-layer-transparent-hover hover:text-primary"
onClick={() => toggleCreateProjectModal(true)}
>
<span className="grid size-8 flex-shrink-0 place-items-center">
<PlusIcon className="size-4" />
</span>
<span>{t("create_project")}</span>
</button>
</Menu.Item>
</div>
</div>
</Menu.Items>
</Menu>
);
});

View File

@ -0,0 +1,118 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import Link from "next/link";
import type { ReactNode } from "react";
import { useTranslation } from "@plane/i18n";
import { InboxIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
// types
import type { TToolbarItem } from "./types";
export const ToolbarIconLink = ({ item }: { item: TToolbarItem }) => {
const { t } = useTranslation();
return (
<Tooltip tooltipContent={t(item.labelTranslationKey)} position="bottom">
<Link
href={item.href ?? "#"}
className="nodedc-toolbar-icon-button flex h-8 w-8 items-center justify-center"
data-active={item.active}
aria-label={t(item.labelTranslationKey)}
>
<span className="nodedc-toolbar-icon-active-dot">{item.icon}</span>
</Link>
</Tooltip>
);
};
export const ToolbarIconButton = ({
label,
active = false,
children,
onClick,
disabled = false,
}: {
label: string;
active?: boolean;
children: ReactNode;
onClick?: () => void;
disabled?: boolean;
}) => (
<Tooltip tooltipContent={label} position="bottom">
<button
type="button"
className="nodedc-toolbar-icon-button flex h-8 w-8 items-center justify-center"
data-active={active}
aria-label={label}
onClick={onClick}
disabled={disabled}
>
<span className="nodedc-toolbar-icon-active-dot">{children}</span>
</button>
</Tooltip>
);
export const ToolbarNotificationsButton = ({
label,
notificationsCount,
onClick,
variant = "compact",
}: {
label: string;
notificationsCount: number;
onClick: () => void;
variant?: "compact" | "expanded";
}) => (
<Tooltip tooltipContent={label} position="bottom">
<button
type="button"
className={cn(
"nodedc-toolbar-icon-button relative flex items-center justify-center",
variant === "expanded" ? "nodedc-expanded-notification-button" : "h-8 w-8"
)}
data-active={false}
aria-label={label}
onClick={onClick}
>
<span className="nodedc-toolbar-icon-active-dot">
<InboxIcon className={variant === "expanded" ? "size-5" : "size-4"} />
</span>
{notificationsCount > 0 && (
<span className="nodedc-toolbar-notification-dot absolute top-1.5 right-1.5 size-2 rounded-full bg-danger-primary" />
)}
</button>
</Tooltip>
);
export const ExpandedToolbarLink = ({ item, label }: { item?: TToolbarItem; label: string }) => {
if (!item?.href) return null;
return (
<Link href={item.href} className="nodedc-expanded-nav-button" data-active={item.active}>
<span>{label}</span>
</Link>
);
};
export const ExpandedToolbarToolButton = ({
label,
children,
onClick,
}: {
label: string;
children: ReactNode;
onClick?: () => void;
}) => (
<Tooltip tooltipContent={label} position="bottom">
<button type="button" className="nodedc-expanded-tool-button" aria-label={label} onClick={onClick}>
{children}
</button>
</Tooltip>
);

View File

@ -0,0 +1,33 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
export type TProjectShellToolbarLayout = "compact" | "expanded";
export type TToolbarItem = {
key: string;
href?: string;
labelTranslationKey: string;
active: boolean;
icon: ReactNode;
onClick?: () => void;
};
export type TProjectShellToolbarLayoutProps = {
canCreateIssue: boolean;
draftsItem?: TToolbarItem;
homeItem?: TToolbarItem;
isWorkspaceHome: boolean;
joinedProjectIdsCount: number;
notificationsCount: number;
primaryItems: TToolbarItem[];
profileItem?: TToolbarItem;
secondaryItems: TToolbarItem[];
stickiesItem?: TToolbarItem;
onCreateIssue: () => void;
onOpenNotifications: () => void;
};

View File

@ -6,18 +6,28 @@
// local components
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
import { useUserProfile } from "@/hooks/store/user";
import { ProjectBreadcrumb } from "./project";
type TCommonProjectBreadcrumbProps = {
workspaceSlug: string;
projectId: string;
shouldTruncate?: boolean;
};
export function CommonProjectBreadcrumbs(props: TCommonProjectBreadcrumbProps) {
const { workspaceSlug, projectId } = props;
const { workspaceSlug, projectId, shouldTruncate } = props;
// preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences();
const { data: userProfile } = useUserProfile();
const shouldUseCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
if (projectPreferences.navigationMode === "TABBED") return null;
return <ProjectBreadcrumb workspaceSlug={workspaceSlug} projectId={projectId} />;
return (
<ProjectBreadcrumb
workspaceSlug={workspaceSlug}
projectId={projectId}
shouldTruncate={shouldTruncate ?? shouldUseCompactToolbar}
/>
);
}

View File

@ -0,0 +1,59 @@
"use client";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import { observer } from "mobx-react";
import { Breadcrumbs } from "@plane/ui";
import { useUserProfile } from "@/hooks/store/user";
type TExpandedToolbarBreadcrumbsProps = {
children: ReactNode;
isLoading?: boolean;
onBack?: () => void;
};
export const ExpandedToolbarBreadcrumbs = observer(function ExpandedToolbarBreadcrumbs(
props: TExpandedToolbarBreadcrumbsProps
) {
const { children, isLoading = false, onBack } = props;
const { data: userProfile } = useUserProfile();
const [target, setTarget] = useState<HTMLElement | null>(null);
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
useEffect(() => {
if (isCompactToolbar || typeof document === "undefined") {
setTarget(null);
return;
}
const animationFrame = window.requestAnimationFrame(() => {
setTarget(document.querySelector<HTMLElement>("[data-nodedc-expanded-breadcrumbs-slot]"));
});
return () => window.cancelAnimationFrame(animationFrame);
}, [isCompactToolbar]);
const content = (
<div className="flex min-w-0 items-center gap-2.5 overflow-visible">
<Breadcrumbs
onBack={onBack}
isLoading={isLoading}
className={isCompactToolbar ? "flex-grow-0" : "nodedc-expanded-breadcrumbs flex-grow-0"}
>
{children}
</Breadcrumbs>
</div>
);
if (!isCompactToolbar && target) return createPortal(content, target);
return content;
});

View File

@ -19,10 +19,11 @@ import { BreadcrumbNavigationSearchDropdown } from "@plane/ui";
type TProjectBreadcrumbProps = {
workspaceSlug: string;
projectId: string;
shouldTruncate?: boolean;
};
export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TProjectBreadcrumbProps) {
const { workspaceSlug, projectId } = props;
const { workspaceSlug, projectId, shouldTruncate = true } = props;
// router
const router = useAppRouter();
// store hooks
@ -69,7 +70,7 @@ export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TPro
title={currentProjectDetails?.name}
icon={renderIcon(currentProjectDetails)}
openOnLabelClick
shouldTruncate
shouldTruncate={shouldTruncate}
/>
);
});

View File

@ -21,7 +21,7 @@ import { useTranslation } from "@plane/i18n";
import { NewTabIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { EIssuesStoreType } from "@plane/types";
import { Breadcrumbs, Header } from "@plane/ui";
import { Header } from "@plane/ui";
import { CountChip } from "@/components/common/count-chip";
import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
// constants
@ -31,11 +31,12 @@ import { HeaderFilters } from "@/components/issues/filters";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useUserPermissions, useUserProfile } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { ExpandedToolbarBreadcrumbs } from "@/plane-web/components/breadcrumbs/expanded-toolbar-breadcrumbs";
import { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature";
export const IssuesHeader = observer(function IssuesHeader() {
@ -53,40 +54,46 @@ export const IssuesHeader = observer(function IssuesHeader() {
const { toggleCreateIssueModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { data: userProfile } = useUserProfile();
const { isMobile } = usePlatformOS();
const SPACE_APP_URL = (SPACE_BASE_URL.trim() === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH;
const publishedURL = `${SPACE_APP_URL}/issues/${currentProjectDetails?.anchor}`;
const issuesCount = getGroupIssueCount(undefined, undefined, false);
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
const canUserCreateIssue = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
const breadcrumbsContent = (
<>
<ExpandedToolbarBreadcrumbs onBack={() => router.back()} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={EProjectFeatureKey.WORK_ITEMS}
isLast
/>
</ExpandedToolbarBreadcrumbs>
{isCompactToolbar && issuesCount && issuesCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={t("issues_header.count_tooltip", { count: issuesCount })}
position="bottom"
>
<CountChip count={issuesCount} />
</Tooltip>
) : null}
</>
);
return (
<Header>
<Header.LeftItem className="nodedc-bottom-dock-left">
<div className="flex min-w-0 items-center gap-2.5 overflow-hidden">
<Breadcrumbs onBack={() => router.back()} isLoading={loader === "init-loader"} className="flex-grow-0">
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
featureKey={EProjectFeatureKey.WORK_ITEMS}
isLast
/>
</Breadcrumbs>
{issuesCount && issuesCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={t("issues_header.count_tooltip", { count: issuesCount })}
position="bottom"
>
<CountChip count={issuesCount} />
</Tooltip>
) : null}
</div>
{breadcrumbsContent}
{currentProjectDetails?.anchor ? (
<a
href={publishedURL}

View File

@ -10,13 +10,14 @@ import { useParams } from "next/navigation";
import { RefreshCcw } from "lucide-react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs, Header } from "@plane/ui";
import { Header } from "@plane/ui";
import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
import { FiltersToggle } from "@/components/rich-filters/filters-toggle";
import { useProject } from "@/hooks/store/use-project";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useUserPermissions } from "@/hooks/store/user";
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { ExpandedToolbarBreadcrumbs } from "@/plane-web/components/breadcrumbs/expanded-toolbar-breadcrumbs";
import { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature";
import { useExternalContoursFilter } from "./filters/provider";
import { ExternalContourCreateModalRoot } from "./create-modal";
@ -39,7 +40,7 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
<Header>
<Header.LeftItem className="nodedc-bottom-dock-left">
<div className="flex min-w-0 flex-grow items-center gap-4 overflow-hidden">
<Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
<ExpandedToolbarBreadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()}
@ -47,7 +48,7 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
featureKey="external_contours"
isLast
/>
</Breadcrumbs>
</ExpandedToolbarBreadcrumbs>
{(loader === "mutation-loading" || loader === "issue-loading") && (
<div className="flex items-center gap-1.5 text-tertiary">

View File

@ -11,7 +11,7 @@ import { RefreshCcw } from "lucide-react";
// ui
import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs, Header } from "@plane/ui";
import { Header } from "@plane/ui";
// components
import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
import { FiltersRoot } from "@/components/inbox/inbox-filter";
@ -22,6 +22,7 @@ import { useProjectInbox } from "@/hooks/store/use-project-inbox";
import { useUserPermissions } from "@/hooks/store/user";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { ExpandedToolbarBreadcrumbs } from "@/plane-web/components/breadcrumbs/expanded-toolbar-breadcrumbs";
import { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature";
export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
@ -46,7 +47,7 @@ export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
<Header>
<Header.LeftItem className="nodedc-bottom-dock-left">
<div className="flex min-w-0 flex-grow items-center gap-4 overflow-hidden">
<Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
<ExpandedToolbarBreadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()}
@ -54,7 +55,7 @@ export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
featureKey={EProjectFeatureKey.INTAKE}
isLast
/>
</Breadcrumbs>
</ExpandedToolbarBreadcrumbs>
{loader === "pagination-loading" && (
<div className="flex items-center gap-1.5 text-tertiary">

View File

@ -11,6 +11,7 @@ import { observer } from "mobx-react";
import { Row } from "@plane/ui";
// components
import { cn } from "@plane/utils";
import { useUserProfile } from "@/hooks/store/user";
import { ExtendedAppHeader } from "@/plane-web/components/common/extended-app-header";
export interface AppHeaderProps {
@ -24,6 +25,13 @@ export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
const { header, mobileHeader, className, rowClassName } = props;
const containerRef = useRef<HTMLDivElement>(null);
const [dockStyle, setDockStyle] = useState<CSSProperties | undefined>(undefined);
const { data: userProfile } = useUserProfile();
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
const effectiveDockStyle = isCompactToolbar
? dockStyle
: {
left: typeof dockStyle?.left === "number" ? dockStyle.left : 0,
};
useEffect(() => {
if (typeof window === "undefined") return;
@ -61,7 +69,15 @@ export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
}, []);
return (
<div ref={containerRef} className={cn("fixed right-0 bottom-0 z-[18]", className)} style={dockStyle}>
<div
ref={containerRef}
className={cn(
"fixed bottom-0 z-[18]",
isCompactToolbar ? "right-0 nodedc-app-header-compact" : "nodedc-app-header-expanded",
className
)}
style={effectiveDockStyle}
>
<Row
className={cn(
"nodedc-bottom-dock flex h-[var(--nodedc-bottom-dock-height)] w-full items-center gap-2",

View File

@ -4,22 +4,65 @@
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import type { ComponentProps } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { PlusIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
import { useUserProfile } from "@/hooks/store/user";
type TPrimaryActionButtonProps = ComponentProps<typeof Button>;
export const AppHeaderPrimaryActionButton = (props: TPrimaryActionButtonProps) => {
const { children, className, ...buttonProps } = props;
const { children, className, disabled, onClick, ...buttonProps } = props;
const { t } = useTranslation();
const { data: userProfile } = useUserProfile();
const [expandedToolbarTarget, setExpandedToolbarTarget] = useState<HTMLElement | null>(null);
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
useEffect(() => {
if (isCompactToolbar || typeof document === "undefined") {
setExpandedToolbarTarget(null);
return;
}
const animationFrame = window.requestAnimationFrame(() => {
setExpandedToolbarTarget(document.querySelector<HTMLElement>("[data-nodedc-expanded-primary-action-slot]"));
});
return () => window.cancelAnimationFrame(animationFrame);
}, [isCompactToolbar]);
if (!isCompactToolbar) {
if (!expandedToolbarTarget) return null;
return createPortal(
<Tooltip tooltipContent={typeof children === "string" ? children : t("app_header.add_task")} position="bottom">
<button
type="button"
className="nodedc-expanded-tool-button"
aria-label={typeof children === "string" ? children : t("app_header.add_task")}
disabled={disabled}
onClick={onClick}
data-ph-element={(buttonProps as { "data-ph-element"?: string })["data-ph-element"]}
>
<PlusIcon className="size-4" />
</button>
</Tooltip>,
expandedToolbarTarget
);
}
return (
<Button
variant="primary"
size="lg"
className={cn("nodedc-toolbar-primary nodedc-toolbar-primary-wide", className)}
disabled={disabled}
onClick={onClick}
{...buttonProps}
>
{children ?? t("app_header.add_task")}

View File

@ -24,7 +24,7 @@ import { WorkspaceService } from "@/services/workspace.service";
import { HomeCardShell } from "./home-card-shell";
import { HomeGanttPreview } from "./home-gantt-preview";
import { HomeRecentIssueDecks } from "./home-recent-issue-decks";
import { HomeActivityTrendCard, HomeOperationsCard, HomeRhythmRecentOverview } from "./home-project-insights";
import { HomeAnalyticsBottomRow, HomeAnalyticsRail, HomeIndividualAnalyticsPanel } from "./home-project-insights";
import { HomeProjectStack } from "./home-project-stack";
import { aggregateProjectAnalytics, type THomeProjectData } from "./home.utils";
import { StickiesWidget } from "../stickies/widget";
@ -184,7 +184,7 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
/>
) : null;
const sideWidgetCards = [
const bottomWidgetCards = [
isQuickLinksEnabled ? (
<HomeCardShell key="quick_links" className="overflow-hidden" contentClassName="p-5">
<DashboardQuickLinks workspaceSlug={workspaceSlugValue} />
@ -213,7 +213,7 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
workspaceName={currentWorkspace?.name}
/>
<div className="nodedc-home-dashboard-grid grid xl:grid-cols-[minmax(320px,360px)_minmax(0,1fr)] xl:items-stretch">
<div className="nodedc-home-dashboard-grid grid xl:grid-cols-[minmax(320px,360px)_minmax(0,1fr)_minmax(320px,360px)] xl:items-stretch">
<div className="flex min-w-0">
<HomeProjectStack
className="h-full"
@ -231,33 +231,18 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
analytics={selectedProjectAnalytics}
workspaceSlug={workspaceSlugValue}
/>
<HomeRhythmRecentOverview
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
recentActivitySlot={recentActivityCard}
locale={currentLocale}
/>
<HomeIndividualAnalyticsPanel project={selectedProject} locale={currentLocale} />
</div>
<HomeAnalyticsRail
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale}
/>
</div>
<div className="nodedc-home-lower-grid grid xl:grid-cols-[minmax(320px,360px)_minmax(0,1fr)]">
<HomeOperationsCard
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale}
/>
<HomeActivityTrendCard
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale}
/>
</div>
<HomeAnalyticsBottomRow recentActivitySlot={recentActivityCard} />
{isProjectLatestIssuesEnabled && (
<HomeRecentIssueDecks project={selectedProject} workspaceSlug={workspaceSlugValue} />
@ -268,11 +253,12 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
{hasSecondaryWidgets && (
<div
className={cn("grid gap-5", {
"md:grid-cols-2": sideWidgetCards.length > 1,
className={cn("nodedc-home-bottom-widgets grid gap-5", {
"md:grid-cols-2": bottomWidgetCards.length === 2,
"xl:grid-cols-3": bottomWidgetCards.length >= 3,
})}
>
{sideWidgetCards}
{bottomWidgetCards}
</div>
)}
</div>

View File

@ -4,9 +4,14 @@
* See the LICENSE file for details.
*/
import { type ReactNode, useId, useMemo } from "react";
import { type ReactNode, useEffect, useId, useMemo } from "react";
import { observer } from "mobx-react";
import { Activity, CheckCircle2, Layers3, UsersRound } from "lucide-react";
import type { TActivityEntityData, TProjectAnalyticsCount } from "@plane/types";
import { type TActivityEntityData, type TProjectAnalyticsCount } from "@plane/types";
import CreatedVsResolved from "@/components/analytics/work-items/created-vs-resolved";
import CustomizedInsights from "@/components/analytics/work-items/customized-insights";
import WorkItemsInsightTable from "@/components/analytics/work-items/workitems-insight-table";
import { useAnalytics } from "@/hooks/store/use-analytics";
import {
aggregateProjectAnalytics,
getActivityProjectId,
@ -294,14 +299,8 @@ export function HomeActivityTrendCard(props: HomeProjectInsightsProps) {
}
export function HomeRhythmCard(props: HomeProjectInsightsProps) {
const {
completedIssues,
metricCards,
openIssues,
project,
recentTouchpoints,
totalIssues,
} = useHomeProjectInsightData(props);
const { completedIssues, metricCards, openIssues, project, recentTouchpoints, totalIssues } =
useHomeProjectInsightData(props);
return (
<section className="nodedc-home-subpanel nodedc-home-rhythm-card space-y-4 p-5">
@ -368,9 +367,7 @@ export function HomeRhythmCard(props: HomeProjectInsightsProps) {
}
export function HomeOperationsCard(props: HomeProjectInsightsProps) {
const {
progressRows,
} = useHomeProjectInsightData(props);
const { progressRows } = useHomeProjectInsightData(props);
return (
<section className="nodedc-home-subpanel nodedc-home-operations-card space-y-4 p-5">
@ -437,6 +434,196 @@ export function HomeRhythmRecentOverview(props: HomeProjectInsightsProps) {
);
}
function HomeActivityMiniCard(props: HomeProjectInsightsProps) {
const { activitySeries, chart, chartId, project, recentTouchpoints } = useHomeProjectInsightData(props);
return (
<section className="nodedc-home-subpanel nodedc-home-activity-mini p-5">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold tracking-[0.2em] text-placeholder uppercase">
{project?.identifier ?? "Workspace"}
</div>
<div className="text-15 mt-2 font-semibold text-primary">Активность</div>
<div className="mt-1 text-12 text-secondary">Касания за последние 7 дней.</div>
</div>
<div className="nodedc-home-focus-chip">{recentTouchpoints}</div>
</div>
<div className="nodedc-home-activity-mini-chart">
<svg viewBox={`0 0 ${chart.width} ${chart.height}`} className="h-full w-full" preserveAspectRatio="none">
<defs>
<linearGradient id={`${chartId}-mini-fill`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="rgba(var(--nodedc-accent-rgb),0.3)" />
<stop offset="100%" stopColor="rgba(var(--nodedc-accent-rgb),0.02)" />
</linearGradient>
</defs>
{[0.25, 0.5, 0.75].map((position) => {
const y = chart.height - chart.paddingY - position * (chart.height - chart.paddingY * 2);
return <line key={position} x1={12} x2={chart.width - 12} y1={y} y2={y} stroke="rgba(255,255,255,0.07)" />;
})}
<path d={chart.areaPath} fill={`url(#${chartId}-mini-fill)`} />
<path
d={chart.linePath}
fill="none"
stroke="rgb(var(--nodedc-accent-rgb))"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="5"
/>
{activitySeries.map((activityPoint, index) => {
const point = chart.points[index];
if (!point) return null;
return (
<circle
key={activityPoint.key}
cx={point.x}
cy={point.y}
fill="rgb(var(--nodedc-accent-rgb))"
r={activityPoint.value > 0 ? 4 : 2.2}
/>
);
})}
</svg>
</div>
</section>
);
}
export const HomeAnalyticsRail = observer(function HomeAnalyticsRail(props: HomeProjectInsightsProps) {
const { project } = props;
const { completionRate, completedIssues, openIssues, recentTouchpoints, totalIssues } =
useHomeProjectInsightData(props);
const { updateIsEpic, updateIsPeekView, updateSelectedCycle, updateSelectedModule, updateSelectedProjects } =
useAnalytics();
useEffect(() => {
updateIsPeekView(true);
updateIsEpic(false);
updateSelectedCycle("");
updateSelectedModule("");
updateSelectedProjects(project?.id ? [project.id] : []);
return () => {
updateSelectedProjects([]);
updateSelectedCycle("");
updateSelectedModule("");
updateIsPeekView(false);
updateIsEpic(false);
};
}, [project?.id, updateIsEpic, updateIsPeekView, updateSelectedCycle, updateSelectedModule, updateSelectedProjects]);
return (
<aside className="nodedc-home-analytics-rail" aria-label="Аналитика проекта">
<section className="nodedc-home-subpanel nodedc-home-analytics-intro p-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold tracking-[0.22em] text-placeholder uppercase">
{project?.identifier ?? "Workspace"}
</div>
<div className="mt-2 text-16 font-semibold text-primary">Аналитика проекта</div>
</div>
<div className="nodedc-home-focus-chip">{completionRate}%</div>
</div>
<div className="nodedc-home-analytics-stat-grid mt-4">
<div className="nodedc-home-analytics-stat">
<span>Всего</span>
<strong>{totalIssues}</strong>
</div>
<div className="nodedc-home-analytics-stat">
<span>Открыто</span>
<strong>{openIssues}</strong>
</div>
<div className="nodedc-home-analytics-stat">
<span>Закрыто</span>
<strong>{completedIssues}</strong>
</div>
<div className="nodedc-home-analytics-stat">
<span>Касания</span>
<strong>{recentTouchpoints}</strong>
</div>
</div>
</section>
<HomeActivityMiniCard {...props} />
<HomeOperationsCard {...props} />
<CreatedVsResolved />
</aside>
);
});
export const HomeIndividualAnalyticsPanel = observer(function HomeIndividualAnalyticsPanel(
props: Pick<HomeProjectInsightsProps, "project" | "locale">
) {
const { updateIsEpic, updateIsPeekView, updateSelectedCycle, updateSelectedModule, updateSelectedProjects } =
useAnalytics();
useEffect(() => {
updateIsPeekView(true);
updateIsEpic(false);
updateSelectedCycle("");
updateSelectedModule("");
updateSelectedProjects(props.project?.id ? [props.project.id] : []);
return () => {
updateSelectedProjects([]);
updateSelectedCycle("");
updateSelectedModule("");
updateIsPeekView(false);
updateIsEpic(false);
};
}, [
props.project?.id,
updateIsEpic,
updateIsPeekView,
updateSelectedCycle,
updateSelectedModule,
updateSelectedProjects,
]);
return (
<section className="nodedc-home-individual-analytics" aria-label="Индивидуальные аналитические данные">
<CustomizedInsights peekView />
</section>
);
});
export const HomeAnalyticsBottomRow = observer(function HomeAnalyticsBottomRow({
recentActivitySlot,
}: {
recentActivitySlot?: ReactNode;
}) {
return (
<section className="nodedc-home-analytics-bottom-row" aria-label="Назначения и последние действия">
<div className="nodedc-home-assignee-analytics">
<WorkItemsInsightTable />
</div>
<div className="nodedc-home-subpanel nodedc-home-analytics-recents p-5">
{recentActivitySlot ? (
<div className="h-full min-h-[22rem]">{recentActivitySlot}</div>
) : (
<div className="flex h-full min-h-[22rem] flex-col justify-between">
<div>
<div className="flex items-center gap-3">
<div className="grid size-11 place-items-center rounded-full bg-black text-[rgb(var(--nodedc-card-active-rgb))]">
<Layers3 className="size-5" />
</div>
<div>
<div className="text-15 font-semibold text-primary">Последние действия</div>
<div className="text-12 text-secondary">Виджет recent activity отключен в настройках.</div>
</div>
</div>
</div>
</div>
)}
</div>
</section>
);
});
export function HomeOperationsOverview(props: HomeProjectInsightsProps) {
return (
<div className="grid gap-4">

View File

@ -4,21 +4,21 @@
* See the LICENSE file for details.
*/
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { observer } from "mobx-react";
import { ChartNoAxesColumn, SlidersHorizontal } from "lucide-react";
import { SlidersHorizontal } from "lucide-react";
// plane imports
import { EIssueFilterType, ISSUE_STORE_TO_FILTERS_MAP } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssueLayoutTypes, EIssuesStoreType } from "@plane/types";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useUserProfile } from "@/hooks/store/user";
// plane web imports
import type { TProject } from "@/plane-web/types";
// local imports
import { WorkItemsModal } from "../analytics/work-items/modal";
import { WorkItemFiltersToggle } from "../work-item-filters/filters-toggle";
import {
DisplayFiltersSelection,
@ -47,20 +47,38 @@ export const HeaderFilters = observer(function HeaderFilters(props: Props) {
currentProjectDetails,
projectId,
workspaceSlug,
canUserCreateIssue,
storeType = EIssuesStoreType.PROJECT,
} = props;
// i18n
const { t } = useTranslation();
// states
const [analyticsModal, setAnalyticsModal] = useState(false);
const [expandedToolbarTarget, setExpandedToolbarTarget] = useState<HTMLElement | null>(null);
// store hooks
const { data: userProfile } = useUserProfile();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(storeType);
// derived values
const activeLayout = issueFilters?.displayFilters?.layout;
const layoutDisplayFiltersOptions = ISSUE_STORE_TO_FILTERS_MAP[storeType]?.layoutOptions[activeLayout];
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
useEffect(() => {
if (isCompactToolbar || typeof document === "undefined") {
setExpandedToolbarTarget(null);
return;
}
let animationFrame = 0;
const resolveTarget = () => {
setExpandedToolbarTarget(document.querySelector<HTMLElement>("[data-nodedc-expanded-header-filters-slot]"));
};
animationFrame = window.requestAnimationFrame(resolveTarget);
return () => window.cancelAnimationFrame(animationFrame);
}, [isCompactToolbar]);
const handleLayoutChange = useCallback(
(layout: EIssueLayoutTypes) => {
@ -86,29 +104,59 @@ export const HeaderFilters = observer(function HeaderFilters(props: Props) {
[workspaceSlug, projectId, updateFilters]
);
const layoutSelection = (
<>
<div className="pointer-events-auto hidden @4xl:flex">
<LayoutSelection layouts={LAYOUTS} onChange={(layout) => handleLayoutChange(layout)} selectedLayout={activeLayout} />
</div>
<div className="pointer-events-auto flex @4xl:hidden">
<MobileLayoutSelection layouts={LAYOUTS} onChange={(layout) => handleLayoutChange(layout)} activeLayout={activeLayout} />
</div>
</>
);
const headerTools = (
<>
<WorkItemFiltersToggle entityType={storeType} entityId={projectId} />
<FiltersDropdown
menuButton={<SlidersHorizontal className="size-4" />}
menuButtonWrapperClassName="nodedc-expanded-tool-button"
miniIcon={<SlidersHorizontal className="size-3.5" />}
title={t("common.display")}
placement="bottom-end"
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
isEpic={storeType === EIssuesStoreType.EPIC}
/>
</FiltersDropdown>
</>
);
const expandedToolbarControls =
!isCompactToolbar && expandedToolbarTarget
? createPortal(
<div className="nodedc-expanded-header-filters">
{layoutSelection}
{headerTools}
</div>,
expandedToolbarTarget
)
: null;
return (
<>
<WorkItemsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
projectDetails={currentProjectDetails ?? undefined}
isEpic={storeType === EIssuesStoreType.EPIC}
/>
{expandedToolbarControls}
{!isCompactToolbar && expandedToolbarTarget ? null : (
<>
<div className="pointer-events-none absolute top-1/2 left-1/2 z-[1] flex -translate-x-1/2 -translate-y-1/2 items-center">
<div className="pointer-events-auto hidden @4xl:flex">
<LayoutSelection
layouts={LAYOUTS}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
</div>
<div className="pointer-events-auto flex @4xl:hidden">
<MobileLayoutSelection
layouts={LAYOUTS}
onChange={(layout) => handleLayoutChange(layout)}
activeLayout={activeLayout}
/>
</div>
{layoutSelection}
</div>
<div className="nodedc-top-toolbar-cluster flex items-center gap-2">
<WorkItemFiltersToggle entityType={storeType} entityId={projectId} />
@ -128,22 +176,9 @@ export const HeaderFilters = observer(function HeaderFilters(props: Props) {
isEpic={storeType === EIssuesStoreType.EPIC}
/>
</FiltersDropdown>
{canUserCreateIssue ? (
<Button
className="nodedc-toolbar-pill nodedc-toolbar-pill-wide hidden md:inline-flex"
onClick={() => setAnalyticsModal(true)}
variant="secondary"
size="lg"
>
<div className="hidden @4xl:flex">{t("common.analytics")}</div>
<div className="flex @4xl:hidden">
<ChartNoAxesColumn className="size-3.5" />
</div>
</Button>
) : (
<></>
)}
</div>
</>
)}
</>
);
});

View File

@ -296,10 +296,10 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
</div>
<IssueLayoutHOC layout={EIssueLayoutTypes.KANBAN}>
<div
className={`horizontal-scrollbar relative flex scrollbar-lg h-full w-full bg-surface-2 ${sub_group_by ? "vertical-scrollbar overflow-y-auto" : "overflow-x-auto overflow-y-hidden"}`}
className={`nodedc-kanban-scroll-container horizontal-scrollbar relative flex scrollbar-lg h-full w-full bg-transparent ${sub_group_by ? "vertical-scrollbar overflow-y-auto" : "overflow-x-auto overflow-y-hidden"}`}
ref={scrollableContainerRef}
>
<div className="relative h-full w-max min-w-full bg-surface-2">
<div className="relative h-full w-max min-w-full bg-transparent">
<div className="h-full w-max">
<KanBanView
issuesMap={issueMap}

View File

@ -173,7 +173,7 @@ export const KanBan = observer(function KanBan(props: IKanBan) {
} `}
>
{sub_group_by === null && (
<div className="sticky top-0 z-[2] w-full flex-shrink-0 bg-surface-2 py-1">
<div className="sticky top-0 z-[2] w-full flex-shrink-0 bg-transparent py-1">
<HeaderGroupByCard
sub_group_by={sub_group_by}
group_by={group_by}

View File

@ -340,7 +340,7 @@ export const KanbanGroup = observer(function KanbanGroup(props: IKanbanGroup) {
</div>
{shouldShowQuickAdd && (
<div className="nodedc-bottom-dock-sticky-offset sticky z-[2] w-full bg-surface-2 py-0.5">
<div className="nodedc-bottom-dock-sticky-offset sticky z-[2] w-full bg-transparent py-0.5">
<QuickAddIssueRoot
layout={EIssueLayoutTypes.KANBAN}
QuickAddButton={KanbanQuickAddIssueButton}

View File

@ -323,7 +323,7 @@ export const KanBanSwimLanes = observer(function KanBanSwimLanes(props: IKanBanS
return (
<div className="relative">
<Row className="sticky top-0 z-[4] h-[50px] bg-surface-2">
<Row className="sticky top-0 z-[4] h-[50px] bg-transparent">
<SubGroupSwimlaneHeader
getGroupIssueCount={getGroupIssueCount}
group_by={group_by}

View File

@ -24,12 +24,14 @@ import { useAppRouter } from "@/hooks/use-app-router";
import { useExpandableSearch } from "@/hooks/use-expandable-search";
type TTopNavPowerKProps = {
variant?: "top-navigation" | "sidebar";
variant?: "top-navigation" | "sidebar" | "expanded-toolbar";
};
export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
const { variant = "top-navigation" } = props;
const { t } = useTranslation();
const isWideSearch = variant === "top-navigation" || variant === "expanded-toolbar";
const isExpandedToolbar = variant === "expanded-toolbar";
// router
const router = useAppRouter();
const params = useParams();
@ -287,44 +289,99 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
return (
<div ref={containerRef} className="relative">
{variant === "top-navigation" ? (
<div
className={cn("relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out", {
"w-[554px]": isOpen,
})}
>
<div
className={cn(
"flex h-7 w-full items-center rounded-lg border border-subtle-1 bg-layer-2 p-2 transition-colors duration-200",
{
"bg-layer-1": isOpen,
}
)}
onClick={() => inputRef.current?.focus()}
role="button"
>
<SearchIcon className="mr-2 size-3.5 shrink-0 text-placeholder" />
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) openPanel();
{isWideSearch ? (
isExpandedToolbar ? (
<div className="nodedc-expanded-search-control" data-open={isOpen}>
<div
className="nodedc-expanded-search-line-panel"
onClick={() => {
openPanel();
requestAnimationFrame(() => inputRef.current?.focus());
}}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder={t("power_k.search_menu.quick_command_placeholder")}
className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none"
/>
{searchTerm && (
<button type="button" onClick={handleClear} className="ml-2 shrink-0">
<CloseIcon className="size-3.5 text-placeholder hover:text-primary" />
</button>
)}
role="button"
>
<div className="nodedc-expanded-search-input-wrap">
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) openPanel();
}}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder=""
tabIndex={isOpen ? 0 : -1}
className="nodedc-expanded-search-input placeholder-text-placeholder min-w-0 flex-1 bg-transparent outline-none"
/>
{searchTerm && (
<button type="button" onClick={handleClear} className="nodedc-expanded-search-clear">
<CloseIcon className="size-3.5" />
</button>
)}
</div>
</div>
<button
type="button"
className="nodedc-expanded-tool-button nodedc-expanded-search-trigger"
data-active={isOpen}
aria-label="Поиск"
aria-pressed={isOpen}
onClick={() => {
if (isOpen) {
closePanel();
return;
}
openPanel();
requestAnimationFrame(() => inputRef.current?.focus());
}}
>
<SearchIcon className="size-4 shrink-0" />
</button>
</div>
</div>
) : (
<div
className={cn("relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out", {
"w-[554px]": isOpen,
})}
>
<div
className={cn(
"flex h-7 w-full items-center rounded-lg border border-subtle-1 bg-layer-2 p-2 transition-colors duration-200",
{
"bg-layer-1": isOpen,
}
)}
onClick={() => inputRef.current?.focus()}
role="button"
>
<span className="mr-2">
<SearchIcon className="size-3.5 shrink-0 text-placeholder" />
</span>
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) openPanel();
}}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder={t("power_k.search_menu.quick_command_placeholder")}
className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none"
/>
{searchTerm && (
<button type="button" onClick={handleClear} className="ml-2 shrink-0">
<CloseIcon className="size-3.5 text-placeholder hover:text-primary" />
</button>
)}
</div>
</div>
)
) : (
<div className="relative z-30 h-8 w-8">
<button
@ -347,15 +404,28 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
</button>
</div>
)}
{variant === "top-navigation" && (
{isWideSearch && isExpandedToolbar && (
<div
className={cn(
"nodedc-expanded-search-results nodedc-glass-modal nodedc-glass-popup-surface absolute z-20 flex flex-col overflow-hidden px-0 pt-3 transition-all duration-300 ease-in-out",
{
"max-h-[80vh] opacity-100": isOpen,
"h-0 w-0 opacity-0": !isOpen,
}
)}
>
{isOpen && searchCommandContent}
</div>
)}
{isWideSearch && !isExpandedToolbar && (
<div
className={cn(
"absolute z-20 flex flex-col overflow-hidden px-0 transition-all duration-300 ease-in-out",
{
"max-h-[80vh] w-[574px] opacity-100": isOpen,
"max-h-[80vh] opacity-100": isOpen,
"w-[574px]": isOpen,
"h-0 w-0 opacity-0": !isOpen,
"-top-[6px] left-1/2 -translate-x-1/2 rounded-md border border-subtle bg-surface-1 shadow-lg pt-10":
true,
"-top-[6px] left-1/2 -translate-x-1/2 rounded-md border border-subtle bg-surface-1 shadow-lg pt-10": true,
}
)}
>

View File

@ -9,6 +9,7 @@ import { observer } from "mobx-react";
import { ThemeSwitcher } from "@/plane-web/components/preferences/theme-switcher";
// local imports
import { ProfileSettingsAccentColor } from "./accent-color";
import { ProfileSettingsToolbarLayout } from "./toolbar-layout";
export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSettingsDefaultPreferencesList() {
return (
@ -21,6 +22,7 @@ export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSe
}}
/>
<ProfileSettingsAccentColor />
<ProfileSettingsToolbarLayout />
</div>
);
});

View File

@ -0,0 +1,84 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { cn } from "@plane/utils";
// components
import { SettingsControlItem } from "@/components/settings/control-item";
// hooks
import { useUserProfile } from "@/hooks/store/user";
export const ProfileSettingsToolbarLayout = observer(function ProfileSettingsToolbarLayout() {
const { data: userProfile, updateUserTheme } = useUserProfile();
const [isSaving, setIsSaving] = useState(false);
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
const handleToggle = async () => {
const nextValue = !isCompactToolbar;
try {
setIsSaving(true);
await updateUserTheme({ nodedcCompactToolbar: nextValue });
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Сохранено",
message: nextValue ? "Компактная панель инструментов включена." : "Расширенная панель инструментов включена.",
});
} catch (_error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Ошибка",
message: "Не удалось обновить режим панели инструментов.",
});
} finally {
setIsSaving(false);
}
};
return (
<SettingsControlItem
title="Панель инструментов"
description="Локальная настройка пользователя. Компактный режим оставляет текущую короткую верхнюю панель, расширенный режим показывает основные разделы текстовыми кнопками."
control={
<button
type="button"
className={cn(
"flex w-full items-center justify-between gap-4 rounded-[1.25rem] bg-white/5 px-4 py-3 text-left transition sm:w-[28rem]",
"hover:bg-white/8 focus-visible:bg-white/8",
isSaving && "cursor-wait opacity-70"
)}
onClick={handleToggle}
disabled={isSaving}
>
<span className="flex min-w-0 items-center gap-3">
<span
className={cn(
"grid size-4 flex-shrink-0 place-items-center rounded-full transition",
isCompactToolbar ? "bg-[rgb(var(--nodedc-accent-rgb))]" : "bg-white/10"
)}
>
{isCompactToolbar && <span className="size-1.5 rounded-full bg-[rgb(var(--nodedc-on-accent-rgb))]" />}
</span>
<span className="min-w-0">
<span className="block text-13 font-semibold text-primary">Компактный режим</span>
<span className="mt-0.5 block text-12 leading-5 text-tertiary">
{isCompactToolbar
? "Все основные действия собраны в короткие иконки."
: "Основные разделы вынесены в расширенную верхнюю навигацию."}
</span>
</span>
</span>
<span className="hidden flex-shrink-0 rounded-full bg-white/6 px-3 py-1 text-11 font-semibold text-secondary sm:block">
{isCompactToolbar ? "Компактно" : "Расширенно"}
</span>
</button>
}
/>
);
});

View File

@ -866,7 +866,10 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
if (typeof document === "undefined") return;
const updateDockSlot = () => {
setDockSlot(document.querySelector("[data-nodedc-voice-task-dock-slot]"));
setDockSlot(
document.querySelector("[data-nodedc-voice-task-toolbar-slot]") ??
document.querySelector("[data-nodedc-voice-task-dock-slot]")
);
};
updateDockSlot();

View File

@ -24,13 +24,13 @@ export const WorkspaceLogo = observer(function WorkspaceLogo(props: Props) {
className={cn(
`relative grid h-6 w-6 flex-shrink-0 place-items-center uppercase ${
!props.logo && "rounded-md bg-accent-primary text-on-color"
} ${props.classNames ? props.classNames : ""}`
} ${props.logo && "rounded-md"} ${props.classNames ? props.classNames : ""}`
)}
>
{props.logo && props.logo !== "" ? (
<img
src={getFileURL(props.logo)}
className="absolute top-0 left-0 h-full w-full rounded-md object-cover"
className="absolute top-0 left-0 h-full w-full rounded-[inherit] object-cover"
alt={t("aria_labels.projects_sidebar.workspace_logo")}
/>
) : (

View File

@ -6,8 +6,9 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Settings, UserPlus } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import type { MouseEvent } from "react";
import { Archive, BarChart3, Layers3, Settings, UserPlus } from "lucide-react";
import { Menu } from "@headlessui/react";
// plane imports
import { EUserPermissions } from "@plane/constants";
@ -31,8 +32,18 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
const { workspace, activeWorkspace, handleItemClick, handleWorkspaceNavigation, handleClose } = props;
// router
const { workspaceSlug } = useParams();
const router = useRouter();
// hooks
const { t } = useTranslation();
const canOpenWorkspaceSettings = [EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role);
const canInviteMembers = [EUserPermissions.ADMIN].includes(workspace?.role);
const handleWorkspaceAction = (e: MouseEvent<HTMLButtonElement>, action: () => void) => {
e.preventDefault();
e.stopPropagation();
action();
handleClose();
};
return (
<Link
@ -92,36 +103,56 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
</div>
{workspace.id === activeWorkspace?.id && (
<>
<div className="mt-2 mb-1 grid grid-cols-2 gap-3">
{[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && (
<div className="mt-2 mb-1 flex flex-col gap-1.5">
{canOpenWorkspaceSettings && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openWorkspaceSettingsModal("general");
handleClose();
}}
className="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[1.25rem] border-0 bg-white/[0.05] px-5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
onClick={(e) => handleWorkspaceAction(e, () => openWorkspaceSettingsModal("general"))}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[1.1rem] border-0 bg-white/[0.05] px-3.5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<Settings className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">{t("settings")}</span>
</button>
)}
{[EUserPermissions.ADMIN].includes(workspace?.role) && (
<Link
href={`/${workspace.slug}/settings/members`}
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
className="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[1.25rem] border-0 bg-white/[0.05] px-5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
{canInviteMembers && (
<button
type="button"
onClick={(e) => handleWorkspaceAction(e, () => openWorkspaceSettingsModal("members"))}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[1.1rem] border-0 bg-white/[0.05] px-3.5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<UserPlus className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">
{t("project_settings.members.invite_members.title")}
</span>
</Link>
</button>
)}
{canOpenWorkspaceSettings && (
<button
type="button"
onClick={(e) => handleWorkspaceAction(e, () => router.push(`/${workspace.slug}/analytics/`))}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[1.1rem] border-0 bg-white/[0.05] px-3.5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<BarChart3 className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">Analytics</span>
</button>
)}
<button
type="button"
onClick={(e) => handleWorkspaceAction(e, () => router.push(`/${workspace.slug}/workspace-views/all-issues/`))}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[1.1rem] border-0 bg-white/[0.05] px-3.5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<Layers3 className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">Представления</span>
</button>
{canOpenWorkspaceSettings && (
<button
type="button"
onClick={(e) => handleWorkspaceAction(e, () => router.push(`/${workspace.slug}/projects/archives`))}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[1.1rem] border-0 bg-white/[0.05] px-3.5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<Archive className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">{t("archives")}</span>
</button>
)}
</div>
</>

View File

@ -22,7 +22,7 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useUser } from "@/hooks/store/user";
type TUserMenuRootProps = {
variant?: "default" | "sidebar-utility" | "toolbar";
variant?: "default" | "sidebar-utility" | "toolbar" | "expanded-toolbar";
};
export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootProps) {
@ -43,6 +43,7 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
const isSidebarUtilityVariant = variant === "sidebar-utility";
const isToolbarVariant = variant === "toolbar";
const isExpandedToolbarVariant = variant === "expanded-toolbar";
const handleSignOut = () => {
signOut().catch(() =>
@ -137,16 +138,18 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
className="flex items-center"
buttonAsChild
button={
isToolbarVariant ? (
isToolbarVariant || isExpandedToolbarVariant ? (
<button
type="button"
aria-label={t("profile")}
className="flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07]"
className={`flex items-center justify-center overflow-hidden rounded-full border-0 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07] ${
isExpandedToolbarVariant ? "nodedc-expanded-user-avatar-button size-12" : "size-8"
}`}
>
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={18}
size={isExpandedToolbarVariant ? 48 : 18}
shape="circle"
/>
</button>

View File

@ -31,7 +31,7 @@ import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item";
type WorkspaceMenuRootProps = {
variant: "sidebar" | "top-navigation" | "sidebar-panel" | "toolbar";
variant: "sidebar" | "top-navigation" | "sidebar-panel" | "toolbar" | "expanded-toolbar";
};
type WorkspaceMenuStateSyncProps = {
@ -46,7 +46,12 @@ function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) {
const { open, variant, sidebarPanelButtonRef, onSidebarDropdownToggle, onSidebarPanelPositionChange } = props;
const updateSidebarPanelMenuPosition = useCallback(() => {
if (!["sidebar-panel", "toolbar"].includes(variant) || !sidebarPanelButtonRef.current || typeof window === "undefined") return;
if (
!["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant) ||
!sidebarPanelButtonRef.current ||
typeof window === "undefined"
)
return;
const rect = sidebarPanelButtonRef.current.getBoundingClientRect();
const width = 480;
@ -64,7 +69,7 @@ function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) {
}, [onSidebarDropdownToggle, open]);
useLayoutEffect(() => {
if (!open || !["sidebar-panel", "toolbar"].includes(variant)) {
if (!open || !["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant)) {
onSidebarPanelPositionChange(null);
return;
}
@ -133,7 +138,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
"w-full justify-center text-center": variant === "sidebar",
"flex-grow justify-stretch text-left": variant === "top-navigation",
"w-full max-w-none justify-stretch text-left": variant === "sidebar-panel",
"w-fit max-w-none justify-center text-center": variant === "toolbar",
"w-fit max-w-none justify-center text-center": ["toolbar", "expanded-toolbar"].includes(variant),
})}
>
{({ open, close }: { open: boolean; close: () => void }) => {
@ -221,11 +226,12 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
/>
</Menu.Button>
)}
{variant === "toolbar" && (
{["toolbar", "expanded-toolbar"].includes(variant) && (
<Menu.Button
ref={sidebarPanelButtonRef}
className={cn(
"flex size-8 items-center justify-center rounded-full bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07] focus:outline-none",
"flex items-center justify-center rounded-full bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07] focus:outline-none",
variant === "expanded-toolbar" ? "size-12" : "size-8",
{
"bg-white/[0.08]": open,
}
@ -235,7 +241,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
<WorkspaceLogo
logo={activeWorkspace?.logo_url}
name={activeWorkspace?.name}
classNames="size-8 rounded-[0.9rem]"
classNames={variant === "expanded-toolbar" ? "size-12 rounded-full" : "size-8 rounded-[0.9rem]"}
/>
</Menu.Button>
)}
@ -247,15 +253,15 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
"z-21 mt-1 flex min-w-[30rem] origin-top-left flex-col divide-y overflow-hidden outline-none",
{
"fixed divide-subtle rounded-md border-[0.5px] border-strong bg-surface-1 shadow-raised-200":
!["sidebar-panel", "toolbar"].includes(variant),
!["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant),
"top-11 left-14": variant === "sidebar",
"top-10 left-4": variant === "top-navigation",
"nodedc-glass-modal nodedc-glass-popup-surface rounded-[1.5rem] divide-white/10":
["sidebar-panel", "toolbar"].includes(variant),
["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant),
}
)}
style={
["sidebar-panel", "toolbar"].includes(variant) && sidebarPanelMenuPosition
["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant) && sidebarPanelMenuPosition
? {
position: "fixed",
left: `${sidebarPanelMenuPosition.left}px`,
@ -270,8 +276,8 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
className={cn(
"sticky top-0 z-21 h-full w-full flex-shrink-0 truncate px-4 pt-3 pb-1 text-left text-13 font-medium text-placeholder",
{
"rounded-md bg-surface-1": !["sidebar-panel", "toolbar"].includes(variant),
"bg-transparent": ["sidebar-panel", "toolbar"].includes(variant),
"rounded-md bg-surface-1": !["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant),
"bg-transparent": ["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant),
}
)}
>
@ -343,7 +349,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
</Menu.Items>
);
if (["sidebar-panel", "toolbar"].includes(variant)) {
if (["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant)) {
if (!open || !sidebarPanelMenuPosition || typeof document === "undefined") return null;
return createPortal(menuItems, document.body);
}

View File

@ -46,6 +46,7 @@ export class ProfileStore implements IUserProfileStore {
background: undefined,
darkPalette: false,
nodedcAccent: undefined,
nodedcCompactToolbar: undefined,
},
onboarding_step: {
workspace_join: false,

View File

@ -0,0 +1 @@
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55"><defs><style>.cls-1{fill:#e2e1e1;}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:0.75px;}</style></defs><path class="cls-1" d="M52.8,23.61,46.92,33.76,41.05,23.61H52.8m18-10.39H23.06L46.92,54.55Z"/><polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/><path class="cls-2" d="M116.35,18.49V1h1.27l10.34,15V1h1.33V18.49H128l-10.34-15v15Z"/><path class="cls-2" d="M140.43,18.64c-4.79,0-8.16-3.72-8.16-8.89S135.64.86,140.43.86s8.17,3.72,8.17,8.89S145.25,18.64,140.43,18.64Zm0-1.25c4,0,6.79-3.17,6.79-7.64s-2.77-7.64-6.79-7.64-6.77,3.17-6.77,7.64S136.44,17.39,140.43,17.39Z"/><path class="cls-2" d="M151.6,18.49V1h5.1c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74ZM153,17.24h3.75c4.77,0,7.42-2.92,7.42-7.49s-2.65-7.49-7.42-7.49H153Z"/><path class="cls-2" d="M168.49,1h10.77V2.26h-9.42V8.93h7.89v1.25h-7.89v7.06h9.74v1.25H168.49Z"/><path class="cls-2" d="M188.88,18.49V1H194c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74Zm1.35-1.25H194c4.77,0,7.41-2.92,7.41-7.49S198.75,2.26,194,2.26h-3.75Z"/><path class="cls-2" d="M205.15,9.75c0-5.24,3.19-8.89,8.11-8.89a6.8,6.8,0,0,1,7.1,5.52h-1.43a5.54,5.54,0,0,0-5.74-4.27c-4.05,0-6.64,3.17-6.64,7.64s2.54,7.64,6.59,7.64a5.46,5.46,0,0,0,5.74-4.29h1.43c-.75,3.52-3.4,5.54-7.15,5.54C208.27,18.64,205.15,15.05,205.15,9.75Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -450,6 +450,29 @@
overflow: hidden;
}
.nodedc-app-header-expanded {
right: auto !important;
bottom: 0;
height: 0;
pointer-events: none;
}
.nodedc-app-header-expanded .nodedc-bottom-dock {
display: none !important;
}
.nodedc-app-header-expanded .nodedc-bottom-dock > [class~="w-full"] {
width: auto !important;
}
.nodedc-app-header-expanded .nodedc-bottom-dock-left {
max-width: min(31rem, 48vw) !important;
}
.nodedc-app-header-expanded .nodedc-bottom-dock-voice-slot {
display: none !important;
}
.nodedc-glass-modal [data-slot="button"],
.nodedc-glass-modal [data-slot="icon-button"] {
border: none !important;
@ -754,6 +777,516 @@
color: rgb(var(--nodedc-on-accent-rgb));
}
.nodedc-expanded-toolbar {
display: flex;
min-height: 4.25rem;
width: 100%;
flex-direction: column;
gap: 0;
}
.nodedc-expanded-toolbar-top {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
min-height: 3rem;
width: 100%;
align-items: center;
gap: 1rem;
}
.nodedc-expanded-toolbar-left,
.nodedc-expanded-toolbar-center,
.nodedc-expanded-toolbar-right {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.nodedc-expanded-toolbar-left {
justify-content: flex-start;
}
.nodedc-expanded-toolbar-center {
justify-content: center;
}
.nodedc-expanded-toolbar-right {
justify-content: flex-end;
}
.nodedc-expanded-brand-logo {
display: block;
width: 7.25rem;
height: auto;
max-height: 2.2rem;
object-fit: contain;
}
.nodedc-expanded-user-group {
display: inline-flex;
height: 3.45rem;
min-height: 3.45rem;
align-items: center;
gap: 0.22rem;
border-radius: 999px;
background: rgba(64, 64, 64, 0.48);
padding: 0.32rem;
}
.nodedc-expanded-user-group .nodedc-expanded-nav-button {
min-height: 2.78rem;
padding-inline: 1.2rem;
}
.nodedc-expanded-user-group .nodedc-expanded-nav-button:not([data-active="true"]) {
color: rgba(255, 255, 255, 0.68);
}
.nodedc-expanded-nav-group {
display: inline-flex;
min-height: 3.45rem;
align-items: center;
gap: 0.18rem;
border: 0 !important;
border-radius: 999px;
background: rgba(64, 64, 64, 0.48);
padding: 0.32rem;
box-shadow: none !important;
}
.nodedc-expanded-toolbar-tools-row {
position: relative;
display: flex;
width: 100%;
min-height: 0;
align-items: center;
justify-content: flex-start;
gap: 0.45rem;
margin: 0;
}
.nodedc-expanded-main-tool-cluster,
.nodedc-expanded-action-tool-cluster {
position: fixed;
z-index: 80;
display: inline-flex;
min-height: 3rem;
align-items: center;
gap: 0.45rem;
min-width: 0;
pointer-events: auto;
}
.nodedc-expanded-main-tool-cluster {
left: 50%;
bottom: 1.1rem;
transform: translateX(-50%);
}
.nodedc-expanded-action-tool-cluster {
right: 2.75rem;
bottom: 1.1rem;
}
.nodedc-expanded-main-tool-cluster:empty,
.nodedc-expanded-action-tool-cluster:empty {
display: none;
}
.nodedc-expanded-nav-button {
display: inline-flex !important;
align-items: center;
justify-content: center;
gap: 0;
min-height: 2.78rem;
border: 0 !important;
outline: none !important;
box-shadow: none !important;
border-radius: 999px !important;
background: transparent !important;
color: rgba(255, 255, 255, 0.68);
padding: 0.2rem 1.22rem;
font-size: 0.74rem;
font-weight: 700;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
transition:
background-color 160ms ease,
color 160ms ease,
opacity 160ms ease;
}
.nodedc-expanded-nav-button:hover {
background: rgba(255, 255, 255, 0.07) !important;
color: rgba(255, 255, 255, 0.96);
}
.nodedc-expanded-nav-button[data-active="true"] {
background: rgba(255, 255, 255, 0.92) !important;
color: rgba(8, 8, 10, 0.96);
}
.nodedc-expanded-nav-icon {
display: grid;
width: 2.1rem;
height: 2.1rem;
flex-shrink: 0;
place-items: center;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.8);
}
.nodedc-expanded-nav-button[data-active="true"] .nodedc-expanded-nav-icon {
background: rgb(var(--nodedc-accent-rgb));
color: rgb(var(--nodedc-on-accent-rgb));
}
.nodedc-expanded-tool-button,
.nodedc-expanded-tool-slot .nodedc-bottom-dock-voice-button {
display: grid !important;
height: 3rem !important;
width: 3rem !important;
min-width: 3rem;
place-items: center;
border: 0 !important;
border-radius: 999px !important;
background: rgba(7, 7, 10, 0.94) !important;
color: rgba(255, 255, 255, 0.68) !important;
outline: none !important;
box-shadow: none !important;
transition:
background-color 180ms ease,
color 180ms ease,
transform 180ms ease;
}
.nodedc-expanded-tool-button:hover,
.nodedc-expanded-tool-slot .nodedc-bottom-dock-voice-button:hover {
color: rgba(255, 255, 255, 0.92) !important;
}
.nodedc-expanded-tool-button[data-active="true"] {
background: rgba(255, 255, 255, 0.94) !important;
color: rgba(8, 8, 10, 0.94) !important;
}
.nodedc-expanded-tool-slot {
display: grid;
min-height: 3rem;
min-width: 3rem;
place-items: center;
}
.nodedc-expanded-header-filters-slot,
.nodedc-expanded-primary-action-slot {
display: contents;
}
.nodedc-expanded-header-filters-slot:empty,
.nodedc-expanded-primary-action-slot:empty,
.nodedc-expanded-breadcrumbs-slot:empty {
display: none;
}
.nodedc-expanded-breadcrumbs-slot {
display: flex;
flex: 0 1 auto;
min-width: 0;
justify-content: flex-start;
position: fixed;
z-index: 80;
left: 1.85rem;
bottom: 1.1rem;
pointer-events: auto;
}
main:has(.nodedc-expanded-toolbar) .nodedc-kanban-scroll-container {
height: calc(100% - 4.5rem) !important;
max-height: calc(100% - 4.5rem);
scrollbar-color: transparent transparent;
}
main:has(.nodedc-expanded-toolbar) .nodedc-kanban-scroll-container::-webkit-scrollbar,
main:has(.nodedc-expanded-toolbar) .nodedc-kanban-scroll-container::-webkit-scrollbar-track,
main:has(.nodedc-expanded-toolbar) .nodedc-kanban-scroll-container::-webkit-scrollbar-thumb {
background: transparent !important;
border-color: transparent !important;
}
.nodedc-expanded-breadcrumbs {
display: inline-flex !important;
flex: 0 1 auto !important;
max-width: min(52rem, 48vw);
height: 3rem;
align-items: center;
gap: 0.45rem !important;
overflow: visible !important;
border-radius: 0;
background: transparent;
padding: 0;
}
.nodedc-expanded-breadcrumbs > div {
height: 3rem !important;
min-width: max-content;
flex-shrink: 0;
}
.nodedc-expanded-breadcrumbs [class~="rounded-sm"] {
border-radius: 999px !important;
}
.nodedc-expanded-breadcrumbs [class~="outline-none"],
.nodedc-expanded-breadcrumbs > div > .group {
min-height: 3rem;
border: 0 !important;
border-radius: 999px !important;
background: rgba(64, 64, 64, 0.52) !important;
outline: none !important;
box-shadow: none !important;
}
.nodedc-expanded-breadcrumbs > div:first-child [class~="outline-none"],
.nodedc-expanded-breadcrumbs > div:first-child > .group {
background: rgba(7, 7, 10, 0.96) !important;
color: rgba(255, 255, 255, 0.86) !important;
}
.nodedc-expanded-breadcrumbs > div:first-child [class*="text-"],
.nodedc-expanded-breadcrumbs > div:first-child svg {
color: rgba(255, 255, 255, 0.78) !important;
}
.nodedc-expanded-breadcrumbs [class~="outline-none"] > div,
.nodedc-expanded-breadcrumbs [class~="outline-none"] [class~="bg-layer-1"],
.nodedc-expanded-breadcrumbs [class~="outline-none"] [class~="hover:bg-layer-1"],
.nodedc-expanded-breadcrumbs [class~="outline-none"] [class~="hover:bg-surface-2"],
.nodedc-expanded-breadcrumbs [class~="outline-none"] [class~="rounded-r-sm"],
.nodedc-expanded-breadcrumbs [class~="outline-none"] [class~="rounded-r-none"] {
background: transparent !important;
}
.nodedc-expanded-breadcrumbs [class~="outline-none"] [class~="bg-surface-1"] {
display: none !important;
}
.nodedc-expanded-breadcrumbs [class~="truncate"] {
max-width: 18rem !important;
}
.nodedc-expanded-breadcrumbs [class~="text-13"] {
font-size: 0.75rem !important;
font-weight: 700 !important;
letter-spacing: 0 !important;
}
.nodedc-expanded-breadcrumbs [class~="px-1.5"] {
padding-left: 0.95rem !important;
padding-right: 0.95rem !important;
}
.nodedc-expanded-breadcrumbs [class~="rounded-r-none"] {
border-top-right-radius: 999px !important;
border-bottom-right-radius: 999px !important;
}
.nodedc-expanded-breadcrumbs [class~="rounded-r-sm"] {
margin-left: -0.65rem;
border-top-left-radius: 999px !important;
border-bottom-left-radius: 999px !important;
}
.nodedc-expanded-breadcrumbs [class~="rounded-r-sm"] span[class*="bg-surface"],
.nodedc-expanded-breadcrumbs [class~="rounded-r-sm"] span[class*="bg-layer"] {
display: none !important;
}
.nodedc-expanded-breadcrumbs [class~="text-tertiary"],
.nodedc-expanded-breadcrumbs [class~="text-placeholder"] {
color: rgba(255, 255, 255, 0.66) !important;
}
.nodedc-expanded-breadcrumbs [class~="hover:bg-surface-2"]:hover,
.nodedc-expanded-breadcrumbs [class~="hover:bg-layer-1"]:hover {
background: transparent !important;
}
.nodedc-expanded-breadcrumbs > div:last-child [class~="outline-none"],
.nodedc-expanded-breadcrumbs > div:last-child > .group {
background: rgba(255, 255, 255, 0.94) !important;
color: rgba(8, 8, 10, 0.96) !important;
}
.nodedc-expanded-breadcrumbs > div:last-child > div [class~="text-tertiary"],
.nodedc-expanded-breadcrumbs > div:last-child > div [class~="text-placeholder"],
.nodedc-expanded-breadcrumbs > div:last-child > div [class~="text-primary"],
.nodedc-expanded-breadcrumbs > div:last-child [class*="text-"] {
color: rgba(8, 8, 10, 0.96) !important;
}
.nodedc-expanded-breadcrumbs > div:last-child svg {
color: rgba(8, 8, 10, 0.86) !important;
}
.nodedc-expanded-header-filters {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.nodedc-expanded-header-filters .nodedc-toolbar-group {
min-height: 3rem;
padding: 0.25rem;
}
.nodedc-expanded-header-filters .nodedc-toolbar-filter-toggle {
display: grid !important;
height: 3rem !important;
width: 3rem !important;
min-width: 3rem !important;
place-items: center;
border: 0 !important;
border-radius: 999px !important;
background: rgba(7, 7, 10, 0.94) !important;
color: rgba(255, 255, 255, 0.68) !important;
outline: none !important;
box-shadow: none !important;
}
.nodedc-expanded-header-filters .nodedc-toolbar-filter-toggle[data-active="true"] {
background: rgba(255, 255, 255, 0.94) !important;
color: rgba(8, 8, 10, 0.94) !important;
}
.nodedc-expanded-search-control {
position: relative;
z-index: 30;
height: 3rem;
width: 3rem;
flex-shrink: 0;
overflow: visible;
}
.nodedc-expanded-search-line-panel {
position: absolute;
top: 0;
right: 0;
z-index: 0;
height: 3rem;
width: min(36rem, 42vw);
overflow: hidden;
border-radius: 999px;
opacity: 0;
pointer-events: none;
transition: opacity 180ms ease;
}
.nodedc-expanded-search-trigger {
position: relative;
z-index: 2;
}
.nodedc-expanded-search-line-panel::before {
content: "";
position: absolute;
right: 3.3rem;
bottom: 0.95rem;
left: 0;
height: 1px;
transform: scaleX(0);
transform-origin: right center;
background: rgba(255, 255, 255, 0.34);
transition: transform 230ms ease;
}
.nodedc-expanded-search-control[data-open="true"] .nodedc-expanded-search-line-panel {
opacity: 1;
pointer-events: auto;
}
.nodedc-expanded-search-control[data-open="true"] .nodedc-expanded-search-line-panel::before {
transform: scaleX(1);
}
.nodedc-expanded-search-input-wrap {
display: flex;
height: 3rem;
width: 100%;
align-items: center;
padding: 0 3.3rem 0 0;
opacity: 0;
transition: opacity 170ms ease 100ms;
}
.nodedc-expanded-search-control[data-open="true"] .nodedc-expanded-search-input-wrap {
opacity: 1;
}
.nodedc-expanded-search-input {
height: 100%;
width: 100%;
text-transform: uppercase;
font-size: 1.08rem;
font-weight: 700;
letter-spacing: 0;
}
.nodedc-expanded-search-clear {
display: grid;
height: 2rem;
width: 2rem;
flex-shrink: 0;
place-items: center;
color: rgba(255, 255, 255, 0.5);
}
.nodedc-expanded-search-results {
top: calc(100% + 0.85rem);
right: 0;
width: min(36rem, 42vw);
border-radius: 1.45rem;
}
.nodedc-expanded-main-tool-cluster .nodedc-expanded-search-results {
top: auto;
bottom: calc(100% + 0.85rem);
}
.nodedc-expanded-notification-button {
height: 2.78rem;
width: 2.78rem;
background: transparent !important;
color: rgba(255, 255, 255, 0.68) !important;
}
.nodedc-expanded-notification-button .nodedc-toolbar-icon-active-dot {
height: auto;
width: auto;
color: rgba(255, 255, 255, 0.68) !important;
}
.nodedc-expanded-notification-button:hover,
.nodedc-expanded-notification-button:hover .nodedc-toolbar-icon-active-dot {
color: rgba(255, 255, 255, 0.94) !important;
}
.nodedc-expanded-notification-button .nodedc-toolbar-notification-dot {
top: 0.58rem !important;
right: 0.58rem !important;
background: rgb(var(--nodedc-accent-rgb)) !important;
}
.nodedc-expanded-user-avatar-button {
background: transparent !important;
}
.nodedc-expanded-user-avatar-button:hover {
background: transparent !important;
}
.nodedc-toolbar-pill {
position: relative;
display: inline-flex !important;
@ -1289,8 +1822,7 @@
border: 0 !important;
outline: none !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.075) 0%, rgba(255, 255, 255, 0.026) 100%),
rgba(8, 9, 12, 0.78) !important;
linear-gradient(180deg, rgba(255, 255, 255, 0.075) 0%, rgba(255, 255, 255, 0.026) 100%), rgba(8, 9, 12, 0.78) !important;
-webkit-backdrop-filter: blur(28px);
backdrop-filter: blur(28px);
}
@ -1720,8 +2252,7 @@
linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%),
rgb(var(--nodedc-card-active-rgb)) !important;
color: rgb(var(--nodedc-on-card-active-rgb)) !important;
box-shadow:
0 12px 32px rgba(0, 0, 0, 0.16) !important;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16) !important;
}
.nodedc-external-card[data-active="true"] .text-primary {
@ -1940,6 +2471,8 @@
.nodedc-home-route-surface {
min-height: 100vh;
padding-right: 1.25rem !important;
padding-left: 1.25rem !important;
background: var(--background-color-surface-1) !important;
}
@ -1948,7 +2481,8 @@
}
.nodedc-home-page-shell {
max-width: min(1840px, calc(100vw - 5rem));
max-width: none;
padding: 0 !important;
}
.nodedc-home-dashboard-shell {
@ -1972,11 +2506,267 @@
}
.nodedc-home-main-column {
--nodedc-home-gantt-height: 35.75rem;
--nodedc-home-individual-chart-height: 11.25rem;
display: flex;
min-width: 0;
min-height: 0;
height: 100%;
flex-direction: column;
gap: 0.75rem;
}
.nodedc-home-analytics-rail {
display: flex;
min-width: 0;
height: auto;
flex-direction: column;
gap: 0.75rem;
overflow: hidden;
padding-right: 0;
scrollbar-color: rgba(var(--nodedc-card-active-rgb), 0.58) rgba(255, 255, 255, 0.035);
scrollbar-width: thin;
}
.nodedc-home-analytics-rail::-webkit-scrollbar {
width: 0.45rem;
}
.nodedc-home-analytics-rail::-webkit-scrollbar-track {
border-radius: 999px;
background: rgba(255, 255, 255, 0.035);
}
.nodedc-home-analytics-rail::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(var(--nodedc-card-active-rgb), 0.58);
}
.nodedc-home-analytics-stat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.nodedc-home-analytics-stat {
display: flex;
min-height: 4.2rem;
flex-direction: column;
justify-content: space-between;
border-radius: 1.15rem;
background: rgba(0, 0, 0, 0.18);
padding: 0.8rem;
}
.nodedc-home-analytics-stat span {
color: var(--text-color-secondary);
font-size: 0.68rem;
font-weight: 650;
}
.nodedc-home-analytics-stat strong {
color: var(--text-color-primary);
font-size: 1.35rem;
line-height: 1;
}
.nodedc-home-analytics-rail .nodedc-external-section,
.nodedc-home-analytics-table-wrap,
.nodedc-home-individual-analytics,
.nodedc-home-assignee-analytics {
border: 0 !important;
border-radius: 1.5rem !important;
background: rgba(10, 10, 12, 0.7) !important;
padding: 1rem !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.014) !important;
-webkit-backdrop-filter: blur(24px);
backdrop-filter: blur(24px);
}
.nodedc-home-analytics-rail .nodedc-external-section,
.nodedc-home-assignee-analytics,
.nodedc-home-analytics-recents {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.022) 0%, rgba(255, 255, 255, 0.006) 100%), rgba(10, 10, 12, 0.7) !important;
}
.nodedc-home-analytics-rail > .nodedc-external-section,
.nodedc-home-analytics-rail > .nodedc-home-subpanel {
flex: 0 0 auto;
min-height: 15.75rem;
}
.nodedc-home-analytics-rail .nodedc-home-subpanel {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.022) 0%, rgba(255, 255, 255, 0.006) 100%), rgba(10, 10, 12, 0.7) !important;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.014) !important;
}
.nodedc-home-individual-analytics {
flex: 0 0 auto;
background: rgba(10, 10, 12, 0.72) !important;
}
.nodedc-home-individual-analytics > .nodedc-external-section,
.nodedc-home-assignee-analytics > .nodedc-external-section {
height: 100%;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
padding: 0 !important;
box-shadow: none !important;
}
.nodedc-home-individual-analytics .nodedc-external-section > .mb-5,
.nodedc-home-analytics-rail .nodedc-external-section > .mb-5 {
margin-bottom: 0.85rem !important;
}
.nodedc-home-individual-analytics .nodedc-external-section h1,
.nodedc-home-analytics-rail .nodedc-external-section h1 {
font-size: 0.92rem !important;
line-height: 1.2 !important;
}
.nodedc-home-analytics-rail .nodedc-analytics-summary-card {
grid-template-columns: repeat(2, minmax(0, 1fr));
min-height: 0;
overflow: hidden;
border-radius: 1.25rem !important;
}
.nodedc-home-analytics-rail .nodedc-analytics-summary-item {
min-height: 4.35rem !important;
padding: 0.8rem !important;
}
.nodedc-home-analytics-rail .nodedc-analytics-summary-item::before {
display: none;
}
.nodedc-home-individual-analytics .h-\[350px\],
.nodedc-home-analytics-rail .h-\[350px\] {
height: 13.5rem !important;
}
.nodedc-home-individual-analytics .h-\[350px\] {
height: calc(var(--nodedc-home-individual-chart-height) + 0.75rem) !important;
}
.nodedc-home-individual-analytics .nodedc-analytics-chart-inner,
.nodedc-home-analytics-rail .nodedc-analytics-chart-inner {
min-width: 24rem;
height: 14rem !important;
}
.nodedc-home-priority-cell .nodedc-analytics-chart-inner {
width: 100% !important;
min-width: 18rem;
height: 12rem !important;
}
.nodedc-home-individual-analytics .nodedc-analytics-chart-stack,
.nodedc-home-analytics-rail .nodedc-analytics-chart-stack {
gap: 0.9rem !important;
}
.nodedc-home-individual-analytics .nodedc-analytics-table-toolbar,
.nodedc-home-analytics-rail .nodedc-analytics-table-toolbar {
align-items: flex-start;
gap: 0.65rem;
}
.nodedc-home-individual-analytics .nodedc-analytics-table-surface,
.nodedc-home-analytics-rail .nodedc-analytics-table-surface {
max-height: 13rem;
overflow: auto;
}
.nodedc-home-individual-analytics .nodedc-analytics-chart-stack {
display: grid !important;
grid-template-columns: minmax(0, 1.1fr) minmax(19rem, 0.9fr);
align-items: stretch;
}
.nodedc-home-individual-analytics .nodedc-analytics-chart-viewport,
.nodedc-home-individual-analytics .nodedc-analytics-table-surface {
height: var(--nodedc-home-individual-chart-height);
min-height: var(--nodedc-home-individual-chart-height);
border-radius: 1.25rem !important;
background: rgba(0, 0, 0, 0.18) !important;
}
.nodedc-home-individual-analytics .nodedc-analytics-chart-inner {
width: 100% !important;
min-width: 22rem;
height: calc(var(--nodedc-home-individual-chart-height) - 0.75rem) !important;
}
.nodedc-home-individual-analytics .nodedc-analytics-table-surface table {
width: 100%;
}
.nodedc-home-analytics-bottom-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: stretch;
gap: 0.75rem;
}
.nodedc-home-assignee-analytics,
.nodedc-home-analytics-recents {
min-width: 0;
min-height: 22rem;
overflow: hidden;
}
.nodedc-home-assignee-analytics .nodedc-analytics-table-surface {
max-height: 17.5rem;
overflow: auto;
}
.nodedc-home-analytics-recents {
max-height: 24rem;
overflow: hidden;
}
.nodedc-home-analytics-intro {
min-height: 10rem;
}
.nodedc-home-activity-mini {
flex: 0 0 auto;
min-height: 15rem;
overflow: hidden;
}
.nodedc-home-activity-mini-chart {
position: relative;
height: 10.5rem;
overflow: hidden;
border-radius: 1.25rem;
background: rgba(0, 0, 0, 0.16);
}
.nodedc-home-bottom-widgets {
align-items: stretch;
}
.nodedc-home-bottom-widgets .nodedc-home-card {
min-height: 16rem;
}
.nodedc-home-bottom-widgets .nodedc-home-card > div {
height: 100%;
}
@media (max-width: 1279px) {
.nodedc-home-individual-analytics .nodedc-analytics-chart-stack,
.nodedc-home-analytics-bottom-row {
grid-template-columns: 1fr;
}
}
.nodedc-home-hero {
--nodedc-home-column-gap: 0.75rem;
--nodedc-home-title-width: 13.25rem;
@ -2177,9 +2967,12 @@
.nodedc-home-gantt-card {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
isolation: isolate;
min-height: 30rem;
min-height: 0;
flex: 1 1 auto;
border-radius: 2rem !important;
background: #050506 !important;
box-shadow: none !important;
@ -2441,7 +3234,10 @@
.nodedc-home-gantt-surface {
position: relative;
min-height: 23.5rem;
flex: 1 1 var(--nodedc-home-gantt-height);
height: auto;
min-height: var(--nodedc-home-gantt-height);
max-height: none;
margin: 0 1.25rem 1.25rem;
overflow: hidden;
border-radius: 1.75rem;
@ -2449,15 +3245,18 @@
}
.nodedc-home-gantt-scroll {
min-height: 23.5rem;
overflow-x: auto;
overflow-y: hidden;
height: 100%;
min-height: 100%;
max-height: none;
overflow: auto;
overscroll-behavior: contain;
scrollbar-color: rgba(var(--nodedc-card-active-rgb), 0.65) rgba(255, 255, 255, 0.04);
scrollbar-width: thin;
}
.nodedc-home-gantt-scroll::-webkit-scrollbar {
height: 0.55rem;
width: 0.45rem;
height: 0.45rem;
}
.nodedc-home-gantt-scroll::-webkit-scrollbar-track {
@ -2472,7 +3271,7 @@
.nodedc-home-gantt-canvas {
position: relative;
min-height: 23.5rem;
min-height: 100%;
padding: 1rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px) 0 0 / 100% 4.2rem,
@ -3579,8 +4378,7 @@
.nodedc-home-operations-card {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.022) 0%, rgba(255, 255, 255, 0.006) 100%),
rgba(10, 10, 12, 0.68) !important;
linear-gradient(180deg, rgba(255, 255, 255, 0.022) 0%, rgba(255, 255, 255, 0.006) 100%), rgba(10, 10, 12, 0.68) !important;
-webkit-backdrop-filter: blur(28px);
backdrop-filter: blur(28px);
}
@ -4257,5 +5055,4 @@
offset-distance: 100%;
}
}
}

View File

@ -14,6 +14,7 @@ export type TUserProfile = {
theme: {
theme: string | undefined;
nodedcAccent?: string | undefined;
nodedcCompactToolbar?: boolean | undefined;
};
onboarding_step: {

View File

@ -70,6 +70,7 @@ export type TUserProfile = {
background: string | undefined;
darkPalette: boolean | undefined;
nodedcAccent?: string | undefined;
nodedcCompactToolbar?: boolean | undefined;
};
onboarding_step: TOnboardingSteps;
is_onboarded: boolean;
@ -109,6 +110,7 @@ export interface IUserTheme {
background?: string | undefined;
darkPalette?: boolean | undefined;
nodedcAccent?: string | undefined;
nodedcCompactToolbar?: boolean | undefined;
}
export interface IUserMemberLite extends IUserLite {