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
@@ -11,6 +11,7 @@ import { observer } from "mobx-react";
import { Row } from "@plane/ui";
// components
import { cn } from "@plane/utils";
import { useUserProfile } from "@/hooks/store/user";
import { ExtendedAppHeader } from "@/plane-web/components/common/extended-app-header";
export interface AppHeaderProps {
@@ -24,6 +25,13 @@ export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
const { header, mobileHeader, className, rowClassName } = props;
const containerRef = useRef<HTMLDivElement>(null);
const [dockStyle, setDockStyle] = useState<CSSProperties | undefined>(undefined);
const { data: userProfile } = useUserProfile();
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
const effectiveDockStyle = isCompactToolbar
? dockStyle
: {
left: typeof dockStyle?.left === "number" ? dockStyle.left : 0,
};
useEffect(() => {
if (typeof window === "undefined") return;
@@ -61,7 +69,15 @@ export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
}, []);
return (
<div ref={containerRef} className={cn("fixed right-0 bottom-0 z-[18]", className)} style={dockStyle}>
<div
ref={containerRef}
className={cn(
"fixed bottom-0 z-[18]",
isCompactToolbar ? "right-0 nodedc-app-header-compact" : "nodedc-app-header-expanded",
className
)}
style={effectiveDockStyle}
>
<Row
className={cn(
"nodedc-bottom-dock flex h-[var(--nodedc-bottom-dock-height)] w-full items-center gap-2",
@@ -4,22 +4,65 @@
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import type { ComponentProps } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { PlusIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
import { useUserProfile } from "@/hooks/store/user";
type TPrimaryActionButtonProps = ComponentProps<typeof Button>;
export const AppHeaderPrimaryActionButton = (props: TPrimaryActionButtonProps) => {
const { children, className, ...buttonProps } = props;
const { children, className, disabled, onClick, ...buttonProps } = props;
const { t } = useTranslation();
const { data: userProfile } = useUserProfile();
const [expandedToolbarTarget, setExpandedToolbarTarget] = useState<HTMLElement | null>(null);
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
useEffect(() => {
if (isCompactToolbar || typeof document === "undefined") {
setExpandedToolbarTarget(null);
return;
}
const animationFrame = window.requestAnimationFrame(() => {
setExpandedToolbarTarget(document.querySelector<HTMLElement>("[data-nodedc-expanded-primary-action-slot]"));
});
return () => window.cancelAnimationFrame(animationFrame);
}, [isCompactToolbar]);
if (!isCompactToolbar) {
if (!expandedToolbarTarget) return null;
return createPortal(
<Tooltip tooltipContent={typeof children === "string" ? children : t("app_header.add_task")} position="bottom">
<button
type="button"
className="nodedc-expanded-tool-button"
aria-label={typeof children === "string" ? children : t("app_header.add_task")}
disabled={disabled}
onClick={onClick}
data-ph-element={(buttonProps as { "data-ph-element"?: string })["data-ph-element"]}
>
<PlusIcon className="size-4" />
</button>
</Tooltip>,
expandedToolbarTarget
);
}
return (
<Button
variant="primary"
size="lg"
className={cn("nodedc-toolbar-primary nodedc-toolbar-primary-wide", className)}
disabled={disabled}
onClick={onClick}
{...buttonProps}
>
{children ?? t("app_header.add_task")}
@@ -24,7 +24,7 @@ import { WorkspaceService } from "@/services/workspace.service";
import { HomeCardShell } from "./home-card-shell";
import { HomeGanttPreview } from "./home-gantt-preview";
import { HomeRecentIssueDecks } from "./home-recent-issue-decks";
import { HomeActivityTrendCard, HomeOperationsCard, HomeRhythmRecentOverview } from "./home-project-insights";
import { HomeAnalyticsBottomRow, HomeAnalyticsRail, HomeIndividualAnalyticsPanel } from "./home-project-insights";
import { HomeProjectStack } from "./home-project-stack";
import { aggregateProjectAnalytics, type THomeProjectData } from "./home.utils";
import { StickiesWidget } from "../stickies/widget";
@@ -184,7 +184,7 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
/>
) : null;
const sideWidgetCards = [
const bottomWidgetCards = [
isQuickLinksEnabled ? (
<HomeCardShell key="quick_links" className="overflow-hidden" contentClassName="p-5">
<DashboardQuickLinks workspaceSlug={workspaceSlugValue} />
@@ -213,7 +213,7 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
workspaceName={currentWorkspace?.name}
/>
<div className="nodedc-home-dashboard-grid grid xl:grid-cols-[minmax(320px,360px)_minmax(0,1fr)] xl:items-stretch">
<div className="nodedc-home-dashboard-grid grid xl:grid-cols-[minmax(320px,360px)_minmax(0,1fr)_minmax(320px,360px)] xl:items-stretch">
<div className="flex min-w-0">
<HomeProjectStack
className="h-full"
@@ -231,33 +231,18 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
analytics={selectedProjectAnalytics}
workspaceSlug={workspaceSlugValue}
/>
<HomeRhythmRecentOverview
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
recentActivitySlot={recentActivityCard}
locale={currentLocale}
/>
<HomeIndividualAnalyticsPanel project={selectedProject} locale={currentLocale} />
</div>
<HomeAnalyticsRail
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale}
/>
</div>
<div className="nodedc-home-lower-grid grid xl:grid-cols-[minmax(320px,360px)_minmax(0,1fr)]">
<HomeOperationsCard
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale}
/>
<HomeActivityTrendCard
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale}
/>
</div>
<HomeAnalyticsBottomRow recentActivitySlot={recentActivityCard} />
{isProjectLatestIssuesEnabled && (
<HomeRecentIssueDecks project={selectedProject} workspaceSlug={workspaceSlugValue} />
@@ -268,11 +253,12 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo
{hasSecondaryWidgets && (
<div
className={cn("grid gap-5", {
"md:grid-cols-2": sideWidgetCards.length > 1,
className={cn("nodedc-home-bottom-widgets grid gap-5", {
"md:grid-cols-2": bottomWidgetCards.length === 2,
"xl:grid-cols-3": bottomWidgetCards.length >= 3,
})}
>
{sideWidgetCards}
{bottomWidgetCards}
</div>
)}
</div>
@@ -4,9 +4,14 @@
* See the LICENSE file for details.
*/
import { type ReactNode, useId, useMemo } from "react";
import { type ReactNode, useEffect, useId, useMemo } from "react";
import { observer } from "mobx-react";
import { Activity, CheckCircle2, Layers3, UsersRound } from "lucide-react";
import type { TActivityEntityData, TProjectAnalyticsCount } from "@plane/types";
import { type TActivityEntityData, type TProjectAnalyticsCount } from "@plane/types";
import CreatedVsResolved from "@/components/analytics/work-items/created-vs-resolved";
import CustomizedInsights from "@/components/analytics/work-items/customized-insights";
import WorkItemsInsightTable from "@/components/analytics/work-items/workitems-insight-table";
import { useAnalytics } from "@/hooks/store/use-analytics";
import {
aggregateProjectAnalytics,
getActivityProjectId,
@@ -294,14 +299,8 @@ export function HomeActivityTrendCard(props: HomeProjectInsightsProps) {
}
export function HomeRhythmCard(props: HomeProjectInsightsProps) {
const {
completedIssues,
metricCards,
openIssues,
project,
recentTouchpoints,
totalIssues,
} = useHomeProjectInsightData(props);
const { completedIssues, metricCards, openIssues, project, recentTouchpoints, totalIssues } =
useHomeProjectInsightData(props);
return (
<section className="nodedc-home-subpanel nodedc-home-rhythm-card space-y-4 p-5">
@@ -368,9 +367,7 @@ export function HomeRhythmCard(props: HomeProjectInsightsProps) {
}
export function HomeOperationsCard(props: HomeProjectInsightsProps) {
const {
progressRows,
} = useHomeProjectInsightData(props);
const { progressRows } = useHomeProjectInsightData(props);
return (
<section className="nodedc-home-subpanel nodedc-home-operations-card space-y-4 p-5">
@@ -437,6 +434,196 @@ export function HomeRhythmRecentOverview(props: HomeProjectInsightsProps) {
);
}
function HomeActivityMiniCard(props: HomeProjectInsightsProps) {
const { activitySeries, chart, chartId, project, recentTouchpoints } = useHomeProjectInsightData(props);
return (
<section className="nodedc-home-subpanel nodedc-home-activity-mini p-5">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold tracking-[0.2em] text-placeholder uppercase">
{project?.identifier ?? "Workspace"}
</div>
<div className="text-15 mt-2 font-semibold text-primary">Активность</div>
<div className="mt-1 text-12 text-secondary">Касания за последние 7 дней.</div>
</div>
<div className="nodedc-home-focus-chip">{recentTouchpoints}</div>
</div>
<div className="nodedc-home-activity-mini-chart">
<svg viewBox={`0 0 ${chart.width} ${chart.height}`} className="h-full w-full" preserveAspectRatio="none">
<defs>
<linearGradient id={`${chartId}-mini-fill`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="rgba(var(--nodedc-accent-rgb),0.3)" />
<stop offset="100%" stopColor="rgba(var(--nodedc-accent-rgb),0.02)" />
</linearGradient>
</defs>
{[0.25, 0.5, 0.75].map((position) => {
const y = chart.height - chart.paddingY - position * (chart.height - chart.paddingY * 2);
return <line key={position} x1={12} x2={chart.width - 12} y1={y} y2={y} stroke="rgba(255,255,255,0.07)" />;
})}
<path d={chart.areaPath} fill={`url(#${chartId}-mini-fill)`} />
<path
d={chart.linePath}
fill="none"
stroke="rgb(var(--nodedc-accent-rgb))"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="5"
/>
{activitySeries.map((activityPoint, index) => {
const point = chart.points[index];
if (!point) return null;
return (
<circle
key={activityPoint.key}
cx={point.x}
cy={point.y}
fill="rgb(var(--nodedc-accent-rgb))"
r={activityPoint.value > 0 ? 4 : 2.2}
/>
);
})}
</svg>
</div>
</section>
);
}
export const HomeAnalyticsRail = observer(function HomeAnalyticsRail(props: HomeProjectInsightsProps) {
const { project } = props;
const { completionRate, completedIssues, openIssues, recentTouchpoints, totalIssues } =
useHomeProjectInsightData(props);
const { updateIsEpic, updateIsPeekView, updateSelectedCycle, updateSelectedModule, updateSelectedProjects } =
useAnalytics();
useEffect(() => {
updateIsPeekView(true);
updateIsEpic(false);
updateSelectedCycle("");
updateSelectedModule("");
updateSelectedProjects(project?.id ? [project.id] : []);
return () => {
updateSelectedProjects([]);
updateSelectedCycle("");
updateSelectedModule("");
updateIsPeekView(false);
updateIsEpic(false);
};
}, [project?.id, updateIsEpic, updateIsPeekView, updateSelectedCycle, updateSelectedModule, updateSelectedProjects]);
return (
<aside className="nodedc-home-analytics-rail" aria-label="Аналитика проекта">
<section className="nodedc-home-subpanel nodedc-home-analytics-intro p-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold tracking-[0.22em] text-placeholder uppercase">
{project?.identifier ?? "Workspace"}
</div>
<div className="mt-2 text-16 font-semibold text-primary">Аналитика проекта</div>
</div>
<div className="nodedc-home-focus-chip">{completionRate}%</div>
</div>
<div className="nodedc-home-analytics-stat-grid mt-4">
<div className="nodedc-home-analytics-stat">
<span>Всего</span>
<strong>{totalIssues}</strong>
</div>
<div className="nodedc-home-analytics-stat">
<span>Открыто</span>
<strong>{openIssues}</strong>
</div>
<div className="nodedc-home-analytics-stat">
<span>Закрыто</span>
<strong>{completedIssues}</strong>
</div>
<div className="nodedc-home-analytics-stat">
<span>Касания</span>
<strong>{recentTouchpoints}</strong>
</div>
</div>
</section>
<HomeActivityMiniCard {...props} />
<HomeOperationsCard {...props} />
<CreatedVsResolved />
</aside>
);
});
export const HomeIndividualAnalyticsPanel = observer(function HomeIndividualAnalyticsPanel(
props: Pick<HomeProjectInsightsProps, "project" | "locale">
) {
const { updateIsEpic, updateIsPeekView, updateSelectedCycle, updateSelectedModule, updateSelectedProjects } =
useAnalytics();
useEffect(() => {
updateIsPeekView(true);
updateIsEpic(false);
updateSelectedCycle("");
updateSelectedModule("");
updateSelectedProjects(props.project?.id ? [props.project.id] : []);
return () => {
updateSelectedProjects([]);
updateSelectedCycle("");
updateSelectedModule("");
updateIsPeekView(false);
updateIsEpic(false);
};
}, [
props.project?.id,
updateIsEpic,
updateIsPeekView,
updateSelectedCycle,
updateSelectedModule,
updateSelectedProjects,
]);
return (
<section className="nodedc-home-individual-analytics" aria-label="Индивидуальные аналитические данные">
<CustomizedInsights peekView />
</section>
);
});
export const HomeAnalyticsBottomRow = observer(function HomeAnalyticsBottomRow({
recentActivitySlot,
}: {
recentActivitySlot?: ReactNode;
}) {
return (
<section className="nodedc-home-analytics-bottom-row" aria-label="Назначения и последние действия">
<div className="nodedc-home-assignee-analytics">
<WorkItemsInsightTable />
</div>
<div className="nodedc-home-subpanel nodedc-home-analytics-recents p-5">
{recentActivitySlot ? (
<div className="h-full min-h-[22rem]">{recentActivitySlot}</div>
) : (
<div className="flex h-full min-h-[22rem] flex-col justify-between">
<div>
<div className="flex items-center gap-3">
<div className="grid size-11 place-items-center rounded-full bg-black text-[rgb(var(--nodedc-card-active-rgb))]">
<Layers3 className="size-5" />
</div>
<div>
<div className="text-15 font-semibold text-primary">Последние действия</div>
<div className="text-12 text-secondary">Виджет recent activity отключен в настройках.</div>
</div>
</div>
</div>
</div>
)}
</div>
</section>
);
});
export function HomeOperationsOverview(props: HomeProjectInsightsProps) {
return (
<div className="grid gap-4">
@@ -4,21 +4,21 @@
* See the LICENSE file for details.
*/
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { observer } from "mobx-react";
import { ChartNoAxesColumn, SlidersHorizontal } from "lucide-react";
import { SlidersHorizontal } from "lucide-react";
// plane imports
import { EIssueFilterType, ISSUE_STORE_TO_FILTERS_MAP } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssueLayoutTypes, EIssuesStoreType } from "@plane/types";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useUserProfile } from "@/hooks/store/user";
// plane web imports
import type { TProject } from "@/plane-web/types";
// local imports
import { WorkItemsModal } from "../analytics/work-items/modal";
import { WorkItemFiltersToggle } from "../work-item-filters/filters-toggle";
import {
DisplayFiltersSelection,
@@ -47,20 +47,38 @@ export const HeaderFilters = observer(function HeaderFilters(props: Props) {
currentProjectDetails,
projectId,
workspaceSlug,
canUserCreateIssue,
storeType = EIssuesStoreType.PROJECT,
} = props;
// i18n
const { t } = useTranslation();
// states
const [analyticsModal, setAnalyticsModal] = useState(false);
const [expandedToolbarTarget, setExpandedToolbarTarget] = useState<HTMLElement | null>(null);
// store hooks
const { data: userProfile } = useUserProfile();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(storeType);
// derived values
const activeLayout = issueFilters?.displayFilters?.layout;
const layoutDisplayFiltersOptions = ISSUE_STORE_TO_FILTERS_MAP[storeType]?.layoutOptions[activeLayout];
const isCompactToolbar = userProfile?.theme?.nodedcCompactToolbar === true;
useEffect(() => {
if (isCompactToolbar || typeof document === "undefined") {
setExpandedToolbarTarget(null);
return;
}
let animationFrame = 0;
const resolveTarget = () => {
setExpandedToolbarTarget(document.querySelector<HTMLElement>("[data-nodedc-expanded-header-filters-slot]"));
};
animationFrame = window.requestAnimationFrame(resolveTarget);
return () => window.cancelAnimationFrame(animationFrame);
}, [isCompactToolbar]);
const handleLayoutChange = useCallback(
(layout: EIssueLayoutTypes) => {
@@ -86,29 +104,59 @@ export const HeaderFilters = observer(function HeaderFilters(props: Props) {
[workspaceSlug, projectId, updateFilters]
);
const layoutSelection = (
<>
<div className="pointer-events-auto hidden @4xl:flex">
<LayoutSelection layouts={LAYOUTS} onChange={(layout) => handleLayoutChange(layout)} selectedLayout={activeLayout} />
</div>
<div className="pointer-events-auto flex @4xl:hidden">
<MobileLayoutSelection layouts={LAYOUTS} onChange={(layout) => handleLayoutChange(layout)} activeLayout={activeLayout} />
</div>
</>
);
const headerTools = (
<>
<WorkItemFiltersToggle entityType={storeType} entityId={projectId} />
<FiltersDropdown
menuButton={<SlidersHorizontal className="size-4" />}
menuButtonWrapperClassName="nodedc-expanded-tool-button"
miniIcon={<SlidersHorizontal className="size-3.5" />}
title={t("common.display")}
placement="bottom-end"
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={layoutDisplayFiltersOptions}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
isEpic={storeType === EIssuesStoreType.EPIC}
/>
</FiltersDropdown>
</>
);
const expandedToolbarControls =
!isCompactToolbar && expandedToolbarTarget
? createPortal(
<div className="nodedc-expanded-header-filters">
{layoutSelection}
{headerTools}
</div>,
expandedToolbarTarget
)
: null;
return (
<>
<WorkItemsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
projectDetails={currentProjectDetails ?? undefined}
isEpic={storeType === EIssuesStoreType.EPIC}
/>
{expandedToolbarControls}
{!isCompactToolbar && expandedToolbarTarget ? null : (
<>
<div className="pointer-events-none absolute top-1/2 left-1/2 z-[1] flex -translate-x-1/2 -translate-y-1/2 items-center">
<div className="pointer-events-auto hidden @4xl:flex">
<LayoutSelection
layouts={LAYOUTS}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
</div>
<div className="pointer-events-auto flex @4xl:hidden">
<MobileLayoutSelection
layouts={LAYOUTS}
onChange={(layout) => handleLayoutChange(layout)}
activeLayout={activeLayout}
/>
</div>
{layoutSelection}
</div>
<div className="nodedc-top-toolbar-cluster flex items-center gap-2">
<WorkItemFiltersToggle entityType={storeType} entityId={projectId} />
@@ -128,22 +176,9 @@ export const HeaderFilters = observer(function HeaderFilters(props: Props) {
isEpic={storeType === EIssuesStoreType.EPIC}
/>
</FiltersDropdown>
{canUserCreateIssue ? (
<Button
className="nodedc-toolbar-pill nodedc-toolbar-pill-wide hidden md:inline-flex"
onClick={() => setAnalyticsModal(true)}
variant="secondary"
size="lg"
>
<div className="hidden @4xl:flex">{t("common.analytics")}</div>
<div className="flex @4xl:hidden">
<ChartNoAxesColumn className="size-3.5" />
</div>
</Button>
) : (
<></>
)}
</div>
</>
)}
</>
);
});
@@ -296,10 +296,10 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
</div>
<IssueLayoutHOC layout={EIssueLayoutTypes.KANBAN}>
<div
className={`horizontal-scrollbar relative flex scrollbar-lg h-full w-full bg-surface-2 ${sub_group_by ? "vertical-scrollbar overflow-y-auto" : "overflow-x-auto overflow-y-hidden"}`}
className={`nodedc-kanban-scroll-container horizontal-scrollbar relative flex scrollbar-lg h-full w-full bg-transparent ${sub_group_by ? "vertical-scrollbar overflow-y-auto" : "overflow-x-auto overflow-y-hidden"}`}
ref={scrollableContainerRef}
>
<div className="relative h-full w-max min-w-full bg-surface-2">
<div className="relative h-full w-max min-w-full bg-transparent">
<div className="h-full w-max">
<KanBanView
issuesMap={issueMap}
@@ -173,7 +173,7 @@ export const KanBan = observer(function KanBan(props: IKanBan) {
} `}
>
{sub_group_by === null && (
<div className="sticky top-0 z-[2] w-full flex-shrink-0 bg-surface-2 py-1">
<div className="sticky top-0 z-[2] w-full flex-shrink-0 bg-transparent py-1">
<HeaderGroupByCard
sub_group_by={sub_group_by}
group_by={group_by}
@@ -340,7 +340,7 @@ export const KanbanGroup = observer(function KanbanGroup(props: IKanbanGroup) {
</div>
{shouldShowQuickAdd && (
<div className="nodedc-bottom-dock-sticky-offset sticky z-[2] w-full bg-surface-2 py-0.5">
<div className="nodedc-bottom-dock-sticky-offset sticky z-[2] w-full bg-transparent py-0.5">
<QuickAddIssueRoot
layout={EIssueLayoutTypes.KANBAN}
QuickAddButton={KanbanQuickAddIssueButton}
@@ -323,7 +323,7 @@ export const KanBanSwimLanes = observer(function KanBanSwimLanes(props: IKanBanS
return (
<div className="relative">
<Row className="sticky top-0 z-[4] h-[50px] bg-surface-2">
<Row className="sticky top-0 z-[4] h-[50px] bg-transparent">
<SubGroupSwimlaneHeader
getGroupIssueCount={getGroupIssueCount}
group_by={group_by}
@@ -24,12 +24,14 @@ import { useAppRouter } from "@/hooks/use-app-router";
import { useExpandableSearch } from "@/hooks/use-expandable-search";
type TTopNavPowerKProps = {
variant?: "top-navigation" | "sidebar";
variant?: "top-navigation" | "sidebar" | "expanded-toolbar";
};
export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
const { variant = "top-navigation" } = props;
const { t } = useTranslation();
const isWideSearch = variant === "top-navigation" || variant === "expanded-toolbar";
const isExpandedToolbar = variant === "expanded-toolbar";
// router
const router = useAppRouter();
const params = useParams();
@@ -287,44 +289,99 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
return (
<div ref={containerRef} className="relative">
{variant === "top-navigation" ? (
<div
className={cn("relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out", {
"w-[554px]": isOpen,
})}
>
<div
className={cn(
"flex h-7 w-full items-center rounded-lg border border-subtle-1 bg-layer-2 p-2 transition-colors duration-200",
{
"bg-layer-1": isOpen,
}
)}
onClick={() => inputRef.current?.focus()}
role="button"
>
<SearchIcon className="mr-2 size-3.5 shrink-0 text-placeholder" />
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) openPanel();
{isWideSearch ? (
isExpandedToolbar ? (
<div className="nodedc-expanded-search-control" data-open={isOpen}>
<div
className="nodedc-expanded-search-line-panel"
onClick={() => {
openPanel();
requestAnimationFrame(() => inputRef.current?.focus());
}}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder={t("power_k.search_menu.quick_command_placeholder")}
className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none"
/>
{searchTerm && (
<button type="button" onClick={handleClear} className="ml-2 shrink-0">
<CloseIcon className="size-3.5 text-placeholder hover:text-primary" />
</button>
)}
role="button"
>
<div className="nodedc-expanded-search-input-wrap">
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) openPanel();
}}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder=""
tabIndex={isOpen ? 0 : -1}
className="nodedc-expanded-search-input placeholder-text-placeholder min-w-0 flex-1 bg-transparent outline-none"
/>
{searchTerm && (
<button type="button" onClick={handleClear} className="nodedc-expanded-search-clear">
<CloseIcon className="size-3.5" />
</button>
)}
</div>
</div>
<button
type="button"
className="nodedc-expanded-tool-button nodedc-expanded-search-trigger"
data-active={isOpen}
aria-label="Поиск"
aria-pressed={isOpen}
onClick={() => {
if (isOpen) {
closePanel();
return;
}
openPanel();
requestAnimationFrame(() => inputRef.current?.focus());
}}
>
<SearchIcon className="size-4 shrink-0" />
</button>
</div>
</div>
) : (
<div
className={cn("relative z-30 flex w-[364px] items-center transition-all duration-300 ease-in-out", {
"w-[554px]": isOpen,
})}
>
<div
className={cn(
"flex h-7 w-full items-center rounded-lg border border-subtle-1 bg-layer-2 p-2 transition-colors duration-200",
{
"bg-layer-1": isOpen,
}
)}
onClick={() => inputRef.current?.focus()}
role="button"
>
<span className="mr-2">
<SearchIcon className="size-3.5 shrink-0 text-placeholder" />
</span>
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!isOpen) openPanel();
}}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder={t("power_k.search_menu.quick_command_placeholder")}
className="placeholder-text-placeholder min-w-0 flex-1 bg-transparent text-13 text-primary outline-none"
/>
{searchTerm && (
<button type="button" onClick={handleClear} className="ml-2 shrink-0">
<CloseIcon className="size-3.5 text-placeholder hover:text-primary" />
</button>
)}
</div>
</div>
)
) : (
<div className="relative z-30 h-8 w-8">
<button
@@ -347,15 +404,28 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
</button>
</div>
)}
{variant === "top-navigation" && (
{isWideSearch && isExpandedToolbar && (
<div
className={cn(
"nodedc-expanded-search-results nodedc-glass-modal nodedc-glass-popup-surface absolute z-20 flex flex-col overflow-hidden px-0 pt-3 transition-all duration-300 ease-in-out",
{
"max-h-[80vh] opacity-100": isOpen,
"h-0 w-0 opacity-0": !isOpen,
}
)}
>
{isOpen && searchCommandContent}
</div>
)}
{isWideSearch && !isExpandedToolbar && (
<div
className={cn(
"absolute z-20 flex flex-col overflow-hidden px-0 transition-all duration-300 ease-in-out",
{
"max-h-[80vh] w-[574px] opacity-100": isOpen,
"max-h-[80vh] opacity-100": isOpen,
"w-[574px]": isOpen,
"h-0 w-0 opacity-0": !isOpen,
"-top-[6px] left-1/2 -translate-x-1/2 rounded-md border border-subtle bg-surface-1 shadow-lg pt-10":
true,
"-top-[6px] left-1/2 -translate-x-1/2 rounded-md border border-subtle bg-surface-1 shadow-lg pt-10": true,
}
)}
>
@@ -9,6 +9,7 @@ import { observer } from "mobx-react";
import { ThemeSwitcher } from "@/plane-web/components/preferences/theme-switcher";
// local imports
import { ProfileSettingsAccentColor } from "./accent-color";
import { ProfileSettingsToolbarLayout } from "./toolbar-layout";
export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSettingsDefaultPreferencesList() {
return (
@@ -21,6 +22,7 @@ export const ProfileSettingsDefaultPreferencesList = observer(function ProfileSe
}}
/>
<ProfileSettingsAccentColor />
<ProfileSettingsToolbarLayout />
</div>
);
});
@@ -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>
}
/>
);
});
@@ -866,7 +866,10 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
if (typeof document === "undefined") return;
const updateDockSlot = () => {
setDockSlot(document.querySelector("[data-nodedc-voice-task-dock-slot]"));
setDockSlot(
document.querySelector("[data-nodedc-voice-task-toolbar-slot]") ??
document.querySelector("[data-nodedc-voice-task-dock-slot]")
);
};
updateDockSlot();
@@ -24,13 +24,13 @@ export const WorkspaceLogo = observer(function WorkspaceLogo(props: Props) {
className={cn(
`relative grid h-6 w-6 flex-shrink-0 place-items-center uppercase ${
!props.logo && "rounded-md bg-accent-primary text-on-color"
} ${props.classNames ? props.classNames : ""}`
} ${props.logo && "rounded-md"} ${props.classNames ? props.classNames : ""}`
)}
>
{props.logo && props.logo !== "" ? (
<img
src={getFileURL(props.logo)}
className="absolute top-0 left-0 h-full w-full rounded-md object-cover"
className="absolute top-0 left-0 h-full w-full rounded-[inherit] object-cover"
alt={t("aria_labels.projects_sidebar.workspace_logo")}
/>
) : (
@@ -6,8 +6,9 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Settings, UserPlus } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import type { MouseEvent } from "react";
import { Archive, BarChart3, Layers3, Settings, UserPlus } from "lucide-react";
import { Menu } from "@headlessui/react";
// plane imports
import { EUserPermissions } from "@plane/constants";
@@ -31,8 +32,18 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
const { workspace, activeWorkspace, handleItemClick, handleWorkspaceNavigation, handleClose } = props;
// router
const { workspaceSlug } = useParams();
const router = useRouter();
// hooks
const { t } = useTranslation();
const canOpenWorkspaceSettings = [EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role);
const canInviteMembers = [EUserPermissions.ADMIN].includes(workspace?.role);
const handleWorkspaceAction = (e: MouseEvent<HTMLButtonElement>, action: () => void) => {
e.preventDefault();
e.stopPropagation();
action();
handleClose();
};
return (
<Link
@@ -92,36 +103,56 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
</div>
{workspace.id === activeWorkspace?.id && (
<>
<div className="mt-2 mb-1 grid grid-cols-2 gap-3">
{[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && (
<div className="mt-2 mb-1 flex flex-col gap-1.5">
{canOpenWorkspaceSettings && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openWorkspaceSettingsModal("general");
handleClose();
}}
className="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[1.25rem] border-0 bg-white/[0.05] px-5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
onClick={(e) => handleWorkspaceAction(e, () => openWorkspaceSettingsModal("general"))}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[1.1rem] border-0 bg-white/[0.05] px-3.5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<Settings className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">{t("settings")}</span>
</button>
)}
{[EUserPermissions.ADMIN].includes(workspace?.role) && (
<Link
href={`/${workspace.slug}/settings/members`}
onClick={(e) => {
e.stopPropagation();
handleClose();
}}
className="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[1.25rem] border-0 bg-white/[0.05] px-5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
{canInviteMembers && (
<button
type="button"
onClick={(e) => handleWorkspaceAction(e, () => openWorkspaceSettingsModal("members"))}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[1.1rem] border-0 bg-white/[0.05] px-3.5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<UserPlus className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">
{t("project_settings.members.invite_members.title")}
</span>
</Link>
</button>
)}
{canOpenWorkspaceSettings && (
<button
type="button"
onClick={(e) => handleWorkspaceAction(e, () => router.push(`/${workspace.slug}/analytics/`))}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[1.1rem] border-0 bg-white/[0.05] px-3.5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<BarChart3 className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">Analytics</span>
</button>
)}
<button
type="button"
onClick={(e) => handleWorkspaceAction(e, () => router.push(`/${workspace.slug}/workspace-views/all-issues/`))}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[1.1rem] border-0 bg-white/[0.05] px-3.5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<Layers3 className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">Представления</span>
</button>
{canOpenWorkspaceSettings && (
<button
type="button"
onClick={(e) => handleWorkspaceAction(e, () => router.push(`/${workspace.slug}/projects/archives`))}
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[1.1rem] border-0 bg-white/[0.05] px-3.5 py-2.5 text-secondary shadow-none outline-none transition-colors hover:bg-white/[0.09] hover:text-primary"
>
<Archive className="my-auto h-4 w-4 flex-shrink-0" />
<span className="my-auto text-13 font-medium whitespace-nowrap">{t("archives")}</span>
</button>
)}
</div>
</>
@@ -22,7 +22,7 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useUser } from "@/hooks/store/user";
type TUserMenuRootProps = {
variant?: "default" | "sidebar-utility" | "toolbar";
variant?: "default" | "sidebar-utility" | "toolbar" | "expanded-toolbar";
};
export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootProps) {
@@ -43,6 +43,7 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
const isSidebarUtilityVariant = variant === "sidebar-utility";
const isToolbarVariant = variant === "toolbar";
const isExpandedToolbarVariant = variant === "expanded-toolbar";
const handleSignOut = () => {
signOut().catch(() =>
@@ -137,16 +138,18 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
className="flex items-center"
buttonAsChild
button={
isToolbarVariant ? (
isToolbarVariant || isExpandedToolbarVariant ? (
<button
type="button"
aria-label={t("profile")}
className="flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07]"
className={`flex items-center justify-center overflow-hidden rounded-full border-0 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07] ${
isExpandedToolbarVariant ? "nodedc-expanded-user-avatar-button size-12" : "size-8"
}`}
>
<Avatar
name={currentUser?.display_name}
src={getFileURL(currentUser?.avatar_url ?? "")}
size={18}
size={isExpandedToolbarVariant ? 48 : 18}
shape="circle"
/>
</button>
@@ -31,7 +31,7 @@ import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item";
type WorkspaceMenuRootProps = {
variant: "sidebar" | "top-navigation" | "sidebar-panel" | "toolbar";
variant: "sidebar" | "top-navigation" | "sidebar-panel" | "toolbar" | "expanded-toolbar";
};
type WorkspaceMenuStateSyncProps = {
@@ -46,7 +46,12 @@ function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) {
const { open, variant, sidebarPanelButtonRef, onSidebarDropdownToggle, onSidebarPanelPositionChange } = props;
const updateSidebarPanelMenuPosition = useCallback(() => {
if (!["sidebar-panel", "toolbar"].includes(variant) || !sidebarPanelButtonRef.current || typeof window === "undefined") return;
if (
!["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant) ||
!sidebarPanelButtonRef.current ||
typeof window === "undefined"
)
return;
const rect = sidebarPanelButtonRef.current.getBoundingClientRect();
const width = 480;
@@ -64,7 +69,7 @@ function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) {
}, [onSidebarDropdownToggle, open]);
useLayoutEffect(() => {
if (!open || !["sidebar-panel", "toolbar"].includes(variant)) {
if (!open || !["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant)) {
onSidebarPanelPositionChange(null);
return;
}
@@ -133,7 +138,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
"w-full justify-center text-center": variant === "sidebar",
"flex-grow justify-stretch text-left": variant === "top-navigation",
"w-full max-w-none justify-stretch text-left": variant === "sidebar-panel",
"w-fit max-w-none justify-center text-center": variant === "toolbar",
"w-fit max-w-none justify-center text-center": ["toolbar", "expanded-toolbar"].includes(variant),
})}
>
{({ open, close }: { open: boolean; close: () => void }) => {
@@ -221,11 +226,12 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
/>
</Menu.Button>
)}
{variant === "toolbar" && (
{["toolbar", "expanded-toolbar"].includes(variant) && (
<Menu.Button
ref={sidebarPanelButtonRef}
className={cn(
"flex size-8 items-center justify-center rounded-full bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07] focus:outline-none",
"flex items-center justify-center rounded-full bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07] focus:outline-none",
variant === "expanded-toolbar" ? "size-12" : "size-8",
{
"bg-white/[0.08]": open,
}
@@ -235,7 +241,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
<WorkspaceLogo
logo={activeWorkspace?.logo_url}
name={activeWorkspace?.name}
classNames="size-8 rounded-[0.9rem]"
classNames={variant === "expanded-toolbar" ? "size-12 rounded-full" : "size-8 rounded-[0.9rem]"}
/>
</Menu.Button>
)}
@@ -247,15 +253,15 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
"z-21 mt-1 flex min-w-[30rem] origin-top-left flex-col divide-y overflow-hidden outline-none",
{
"fixed divide-subtle rounded-md border-[0.5px] border-strong bg-surface-1 shadow-raised-200":
!["sidebar-panel", "toolbar"].includes(variant),
!["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant),
"top-11 left-14": variant === "sidebar",
"top-10 left-4": variant === "top-navigation",
"nodedc-glass-modal nodedc-glass-popup-surface rounded-[1.5rem] divide-white/10":
["sidebar-panel", "toolbar"].includes(variant),
["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant),
}
)}
style={
["sidebar-panel", "toolbar"].includes(variant) && sidebarPanelMenuPosition
["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant) && sidebarPanelMenuPosition
? {
position: "fixed",
left: `${sidebarPanelMenuPosition.left}px`,
@@ -270,8 +276,8 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
className={cn(
"sticky top-0 z-21 h-full w-full flex-shrink-0 truncate px-4 pt-3 pb-1 text-left text-13 font-medium text-placeholder",
{
"rounded-md bg-surface-1": !["sidebar-panel", "toolbar"].includes(variant),
"bg-transparent": ["sidebar-panel", "toolbar"].includes(variant),
"rounded-md bg-surface-1": !["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant),
"bg-transparent": ["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant),
}
)}
>
@@ -343,7 +349,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
</Menu.Items>
);
if (["sidebar-panel", "toolbar"].includes(variant)) {
if (["sidebar-panel", "toolbar", "expanded-toolbar"].includes(variant)) {
if (!open || !sidebarPanelMenuPosition || typeof document === "undefined") return null;
return createPortal(menuItems, document.body);
}