UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: расширенный home layout и аналитические панели
This commit is contained in:
@@ -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,
|
||||
}
|
||||
)}
|
||||
>
|
||||
|
||||
+2
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
+84
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user