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

View File

@ -7,8 +7,6 @@
*/ */
import { useMemo } from "react"; import { useMemo } from "react";
import Link from "next/link";
import { Menu } from "@headlessui/react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import useSWR from "swr"; import useSWR from "swr";
@ -19,157 +17,31 @@ import {
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS, WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS,
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS, WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS,
} from "@plane/constants"; } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { joinUrlPath } from "@plane/utils";
import { InboxIcon, PlusIcon, ProjectIcon } from "@plane/propel/icons"; import { openWorkspaceNotificationsModal } from "@/components/workspace-notifications/notifications-modal.utils";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { Tooltip } from "@plane/propel/tooltip";
import { cn, copyUrlToClipboard, joinUrlPath } from "@plane/utils";
import { TopNavPowerK } from "@/components/navigation";
import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
import { useWorkspaceNotifications } from "@/hooks/store/notifications"; import { useUser, useUserPermissions, useUserProfile } from "@/hooks/store/user";
import { useUser, useUserPermissions } from "@/hooks/store/user";
import { import {
usePersonalNavigationPreferences, usePersonalNavigationPreferences,
useWorkspaceNavigationPreferences, useWorkspaceNavigationPreferences,
} from "@/hooks/use-navigation-preferences"; } 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"; import { getSidebarNavigationItemIcon } from "@/plane-web/components/workspace/sidebar/helper";
import {
type TToolbarItem = { DEFAULT_PROJECT_SHELL_TOOLBAR_LAYOUT,
key: string; PROJECT_SHELL_TOOLBAR_LAYOUTS,
href?: string; type TProjectShellToolbarLayout,
labelTranslationKey: string; type TToolbarItem,
active: boolean; } from "./top-toolbar";
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>
);
});
export const ProjectShellTopToolbar = observer(function ProjectShellTopToolbar() { export const ProjectShellTopToolbar = observer(function ProjectShellTopToolbar() {
const { t } = useTranslation();
const pathname = usePathname(); const pathname = usePathname();
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
const { toggleCreateIssueModal } = useCommandPalette(); const { toggleCreateIssueModal } = useCommandPalette();
const { joinedProjectIds } = useProject(); const { joinedProjectIds } = useProject();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { data: userProfile } = useUserProfile();
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications(); const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
const { preferences: personalPreferences } = usePersonalNavigationPreferences(); const { preferences: personalPreferences } = usePersonalNavigationPreferences();
@ -186,7 +58,7 @@ export const ProjectShellTopToolbar = observer(function ProjectShellTopToolbar()
); );
const isMentionsEnabled = unreadNotificationsCount.mention_unread_notifications_count > 0; const isMentionsEnabled = unreadNotificationsCount.mention_unread_notifications_count > 0;
const totalNotifications = isMentionsEnabled const notificationsCount = isMentionsEnabled
? unreadNotificationsCount.mention_unread_notifications_count ? unreadNotificationsCount.mention_unread_notifications_count
: unreadNotificationsCount.total_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), }).sort((a, b) => a.sort_order - b.sort_order),
[pathname, workspacePreferences, workspaceSlug] [pathname, workspacePreferences, workspaceSlug]
); );
const workspaceSlugValue = workspaceSlug?.toString(); const workspaceSlugValue = workspaceSlug?.toString();
const isWorkspaceHome = pathname === `/${workspaceSlugValue}` || pathname === `/${workspaceSlugValue}/`; 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 ( return (
<div <ToolbarLayout
className={cn("z-20 w-full flex-shrink-0 px-4 pt-4 pb-3", { canCreateIssue={canCreateIssue}
"nodedc-home-top-toolbar": isWorkspaceHome, draftsItem={primaryItems.find((item) => item.key === "drafts")}
})} homeItem={primaryItems.find((item) => item.key === "home")}
> isWorkspaceHome={isWorkspaceHome}
<div className="nodedc-glass-modal flex w-full flex-wrap items-center justify-between gap-4 rounded-[1.6rem] px-4 py-3"> joinedProjectIdsCount={joinedProjectIds.length}
<div className="flex min-w-0 items-center gap-3"> notificationsCount={notificationsCount}
<div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible"> primaryItems={primaryItems}
<WorkspaceMenuRoot variant="toolbar" /> profileItem={primaryItems.find((item) => item.key === "your_work")}
<TopNavPowerK variant="sidebar" /> secondaryItems={secondaryItems}
<UserMenuRoot variant="toolbar" /> stickiesItem={primaryItems.find((item) => item.key === "stickies")}
<Tooltip tooltipContent={t("notification.label")} position="bottom"> onCreateIssue={() => toggleCreateIssueModal(true)}
<button onOpenNotifications={() => openWorkspaceNotificationsModal()}
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>
); );
}); });

View File

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

View File

@ -14,13 +14,14 @@ import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TPage } from "@plane/types"; import type { TPage } from "@plane/types";
// plane ui // plane ui
import { Breadcrumbs, Header } from "@plane/ui"; import { Header } from "@plane/ui";
// components // components
import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button"; import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
// hooks // hooks
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
// plane web imports // plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; 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 { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature";
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
@ -62,7 +63,7 @@ export const PagesListHeader = observer(function PagesListHeader() {
return ( return (
<Header> <Header>
<Header.LeftItem> <Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}> <ExpandedToolbarBreadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} /> <CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<ProjectFeatureBreadcrumb <ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()} workspaceSlug={workspaceSlug?.toString()}
@ -70,7 +71,7 @@ export const PagesListHeader = observer(function PagesListHeader() {
featureKey={EProjectFeatureKey.PAGES} featureKey={EProjectFeatureKey.PAGES}
isLast isLast
/> />
</Breadcrumbs> </ExpandedToolbarBreadcrumbs>
</Header.LeftItem> </Header.LeftItem>
{canCurrentUserCreatePage && ( {canCurrentUserCreatePage && (
<Header.RightItem> <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 // local components
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences"; import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
import { useUserProfile } from "@/hooks/store/user";
import { ProjectBreadcrumb } from "./project"; import { ProjectBreadcrumb } from "./project";
type TCommonProjectBreadcrumbProps = { type TCommonProjectBreadcrumbProps = {
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
shouldTruncate?: boolean;
}; };
export function CommonProjectBreadcrumbs(props: TCommonProjectBreadcrumbProps) { export function CommonProjectBreadcrumbs(props: TCommonProjectBreadcrumbProps) {
const { workspaceSlug, projectId } = props; const { workspaceSlug, projectId, shouldTruncate } = props;
// preferences // preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences(); const { preferences: projectPreferences } = useProjectNavigationPreferences();
const { data: userProfile } = useUserProfile();
const shouldUseCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
if (projectPreferences.navigationMode === "TABBED") return null; 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 = { type TProjectBreadcrumbProps = {
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
shouldTruncate?: boolean;
}; };
export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TProjectBreadcrumbProps) { export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TProjectBreadcrumbProps) {
const { workspaceSlug, projectId } = props; const { workspaceSlug, projectId, shouldTruncate = true } = props;
// router // router
const router = useAppRouter(); const router = useAppRouter();
// store hooks // store hooks
@ -69,7 +70,7 @@ export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TPro
title={currentProjectDetails?.name} title={currentProjectDetails?.name}
icon={renderIcon(currentProjectDetails)} icon={renderIcon(currentProjectDetails)}
openOnLabelClick openOnLabelClick
shouldTruncate shouldTruncate={shouldTruncate}
/> />
); );
}); });

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import { RefreshCcw } from "lucide-react";
// ui // ui
import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Breadcrumbs, Header } from "@plane/ui"; import { Header } from "@plane/ui";
// components // components
import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button"; import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
import { FiltersRoot } from "@/components/inbox/inbox-filter"; 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"; import { useUserPermissions } from "@/hooks/store/user";
// plane web imports // plane web imports
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; 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 { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature";
export const ProjectInboxHeader = observer(function ProjectInboxHeader() { export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
@ -46,7 +47,7 @@ export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
<Header> <Header>
<Header.LeftItem className="nodedc-bottom-dock-left"> <Header.LeftItem className="nodedc-bottom-dock-left">
<div className="flex min-w-0 flex-grow items-center gap-4 overflow-hidden"> <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()} /> <CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<ProjectFeatureBreadcrumb <ProjectFeatureBreadcrumb
workspaceSlug={workspaceSlug?.toString()} workspaceSlug={workspaceSlug?.toString()}
@ -54,7 +55,7 @@ export const ProjectInboxHeader = observer(function ProjectInboxHeader() {
featureKey={EProjectFeatureKey.INTAKE} featureKey={EProjectFeatureKey.INTAKE}
isLast isLast
/> />
</Breadcrumbs> </ExpandedToolbarBreadcrumbs>
{loader === "pagination-loading" && ( {loader === "pagination-loading" && (
<div className="flex items-center gap-1.5 text-tertiary"> <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"; import { Row } from "@plane/ui";
// components // components
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
import { useUserProfile } from "@/hooks/store/user";
import { ExtendedAppHeader } from "@/plane-web/components/common/extended-app-header"; import { ExtendedAppHeader } from "@/plane-web/components/common/extended-app-header";
export interface AppHeaderProps { export interface AppHeaderProps {
@ -24,6 +25,13 @@ export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
const { header, mobileHeader, className, rowClassName } = props; const { header, mobileHeader, className, rowClassName } = props;
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [dockStyle, setDockStyle] = useState<CSSProperties | undefined>(undefined); 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(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@ -61,7 +69,15 @@ export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
}, []); }, []);
return ( 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 <Row
className={cn( className={cn(
"nodedc-bottom-dock flex h-[var(--nodedc-bottom-dock-height)] w-full items-center gap-2", "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. * See the LICENSE file for details.
*/ */
import { useEffect, useState } from "react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
import { PlusIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
import { useUserProfile } from "@/hooks/store/user";
type TPrimaryActionButtonProps = ComponentProps<typeof Button>; type TPrimaryActionButtonProps = ComponentProps<typeof Button>;
export const AppHeaderPrimaryActionButton = (props: TPrimaryActionButtonProps) => { export const AppHeaderPrimaryActionButton = (props: TPrimaryActionButtonProps) => {
const { children, className, ...buttonProps } = props; const { children, className, disabled, onClick, ...buttonProps } = props;
const { t } = useTranslation(); 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 ( return (
<Button <Button
variant="primary" variant="primary"
size="lg" size="lg"
className={cn("nodedc-toolbar-primary nodedc-toolbar-primary-wide", className)} className={cn("nodedc-toolbar-primary nodedc-toolbar-primary-wide", className)}
disabled={disabled}
onClick={onClick}
{...buttonProps} {...buttonProps}
> >
{children ?? t("app_header.add_task")} {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 { HomeCardShell } from "./home-card-shell";
import { HomeGanttPreview } from "./home-gantt-preview"; import { HomeGanttPreview } from "./home-gantt-preview";
import { HomeRecentIssueDecks } from "./home-recent-issue-decks"; 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 { HomeProjectStack } from "./home-project-stack";
import { aggregateProjectAnalytics, type THomeProjectData } from "./home.utils"; import { aggregateProjectAnalytics, type THomeProjectData } from "./home.utils";
import { StickiesWidget } from "../stickies/widget"; import { StickiesWidget } from "../stickies/widget";
@ -184,7 +184,7 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
/> />
) : null; ) : null;
const sideWidgetCards = [ const bottomWidgetCards = [
isQuickLinksEnabled ? ( isQuickLinksEnabled ? (
<HomeCardShell key="quick_links" className="overflow-hidden" contentClassName="p-5"> <HomeCardShell key="quick_links" className="overflow-hidden" contentClassName="p-5">
<DashboardQuickLinks workspaceSlug={workspaceSlugValue} /> <DashboardQuickLinks workspaceSlug={workspaceSlugValue} />
@ -213,7 +213,7 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
workspaceName={currentWorkspace?.name} 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"> <div className="flex min-w-0">
<HomeProjectStack <HomeProjectStack
className="h-full" className="h-full"
@ -231,33 +231,18 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
analytics={selectedProjectAnalytics} analytics={selectedProjectAnalytics}
workspaceSlug={workspaceSlugValue} workspaceSlug={workspaceSlugValue}
/> />
<HomeRhythmRecentOverview <HomeIndividualAnalyticsPanel project={selectedProject} locale={currentLocale} />
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
recentActivitySlot={recentActivityCard}
locale={currentLocale}
/>
</div> </div>
<HomeAnalyticsRail
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale}
/>
</div> </div>
<div className="nodedc-home-lower-grid grid xl:grid-cols-[minmax(320px,360px)_minmax(0,1fr)]"> <HomeAnalyticsBottomRow recentActivitySlot={recentActivityCard} />
<HomeOperationsCard
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale}
/>
<HomeActivityTrendCard
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale}
/>
</div>
{isProjectLatestIssuesEnabled && ( {isProjectLatestIssuesEnabled && (
<HomeRecentIssueDecks project={selectedProject} workspaceSlug={workspaceSlugValue} /> <HomeRecentIssueDecks project={selectedProject} workspaceSlug={workspaceSlugValue} />
@ -268,11 +253,12 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
{hasSecondaryWidgets && ( {hasSecondaryWidgets && (
<div <div
className={cn("grid gap-5", { className={cn("nodedc-home-bottom-widgets grid gap-5", {
"md:grid-cols-2": sideWidgetCards.length > 1, "md:grid-cols-2": bottomWidgetCards.length === 2,
"xl:grid-cols-3": bottomWidgetCards.length >= 3,
})} })}
> >
{sideWidgetCards} {bottomWidgetCards}
</div> </div>
)} )}
</div> </div>

View File

@ -4,9 +4,14 @@
* See the LICENSE file for details. * 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 { 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 { import {
aggregateProjectAnalytics, aggregateProjectAnalytics,
getActivityProjectId, getActivityProjectId,
@ -294,14 +299,8 @@ export function HomeActivityTrendCard(props: HomeProjectInsightsProps) {
} }
export function HomeRhythmCard(props: HomeProjectInsightsProps) { export function HomeRhythmCard(props: HomeProjectInsightsProps) {
const { const { completedIssues, metricCards, openIssues, project, recentTouchpoints, totalIssues } =
completedIssues, useHomeProjectInsightData(props);
metricCards,
openIssues,
project,
recentTouchpoints,
totalIssues,
} = useHomeProjectInsightData(props);
return ( return (
<section className="nodedc-home-subpanel nodedc-home-rhythm-card space-y-4 p-5"> <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) { export function HomeOperationsCard(props: HomeProjectInsightsProps) {
const { const { progressRows } = useHomeProjectInsightData(props);
progressRows,
} = useHomeProjectInsightData(props);
return ( return (
<section className="nodedc-home-subpanel nodedc-home-operations-card space-y-4 p-5"> <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) { export function HomeOperationsOverview(props: HomeProjectInsightsProps) {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">

View File

@ -4,21 +4,21 @@
* See the LICENSE file for details. * 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 { observer } from "mobx-react";
import { ChartNoAxesColumn, SlidersHorizontal } from "lucide-react"; import { SlidersHorizontal } from "lucide-react";
// plane imports // plane imports
import { EIssueFilterType, ISSUE_STORE_TO_FILTERS_MAP } from "@plane/constants"; import { EIssueFilterType, ISSUE_STORE_TO_FILTERS_MAP } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssueLayoutTypes, EIssuesStoreType } from "@plane/types"; import { EIssueLayoutTypes, EIssuesStoreType } from "@plane/types";
// hooks // hooks
import { useIssues } from "@/hooks/store/use-issues"; import { useIssues } from "@/hooks/store/use-issues";
import { useUserProfile } from "@/hooks/store/user";
// plane web imports // plane web imports
import type { TProject } from "@/plane-web/types"; import type { TProject } from "@/plane-web/types";
// local imports // local imports
import { WorkItemsModal } from "../analytics/work-items/modal";
import { WorkItemFiltersToggle } from "../work-item-filters/filters-toggle"; import { WorkItemFiltersToggle } from "../work-item-filters/filters-toggle";
import { import {
DisplayFiltersSelection, DisplayFiltersSelection,
@ -47,20 +47,38 @@ export const HeaderFilters = observer(function HeaderFilters(props: Props) {
currentProjectDetails, currentProjectDetails,
projectId, projectId,
workspaceSlug, workspaceSlug,
canUserCreateIssue,
storeType = EIssuesStoreType.PROJECT, storeType = EIssuesStoreType.PROJECT,
} = props; } = props;
// i18n // i18n
const { t } = useTranslation(); const { t } = useTranslation();
// states // states
const [analyticsModal, setAnalyticsModal] = useState(false); const [expandedToolbarTarget, setExpandedToolbarTarget] = useState<HTMLElement | null>(null);
// store hooks // store hooks
const { data: userProfile } = useUserProfile();
const { const {
issuesFilter: { issueFilters, updateFilters }, issuesFilter: { issueFilters, updateFilters },
} = useIssues(storeType); } = useIssues(storeType);
// derived values // derived values
const activeLayout = issueFilters?.displayFilters?.layout; const activeLayout = issueFilters?.displayFilters?.layout;
const layoutDisplayFiltersOptions = ISSUE_STORE_TO_FILTERS_MAP[storeType]?.layoutOptions[activeLayout]; 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( const handleLayoutChange = useCallback(
(layout: EIssueLayoutTypes) => { (layout: EIssueLayoutTypes) => {
@ -86,29 +104,59 @@ export const HeaderFilters = observer(function HeaderFilters(props: Props) {
[workspaceSlug, projectId, updateFilters] [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 ( return (
<> <>
<WorkItemsModal {expandedToolbarControls}
isOpen={analyticsModal} {!isCompactToolbar && expandedToolbarTarget ? null : (
onClose={() => setAnalyticsModal(false)} <>
projectDetails={currentProjectDetails ?? undefined}
isEpic={storeType === EIssuesStoreType.EPIC}
/>
<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-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}
<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>
</div> </div>
<div className="nodedc-top-toolbar-cluster flex items-center gap-2"> <div className="nodedc-top-toolbar-cluster flex items-center gap-2">
<WorkItemFiltersToggle entityType={storeType} entityId={projectId} /> <WorkItemFiltersToggle entityType={storeType} entityId={projectId} />
@ -128,22 +176,9 @@ export const HeaderFilters = observer(function HeaderFilters(props: Props) {
isEpic={storeType === EIssuesStoreType.EPIC} isEpic={storeType === EIssuesStoreType.EPIC}
/> />
</FiltersDropdown> </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> </div>
</>
)}
</> </>
); );
}); });

View File

@ -296,10 +296,10 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
</div> </div>
<IssueLayoutHOC layout={EIssueLayoutTypes.KANBAN}> <IssueLayoutHOC layout={EIssueLayoutTypes.KANBAN}>
<div <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} 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"> <div className="h-full w-max">
<KanBanView <KanBanView
issuesMap={issueMap} issuesMap={issueMap}

View File

@ -173,7 +173,7 @@ export const KanBan = observer(function KanBan(props: IKanBan) {
} `} } `}
> >
{sub_group_by === null && ( {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 <HeaderGroupByCard
sub_group_by={sub_group_by} sub_group_by={sub_group_by}
group_by={group_by} group_by={group_by}

View File

@ -340,7 +340,7 @@ export const KanbanGroup = observer(function KanbanGroup(props: IKanbanGroup) {
</div> </div>
{shouldShowQuickAdd && ( {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 <QuickAddIssueRoot
layout={EIssueLayoutTypes.KANBAN} layout={EIssueLayoutTypes.KANBAN}
QuickAddButton={KanbanQuickAddIssueButton} QuickAddButton={KanbanQuickAddIssueButton}

View File

@ -323,7 +323,7 @@ export const KanBanSwimLanes = observer(function KanBanSwimLanes(props: IKanBanS
return ( return (
<div className="relative"> <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 <SubGroupSwimlaneHeader
getGroupIssueCount={getGroupIssueCount} getGroupIssueCount={getGroupIssueCount}
group_by={group_by} group_by={group_by}

View File

@ -24,12 +24,14 @@ import { useAppRouter } from "@/hooks/use-app-router";
import { useExpandableSearch } from "@/hooks/use-expandable-search"; import { useExpandableSearch } from "@/hooks/use-expandable-search";
type TTopNavPowerKProps = { type TTopNavPowerKProps = {
variant?: "top-navigation" | "sidebar"; variant?: "top-navigation" | "sidebar" | "expanded-toolbar";
}; };
export const TopNavPowerK = observer((props: TTopNavPowerKProps) => { export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
const { variant = "top-navigation" } = props; const { variant = "top-navigation" } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const isWideSearch = variant === "top-navigation" || variant === "expanded-toolbar";
const isExpandedToolbar = variant === "expanded-toolbar";
// router // router
const router = useAppRouter(); const router = useAppRouter();
const params = useParams(); const params = useParams();
@ -287,44 +289,99 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef} className="relative">
{variant === "top-navigation" ? ( {isWideSearch ? (
<div isExpandedToolbar ? (
className={cn("relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out", { <div className="nodedc-expanded-search-control" data-open={isOpen}>
"w-[554px]": isOpen, <div
})} className="nodedc-expanded-search-line-panel"
> onClick={() => {
<div openPanel();
className={cn( requestAnimationFrame(() => inputRef.current?.focus());
"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();
}} }}
onMouseDown={handleMouseDown} role="button"
onFocus={handleFocus} >
onKeyDown={handleKeyDown} <div className="nodedc-expanded-search-input-wrap">
placeholder={t("power_k.search_menu.quick_command_placeholder")} <input
className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none" ref={inputRef}
/> type="text"
{searchTerm && ( value={searchTerm}
<button type="button" onClick={handleClear} className="ml-2 shrink-0"> onChange={(e) => {
<CloseIcon className="size-3.5 text-placeholder hover:text-primary" /> setSearchTerm(e.target.value);
</button> 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> ) : (
<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"> <div className="relative z-30 h-8 w-8">
<button <button
@ -347,15 +404,28 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
</button> </button>
</div> </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 <div
className={cn( className={cn(
"absolute z-20 flex flex-col overflow-hidden px-0 transition-all duration-300 ease-in-out", "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, "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": "-top-[6px] left-1/2 -translate-x-1/2 rounded-md border border-subtle bg-surface-1 shadow-lg pt-10": true,
true,
} }
)} )}
> >

View File

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

View File

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

View File

@ -6,8 +6,9 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { Settings, UserPlus } from "lucide-react"; import type { MouseEvent } from "react";
import { Archive, BarChart3, Layers3, Settings, UserPlus } from "lucide-react";
import { Menu } from "@headlessui/react"; import { Menu } from "@headlessui/react";
// plane imports // plane imports
import { EUserPermissions } from "@plane/constants"; import { EUserPermissions } from "@plane/constants";
@ -31,8 +32,18 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
const { workspace, activeWorkspace, handleItemClick, handleWorkspaceNavigation, handleClose } = props; const { workspace, activeWorkspace, handleItemClick, handleWorkspaceNavigation, handleClose } = props;
// router // router
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
const router = useRouter();
// hooks // hooks
const { t } = useTranslation(); 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 ( return (
<Link <Link
@ -92,36 +103,56 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
</div> </div>
{workspace.id === activeWorkspace?.id && ( {workspace.id === activeWorkspace?.id && (
<> <>
<div className="mt-2 mb-1 grid grid-cols-2 gap-3"> <div className="mt-2 mb-1 flex flex-col gap-1.5">
{[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && ( {canOpenWorkspaceSettings && (
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => handleWorkspaceAction(e, () => openWorkspaceSettingsModal("general"))}
e.preventDefault(); 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"
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"
> >
<Settings className="my-auto h-4 w-4 flex-shrink-0" /> <Settings className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">{t("settings")}</span> <span className="my-auto text-13 font-medium whitespace-nowrap">{t("settings")}</span>
</button> </button>
)} )}
{[EUserPermissions.ADMIN].includes(workspace?.role) && ( {canInviteMembers && (
<Link <button
href={`/${workspace.slug}/settings/members`} type="button"
onClick={(e) => { onClick={(e) => handleWorkspaceAction(e, () => openWorkspaceSettingsModal("members"))}
e.stopPropagation(); 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"
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"
> >
<UserPlus className="my-auto h-4 w-4 flex-shrink-0" /> <UserPlus className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap"> <span className="my-auto text-13 font-medium whitespace-nowrap">
{t("project_settings.members.invite_members.title")} {t("project_settings.members.invite_members.title")}
</span> </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> </div>
</> </>

View File

@ -22,7 +22,7 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useUser } from "@/hooks/store/user"; import { useUser } from "@/hooks/store/user";
type TUserMenuRootProps = { type TUserMenuRootProps = {
variant?: "default" | "sidebar-utility" | "toolbar"; variant?: "default" | "sidebar-utility" | "toolbar" | "expanded-toolbar";
}; };
export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootProps) { 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 isSidebarUtilityVariant = variant === "sidebar-utility";
const isToolbarVariant = variant === "toolbar"; const isToolbarVariant = variant === "toolbar";
const isExpandedToolbarVariant = variant === "expanded-toolbar";
const handleSignOut = () => { const handleSignOut = () => {
signOut().catch(() => signOut().catch(() =>
@ -137,16 +138,18 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
className="flex items-center" className="flex items-center"
buttonAsChild buttonAsChild
button={ button={
isToolbarVariant ? ( isToolbarVariant || isExpandedToolbarVariant ? (
<button <button
type="button" type="button"
aria-label={t("profile")} 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 <Avatar
name={currentUser?.display_name} name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")} src={getFileURL(currentUser?.avatar_url ?? "")}
size={18} size={isExpandedToolbarVariant ? 48 : 18}
shape="circle" shape="circle"
/> />
</button> </button>

View File

@ -31,7 +31,7 @@ import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item"; import SidebarDropdownItem from "./dropdown-item";
type WorkspaceMenuRootProps = { type WorkspaceMenuRootProps = {
variant: "sidebar" | "top-navigation" | "sidebar-panel" | "toolbar"; variant: "sidebar" | "top-navigation" | "sidebar-panel" | "toolbar" | "expanded-toolbar";
}; };
type WorkspaceMenuStateSyncProps = { type WorkspaceMenuStateSyncProps = {
@ -46,7 +46,12 @@ function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) {
const { open, variant, sidebarPanelButtonRef, onSidebarDropdownToggle, onSidebarPanelPositionChange } = props; const { open, variant, sidebarPanelButtonRef, onSidebarDropdownToggle, onSidebarPanelPositionChange } = props;
const updateSidebarPanelMenuPosition = useCallback(() => { 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 rect = sidebarPanelButtonRef.current.getBoundingClientRect();
const width = 480; const width = 480;
@ -64,7 +69,7 @@ function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) {
}, [onSidebarDropdownToggle, open]); }, [onSidebarDropdownToggle, open]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!open || !["sidebar-panel", "toolbar"].includes(variant)) { if (!open || !["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant)) {
onSidebarPanelPositionChange(null); onSidebarPanelPositionChange(null);
return; return;
} }
@ -133,7 +138,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
"w-full justify-center text-center": variant === "sidebar", "w-full justify-center text-center": variant === "sidebar",
"flex-grow justify-stretch text-left": variant === "top-navigation", "flex-grow justify-stretch text-left": variant === "top-navigation",
"w-full max-w-none justify-stretch text-left": variant === "sidebar-panel", "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 }) => { {({ open, close }: { open: boolean; close: () => void }) => {
@ -221,11 +226,12 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
/> />
</Menu.Button> </Menu.Button>
)} )}
{variant === "toolbar" && ( {["toolbar", "expanded-toolbar"].includes(variant) && (
<Menu.Button <Menu.Button
ref={sidebarPanelButtonRef} ref={sidebarPanelButtonRef}
className={cn( 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, "bg-white/[0.08]": open,
} }
@ -235,7 +241,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
<WorkspaceLogo <WorkspaceLogo
logo={activeWorkspace?.logo_url} logo={activeWorkspace?.logo_url}
name={activeWorkspace?.name} name={activeWorkspace?.name}
classNames="size-8 rounded-[0.9rem]" classNames={variant === "expanded-toolbar" ? "size-12 rounded-full" : "size-8 rounded-[0.9rem]"}
/> />
</Menu.Button> </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", "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": "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-11 left-14": variant === "sidebar",
"top-10 left-4": variant === "top-navigation", "top-10 left-4": variant === "top-navigation",
"nodedc-glass-modal nodedc-glass-popup-surface rounded-[1.5rem] divide-white/10": "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={ style={
["sidebar-panel", "toolbar"].includes(variant) && sidebarPanelMenuPosition ["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant) && sidebarPanelMenuPosition
? { ? {
position: "fixed", position: "fixed",
left: `${sidebarPanelMenuPosition.left}px`, left: `${sidebarPanelMenuPosition.left}px`,
@ -270,8 +276,8 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
className={cn( 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", "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), "rounded-md bg-surface-1": !["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant),
"bg-transparent": ["sidebar-panel", "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> </Menu.Items>
); );
if (["sidebar-panel", "toolbar"].includes(variant)) { if (["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant)) {
if (!open || !sidebarPanelMenuPosition || typeof document === "undefined") return null; if (!open || !sidebarPanelMenuPosition || typeof document === "undefined") return null;
return createPortal(menuItems, document.body); return createPortal(menuItems, document.body);
} }

View File

@ -46,6 +46,7 @@ export class ProfileStore implements IUserProfileStore {
background: undefined, background: undefined,
darkPalette: false, darkPalette: false,
nodedcAccent: undefined, nodedcAccent: undefined,
nodedcCompactToolbar: undefined,
}, },
onboarding_step: { onboarding_step: {
workspace_join: false, 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; 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="button"],
.nodedc-glass-modal [data-slot="icon-button"] { .nodedc-glass-modal [data-slot="icon-button"] {
border: none !important; border: none !important;
@ -754,6 +777,516 @@
color: rgb(var(--nodedc-on-accent-rgb)); 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 { .nodedc-toolbar-pill {
position: relative; position: relative;
display: inline-flex !important; display: inline-flex !important;
@ -1289,8 +1822,7 @@
border: 0 !important; border: 0 !important;
outline: none !important; outline: none !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.075) 0%, rgba(255, 255, 255, 0.026) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.075) 0%, rgba(255, 255, 255, 0.026) 100%), rgba(8, 9, 12, 0.78) !important;
rgba(8, 9, 12, 0.78) !important;
-webkit-backdrop-filter: blur(28px); -webkit-backdrop-filter: blur(28px);
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%), linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%),
rgb(var(--nodedc-card-active-rgb)) !important; rgb(var(--nodedc-card-active-rgb)) !important;
color: rgb(var(--nodedc-on-card-active-rgb)) !important; color: rgb(var(--nodedc-on-card-active-rgb)) !important;
box-shadow: box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16) !important;
0 12px 32px rgba(0, 0, 0, 0.16) !important;
} }
.nodedc-external-card[data-active="true"] .text-primary { .nodedc-external-card[data-active="true"] .text-primary {
@ -1940,6 +2471,8 @@
.nodedc-home-route-surface { .nodedc-home-route-surface {
min-height: 100vh; min-height: 100vh;
padding-right: 1.25rem !important;
padding-left: 1.25rem !important;
background: var(--background-color-surface-1) !important; background: var(--background-color-surface-1) !important;
} }
@ -1948,7 +2481,8 @@
} }
.nodedc-home-page-shell { .nodedc-home-page-shell {
max-width: min(1840px, calc(100vw - 5rem)); max-width: none;
padding: 0 !important;
} }
.nodedc-home-dashboard-shell { .nodedc-home-dashboard-shell {
@ -1972,11 +2506,267 @@
} }
.nodedc-home-main-column { .nodedc-home-main-column {
--nodedc-home-gantt-height: 35.75rem;
--nodedc-home-individual-chart-height: 11.25rem;
display: flex; display: flex;
min-width: 0;
min-height: 0;
height: 100%;
flex-direction: column; flex-direction: column;
gap: 0.75rem; 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-hero {
--nodedc-home-column-gap: 0.75rem; --nodedc-home-column-gap: 0.75rem;
--nodedc-home-title-width: 13.25rem; --nodedc-home-title-width: 13.25rem;
@ -2177,9 +2967,12 @@
.nodedc-home-gantt-card { .nodedc-home-gantt-card {
position: relative; position: relative;
display: flex;
flex-direction: column;
overflow: hidden; overflow: hidden;
isolation: isolate; isolation: isolate;
min-height: 30rem; min-height: 0;
flex: 1 1 auto;
border-radius: 2rem !important; border-radius: 2rem !important;
background: #050506 !important; background: #050506 !important;
box-shadow: none !important; box-shadow: none !important;
@ -2441,7 +3234,10 @@
.nodedc-home-gantt-surface { .nodedc-home-gantt-surface {
position: relative; 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; margin: 0 1.25rem 1.25rem;
overflow: hidden; overflow: hidden;
border-radius: 1.75rem; border-radius: 1.75rem;
@ -2449,15 +3245,18 @@
} }
.nodedc-home-gantt-scroll { .nodedc-home-gantt-scroll {
min-height: 23.5rem; height: 100%;
overflow-x: auto; min-height: 100%;
overflow-y: hidden; 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-color: rgba(var(--nodedc-card-active-rgb), 0.65) rgba(255, 255, 255, 0.04);
scrollbar-width: thin; scrollbar-width: thin;
} }
.nodedc-home-gantt-scroll::-webkit-scrollbar { .nodedc-home-gantt-scroll::-webkit-scrollbar {
height: 0.55rem; width: 0.45rem;
height: 0.45rem;
} }
.nodedc-home-gantt-scroll::-webkit-scrollbar-track { .nodedc-home-gantt-scroll::-webkit-scrollbar-track {
@ -2472,7 +3271,7 @@
.nodedc-home-gantt-canvas { .nodedc-home-gantt-canvas {
position: relative; position: relative;
min-height: 23.5rem; min-height: 100%;
padding: 1rem; padding: 1rem;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px) 0 0 / 100% 4.2rem, 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 { .nodedc-home-operations-card {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.022) 0%, rgba(255, 255, 255, 0.006) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.022) 0%, rgba(255, 255, 255, 0.006) 100%), rgba(10, 10, 12, 0.68) !important;
rgba(10, 10, 12, 0.68) !important;
-webkit-backdrop-filter: blur(28px); -webkit-backdrop-filter: blur(28px);
backdrop-filter: blur(28px); backdrop-filter: blur(28px);
} }
@ -4257,5 +5055,4 @@
offset-distance: 100%; offset-distance: 100%;
} }
} }
} }

View File

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

View File

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