UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: новый каркас стартовой страницы workspace

This commit is contained in:
DCCONSTRUCTIONS 2026-04-23 11:23:29 +03:00
parent 42caa1471e
commit c18fa2b7e9
10 changed files with 1237 additions and 129 deletions

View File

@ -4,6 +4,37 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { SlidersHorizontal } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { useHome } from "@/hooks/store/use-home";
import { useWorkspace } from "@/hooks/store/use-workspace";
export function HomePageHeader() { export function HomePageHeader() {
return <></>; const { t } = useTranslation();
const { toggleWidgetSettings } = useHome();
const { currentWorkspace } = useWorkspace();
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<div className="inline-flex w-fit items-center gap-2 rounded-full bg-white/6 px-3 py-1.5 text-[11px] font-semibold tracking-[0.22em] text-placeholder uppercase">
<span>Workspace Home</span>
</div>
<div className="text-13 text-secondary">
{currentWorkspace?.name ? `Стартовый экран для ${currentWorkspace.name}` : "Главная страница workspace"}
</div>
</div>
<Button
variant="secondary"
size="lg"
className="nodedc-toolbar-pill"
prependIcon={<SlidersHorizontal className="size-4" />}
onClick={() => toggleWidgetSettings(true)}
>
{t("home.manage_widgets")}
</Button>
</div>
);
} }

View File

@ -0,0 +1,42 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { cn } from "@plane/utils";
type HomeCardShellProps = {
title?: string;
eyebrow?: string;
description?: string;
action?: ReactNode;
children: ReactNode;
className?: string;
contentClassName?: string;
tone?: "default" | "accent";
};
export function HomeCardShell(props: HomeCardShellProps) {
const { title, eyebrow, description, action, children, className, contentClassName, tone = "default" } = props;
return (
<section className={cn("nodedc-home-card", className)} data-tone={tone}>
{(title || eyebrow || description || action) && (
<div className="relative z-[1] flex flex-wrap items-start justify-between gap-3 border-b border-white/6 px-5 py-5">
<div className="min-w-0 space-y-1">
{eyebrow && (
<div className="text-[11px] font-semibold tracking-[0.22em] text-placeholder uppercase">{eyebrow}</div>
)}
{title && <h3 className="text-18 font-semibold text-primary">{title}</h3>}
{description && <p className="max-w-2xl text-13 leading-5 text-secondary">{description}</p>}
</div>
{action && <div className="relative z-[1] flex items-center gap-2">{action}</div>}
</div>
)}
<div className={cn("relative z-[1]", contentClassName ?? "p-5")}>{children}</div>
</section>
);
}

View File

@ -4,12 +4,15 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useEffect, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import useSWR from "swr";
// plane imports // plane imports
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import type { THomeWidgetKeys, THomeWidgetProps } from "@plane/types"; import type { IUser, THomeWidgetKeys, THomeWidgetProps, TProjectAnalyticsCount } from "@plane/types";
import { cn } from "@plane/utils";
// assets // assets
import darkWidgetsAsset from "@/app/assets/empty-state/dashboard/widgets-dark.webp?url"; import darkWidgetsAsset from "@/app/assets/empty-state/dashboard/widgets-dark.webp?url";
import lightWidgetsAsset from "@/app/assets/empty-state/dashboard/widgets-light.webp?url"; import lightWidgetsAsset from "@/app/assets/empty-state/dashboard/widgets-light.webp?url";
@ -18,13 +21,24 @@ import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-ro
// hooks // hooks
import { useHome } from "@/hooks/store/use-home"; import { useHome } from "@/hooks/store/use-home";
import { useProject } from "@/hooks/store/use-project"; import { useProject } from "@/hooks/store/use-project";
import { useWorkspace } from "@/hooks/store/use-workspace";
// plane web components // plane web components
import { HomePageHeader } from "@/plane-web/components/home/header"; import { HomePageHeader } from "@/plane-web/components/home/header";
import { ProjectService } from "@/services/project";
import { WorkspaceService } from "@/services/workspace.service";
// local imports // local imports
import { HomeCardShell } from "./home-card-shell";
import { HomeProjectInsights } from "./home-project-insights";
import { HomeProjectStack } from "./home-project-stack";
import { aggregateProjectAnalytics, type THomeProjectData } from "./home.utils";
import { StickiesWidget } from "../stickies/widget"; import { StickiesWidget } from "../stickies/widget";
import { HomeLoader, NoProjectsEmptyState, RecentActivityWidget } from "./widgets"; import { HomeLoader, NoProjectsEmptyState, RecentActivityWidget } from "./widgets";
import { DashboardQuickLinks } from "./widgets/links"; import { DashboardQuickLinks } from "./widgets/links";
import { ManageWidgetsModal } from "./widgets/manage"; import { ManageWidgetsModal } from "./widgets/manage";
import { UserGreetingsView } from "./user-greetings";
const projectService = new ProjectService();
const workspaceService = new WorkspaceService();
export const HOME_WIDGETS_LIST: { export const HOME_WIDGETS_LIST: {
[key in THomeWidgetKeys]: { [key in THomeWidgetKeys]: {
@ -60,59 +74,205 @@ export const HOME_WIDGETS_LIST: {
}, },
}; };
export const DashboardWidgets = observer(function DashboardWidgets() { type DashboardWidgetsProps = {
currentUser?: IUser;
};
export const DashboardWidgets = observer(function DashboardWidgets(props: DashboardWidgetsProps) {
const { currentUser } = props;
// router // router
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
const workspaceSlugValue = Array.isArray(workspaceSlug) ? workspaceSlug[0] : workspaceSlug?.toString();
// navigation // navigation
const pathname = usePathname(); const pathname = usePathname();
// theme hook // theme hook
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
// store hooks // store hooks
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets, isAnyWidgetEnabled, loading } = const { toggleWidgetSettings, widgetsMap, showWidgetSettings, loading } = useHome();
useHome(); const { loader, joinedProjectIds, getPartialProjectById, fetchProjectAnalyticsCount, getProjectAnalyticsCountById } =
const { loader } = useProject(); useProject();
const { currentWorkspace } = useWorkspace();
// plane hooks // plane hooks
const { t } = useTranslation(); const { t, currentLocale } = useTranslation();
// states
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
// derived values // derived values
const noWidgetsResolvedPath = resolvedTheme === "light" ? lightWidgetsAsset : darkWidgetsAsset; const noWidgetsResolvedPath = resolvedTheme === "light" ? lightWidgetsAsset : darkWidgetsAsset;
// derived values // derived values
const isWikiApp = pathname.includes(`/${workspaceSlug.toString()}/pages`); const isWikiApp = workspaceSlugValue ? pathname.includes(`/${workspaceSlugValue}/pages`) : false;
if (!workspaceSlug) return null;
const projectIds = joinedProjectIds ?? [];
const { data: detailedProjects } = useSWR(
workspaceSlugValue && projectIds.length > 0 ? `HOME_PROJECT_DETAILS_${workspaceSlugValue}` : null,
() => projectService.getProjects(workspaceSlugValue!),
{
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
useSWR(
workspaceSlugValue && projectIds.length > 0
? `HOME_PROJECT_STATS_${workspaceSlugValue}_${projectIds.join(",")}`
: null,
() =>
fetchProjectAnalyticsCount(workspaceSlugValue!, {
project_ids: projectIds.join(","),
fields: "total_issues,completed_issues,total_members,total_cycles,total_modules",
}),
{
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
const { data: workspaceRecents } = useSWR(
workspaceSlugValue ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlugValue}_all item` : null,
() => workspaceService.fetchWorkspaceRecents(workspaceSlugValue!),
{
revalidateIfStale: false,
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
const detailedProjectsMap = new Map((detailedProjects ?? []).map((project) => [project.id, project]));
const homeProjects = projectIds
.map(
(projectId) =>
(detailedProjectsMap.get(projectId) ?? getPartialProjectById(projectId)) as THomeProjectData | undefined
)
.filter((project): project is THomeProjectData => !!project);
useEffect(() => {
if (homeProjects.length === 0) {
if (selectedProjectId !== null) setSelectedProjectId(null);
return;
}
if (!selectedProjectId || !homeProjects.some((project) => project.id === selectedProjectId)) {
setSelectedProjectId(homeProjects[0].id);
}
}, [homeProjects, selectedProjectId]);
const analyticsMap = projectIds.reduce<Record<string, TProjectAnalyticsCount | undefined>>((acc, projectId) => {
acc[projectId] = getProjectAnalyticsCountById(projectId);
return acc;
}, {});
const analyticsCollection = projectIds
.map((projectId) => analyticsMap[projectId])
.filter((item): item is TProjectAnalyticsCount => !!item);
const selectedProject = homeProjects.find((project) => project.id === selectedProjectId);
const selectedProjectAnalytics =
(selectedProjectId ? analyticsMap[selectedProjectId] : undefined) ?? aggregateProjectAnalytics(analyticsCollection);
const isRecentsEnabled = !!widgetsMap.recents?.is_enabled;
const isQuickLinksEnabled = !!widgetsMap.quick_links?.is_enabled;
const isStickiesEnabled = !!widgetsMap.my_stickies?.is_enabled;
const hasDashboardContent = isRecentsEnabled || isQuickLinksEnabled || isStickiesEnabled;
if (!workspaceSlugValue) return null;
if (loading || loader !== "loaded") return <HomeLoader />; if (loading || loader !== "loaded") return <HomeLoader />;
const recentsCard = isRecentsEnabled ? (
<HomeCardShell className="overflow-hidden" contentClassName="p-5">
<RecentActivityWidget
workspaceSlug={workspaceSlugValue}
recents={workspaceRecents}
projectId={selectedProjectId}
/>
</HomeCardShell>
) : null;
const sideWidgetCards = [
isQuickLinksEnabled ? (
<HomeCardShell key="quick_links" className="overflow-hidden" contentClassName="p-5">
<DashboardQuickLinks workspaceSlug={workspaceSlugValue} />
</HomeCardShell>
) : null,
isStickiesEnabled ? (
<HomeCardShell key="my_stickies" className="overflow-hidden" contentClassName="p-5">
<StickiesWidget />
</HomeCardShell>
) : null,
].filter(Boolean);
return ( return (
<div className="relative flex h-full w-full flex-col gap-7"> <div className="relative flex h-full w-full flex-col gap-6">
<HomePageHeader /> <HomePageHeader />
<ManageWidgetsModal <ManageWidgetsModal
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlugValue}
isModalOpen={showWidgetSettings} isModalOpen={showWidgetSettings}
handleOnClose={() => toggleWidgetSettings(false)} handleOnClose={() => toggleWidgetSettings(false)}
/> />
<div className="grid gap-5 xl:grid-cols-[340px_minmax(0,1fr)]">
<div className="min-w-0">
<HomeProjectStack
projects={homeProjects}
analyticsMap={analyticsMap}
recents={workspaceRecents}
selectedProjectId={selectedProjectId}
onSelectProject={setSelectedProjectId}
workspaceSlug={workspaceSlugValue}
/>
</div>
<div className="min-w-0 space-y-5">
{currentUser && (
<UserGreetingsView
user={currentUser}
workspaceName={currentWorkspace?.name}
selectedProject={selectedProject}
selectedProjectAnalytics={selectedProjectAnalytics}
/>
)}
<HomeProjectInsights
project={selectedProject}
analytics={selectedProjectAnalytics}
analyticsCollection={analyticsCollection}
recents={workspaceRecents}
locale={currentLocale || "ru-RU"}
/>
{!isWikiApp && <NoProjectsEmptyState />} {!isWikiApp && <NoProjectsEmptyState />}
{isAnyWidgetEnabled ? ( {hasDashboardContent ? (
<div className="flex flex-col gap-2"> <>
{orderedWidgets.map((key) => { {recentsCard && sideWidgetCards.length > 0 ? (
const WidgetComponent = HOME_WIDGETS_LIST[key]?.component; <div className="grid gap-5 xl:grid-cols-[minmax(0,1.08fr)_minmax(320px,0.92fr)]">
const isEnabled = widgetsMap[key]?.is_enabled; {recentsCard}
if (!WidgetComponent || !isEnabled) return null; <div className="grid min-w-0 gap-5">{sideWidgetCards}</div>
return (
<div key={key} className="py-3">
<WidgetComponent workspaceSlug={workspaceSlug.toString()} />
</div>
);
})}
</div> </div>
) : recentsCard ? (
recentsCard
) : ( ) : (
<div className="grid h-full w-full place-items-center"> <div
className={cn("grid gap-5", {
"md:grid-cols-2": sideWidgetCards.length > 1,
})}
>
{sideWidgetCards}
</div>
)}
</>
) : (
<HomeCardShell className="overflow-hidden" contentClassName="p-8">
<div className="grid min-h-[260px] place-items-center">
<SimpleEmptyState <SimpleEmptyState
title={t("home.empty.widgets.title")} title={t("home.empty.widgets.title")}
description={t("home.empty.widgets.description")} description={t("home.empty.widgets.description")}
assetPath={noWidgetsResolvedPath} assetPath={noWidgetsResolvedPath}
/> />
</div> </div>
</HomeCardShell>
)} )}
</div> </div>
</div>
</div>
); );
}); });

View File

@ -0,0 +1,367 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useId, useMemo } from "react";
import { Activity, CheckCircle2, Layers3, UsersRound } from "lucide-react";
import type { TActivityEntityData, TProjectAnalyticsCount } from "@plane/types";
import { cn } from "@plane/utils";
import { HomeCardShell } from "./home-card-shell";
import {
aggregateProjectAnalytics,
getActivityProjectId,
getCompletionRate,
type THomeProjectData,
} from "./home.utils";
type HomeProjectInsightsProps = {
project?: THomeProjectData;
analytics?: TProjectAnalyticsCount;
analyticsCollection?: TProjectAnalyticsCount[];
recents?: TActivityEntityData[];
locale: string;
};
type TActivityPoint = {
key: string;
label: string;
value: number;
};
const formatCompactNumber = (value: number) => new Intl.NumberFormat("ru-RU", { notation: "compact" }).format(value);
const buildChartPaths = (data: TActivityPoint[]) => {
const width = 420;
const height = 180;
const paddingX = 10;
const paddingY = 18;
const maxValue = Math.max(...data.map((item) => item.value), 1);
const stepX = data.length > 1 ? (width - paddingX * 2) / (data.length - 1) : 0;
const points = data.map((item, index) => {
const x = paddingX + index * stepX;
const y = height - paddingY - (item.value / maxValue) * (height - paddingY * 2);
return { x, y };
});
const linePath = points
.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`)
.join(" ");
const areaPath = `${linePath} L ${(points[points.length - 1]?.x ?? paddingX).toFixed(2)} ${(
height - paddingY
).toFixed(2)} L ${(points[0]?.x ?? paddingX).toFixed(2)} ${(height - paddingY).toFixed(2)} Z`;
return { width, height, paddingY, points, areaPath, linePath, maxValue };
};
export function HomeProjectInsights(props: HomeProjectInsightsProps) {
const { project, analytics, analyticsCollection, recents, locale } = props;
const chartId = useId();
const resolvedAnalytics = analytics ?? aggregateProjectAnalytics(analyticsCollection);
const totalIssues = resolvedAnalytics?.total_issues ?? 0;
const completedIssues = resolvedAnalytics?.completed_issues ?? 0;
const openIssues = Math.max(totalIssues - completedIssues, 0);
const completionRate = getCompletionRate(resolvedAnalytics);
const activitySeries = useMemo<TActivityPoint[]>(() => {
const formatter = new Intl.DateTimeFormat(locale || "ru-RU", {
weekday: "short",
day: "numeric",
});
const series = Array.from({ length: 7 }, (_, index) => {
const date = new Date();
date.setDate(date.getDate() - (6 - index));
const key = date.toISOString().slice(0, 10);
return {
key,
label: formatter.format(date),
value: 0,
};
});
for (const activity of recents ?? []) {
const projectId = getActivityProjectId(activity);
if (project && projectId !== project.id) continue;
if (!project && projectId === null && activity.entity_name === "workspace_page") continue;
const activityKey = new Date(activity.visited_at).toISOString().slice(0, 10);
const matchingPoint = series.find((point) => point.key === activityKey);
if (matchingPoint) matchingPoint.value += 1;
}
return series;
}, [locale, project, recents]);
const chart = buildChartPaths(activitySeries);
const recentTouchpoints = activitySeries.reduce((sum, item) => sum + item.value, 0);
const benchmark = {
members: Math.max(
...(analyticsCollection ?? []).map((item) => item.total_members ?? 0),
resolvedAnalytics?.total_members ?? 0,
1
),
cycles: Math.max(
...(analyticsCollection ?? []).map((item) => item.total_cycles ?? 0),
resolvedAnalytics?.total_cycles ?? 0,
1
),
modules: Math.max(
...(analyticsCollection ?? []).map((item) => item.total_modules ?? 0),
resolvedAnalytics?.total_modules ?? 0,
1
),
};
const metricCards = [
{
label: "Готовность",
value: `${completionRate}%`,
caption: `${completedIssues} из ${totalIssues || 0} закрыто`,
icon: <CheckCircle2 className="size-4" />,
accent: true,
},
{
label: "Открытые задачи",
value: formatCompactNumber(openIssues),
caption: "Текущая незакрытая нагрузка",
icon: <Layers3 className="size-4" />,
},
{
label: "Касания за 7 дней",
value: formatCompactNumber(recentTouchpoints),
caption: "Recent activity по этому фокусу",
icon: <Activity className="size-4" />,
},
];
const progressRows = [
{
label: "Команда",
value: resolvedAnalytics?.total_members ?? 0,
max: benchmark.members,
},
{
label: "Циклы",
value: resolvedAnalytics?.total_cycles ?? 0,
max: benchmark.cycles,
},
{
label: "Модули",
value: resolvedAnalytics?.total_modules ?? 0,
max: benchmark.modules,
},
];
return (
<HomeCardShell
eyebrow={project ? "Фокус проекта" : "Workspace overview"}
title={project ? project.name : "Координационный обзор workspace"}
description={
project
? `${project.identifier} ${project.description ? `${project.description}` : "• домашняя сводка проекта в одном экране"}`
: "Агрегированный обзор по текущим проектам, recent activity и операционной нагрузке."
}
tone="default"
>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.2fr)_minmax(260px,0.8fr)]">
<div className="space-y-5">
<div className="grid gap-3 md:grid-cols-3">
{metricCards.map((metric) => (
<div
key={metric.label}
className={cn("nodedc-home-metric-card", { "nodedc-home-metric-card-accent": metric.accent })}
>
<div className="flex items-center justify-between gap-3">
<div className="text-12 font-medium text-secondary">{metric.label}</div>
<div className="text-[rgb(var(--nodedc-accent-rgb))]">{metric.icon}</div>
</div>
<div className="mt-4 text-28 font-semibold text-primary">{metric.value}</div>
<div className="mt-1 text-12 text-secondary">{metric.caption}</div>
</div>
))}
</div>
<div className="nodedc-home-chart-panel">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<div className="text-14 font-semibold text-primary">Темп активности</div>
<div className="text-12 text-secondary">Последние 7 дней переходов и взаимодействий внутри сводки.</div>
</div>
<div className="rounded-full bg-white/6 px-3 py-1.5 text-12 text-secondary">
{recentTouchpoints} событий
</div>
</div>
<div className="relative overflow-hidden rounded-[24px] border border-white/6 bg-black/12 p-4">
<div className="absolute inset-x-6 top-4 bottom-4 grid grid-cols-4 gap-4 opacity-25">
{["col-1", "col-2", "col-3", "col-4"].map((key) => (
<div key={key} className="border-r border-dashed border-white/8 last:border-r-0" />
))}
</div>
<svg
viewBox={`0 0 ${chart.width} ${chart.height}`}
className="relative z-[1] h-[180px] w-full"
preserveAspectRatio="none"
>
<defs>
<linearGradient id={`${chartId}-fill`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="rgba(var(--nodedc-accent-rgb),0.64)" />
<stop offset="100%" stopColor="rgba(var(--nodedc-accent-rgb),0.02)" />
</linearGradient>
</defs>
{activitySeries.map((point, index) => {
const x = chart.points[index]?.x ?? 0;
return (
<line
key={point.key}
x1={x}
x2={x}
y1={12}
y2={chart.height - chart.paddingY}
stroke="rgba(255,255,255,0.05)"
/>
);
})}
{[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={10}
x2={chart.width - 10}
y1={y}
y2={y}
stroke="rgba(255,255,255,0.05)"
strokeDasharray="4 6"
/>
);
})}
<path d={chart.areaPath} fill={`url(#${chartId}-fill)`} />
<path
d={chart.linePath}
fill="none"
stroke="rgb(var(--nodedc-accent-rgb))"
strokeWidth="4"
strokeLinecap="round"
/>
{activitySeries.map((activityPoint, index) => {
const point = chart.points[index];
if (!point) return null;
return (
<circle
key={activityPoint.key}
cx={point.x}
cy={point.y}
r="5"
fill="rgb(var(--nodedc-accent-rgb))"
stroke="rgba(9,9,12,0.8)"
strokeWidth="3"
/>
);
})}
</svg>
<div className="relative z-[1] mt-4 grid grid-cols-7 gap-2">
{activitySeries.map((point) => (
<div key={point.key} className="rounded-2xl bg-white/4 px-2 py-2 text-center">
<div className="text-[11px] tracking-[0.14em] text-placeholder uppercase">{point.label}</div>
<div className="mt-1 text-13 font-semibold text-primary">{point.value}</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div className="rounded-[28px] border border-white/6 bg-[rgba(var(--nodedc-accent-rgb),0.08)] p-5">
<div className="flex items-center gap-3">
<div className="grid size-11 place-items-center rounded-2xl bg-[rgba(var(--nodedc-accent-rgb),0.18)] text-[rgb(var(--nodedc-accent-rgb))]">
<UsersRound className="size-5" />
</div>
<div>
<div className="text-13 font-semibold text-primary">Операционный срез</div>
<div className="text-12 text-secondary">
Нагрузка команды, циклов и модулей относительно остального workspace.
</div>
</div>
</div>
<div className="mt-5 space-y-4">
{progressRows.map((row) => {
const percent = row.max > 0 ? Math.max((row.value / row.max) * 100, row.value > 0 ? 10 : 0) : 0;
return (
<div key={row.label} className="space-y-2">
<div className="flex items-center justify-between gap-3 text-12">
<span className="text-secondary">{row.label}</span>
<span className="font-semibold text-primary">{row.value}</span>
</div>
<div className="nodedc-home-progress-track">
<div className="nodedc-home-progress-fill" style={{ width: `${Math.min(percent, 100)}%` }} />
</div>
</div>
);
})}
</div>
</div>
<div className="rounded-[28px] border border-white/6 bg-black/12 p-5">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-13 font-semibold text-primary">Ритм исполнения</div>
<div className="text-12 text-secondary">Сколько уже закрыто и какой объём ещё держим открытым.</div>
</div>
<div className="rounded-full bg-white/6 px-3 py-1.5 text-12 text-secondary">{completionRate}%</div>
</div>
<div className="mt-5 space-y-4">
<div className="rounded-[22px] bg-white/4 p-4">
<div className="flex items-center justify-between gap-3 text-12">
<span className="text-secondary">Закрытые задачи</span>
<span className="font-semibold text-primary">{completedIssues}</span>
</div>
<div className="nodedc-home-progress-track mt-3">
<div
className="nodedc-home-progress-fill"
style={{ width: `${totalIssues > 0 ? (completedIssues / totalIssues) * 100 : 0}%` }}
/>
</div>
</div>
<div className="rounded-[22px] bg-white/4 p-4">
<div className="flex items-center justify-between gap-3 text-12">
<span className="text-secondary">Открытый остаток</span>
<span className="font-semibold text-primary">{openIssues}</span>
</div>
<div className="nodedc-home-progress-track mt-3">
<div
className="rounded-full bg-white/16"
style={{
width: `${totalIssues > 0 ? (openIssues / totalIssues) * 100 : 0}%`,
height: "100%",
}}
/>
</div>
</div>
<div className="rounded-[22px] border border-dashed border-white/8 bg-white/3 p-4 text-12 text-secondary">
<span className="font-semibold text-primary">{project ? project.identifier : "Workspace"}</span>
<span> держит </span>
<span className="font-semibold text-primary">{totalIssues}</span>
<span> задач в общей матрице и </span>
<span className="font-semibold text-primary">{recentTouchpoints}</span>
<span> недавних касаний за неделю.</span>
</div>
</div>
</div>
</div>
</div>
</HomeCardShell>
);
}

View File

@ -0,0 +1,233 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import Link from "next/link";
import { ArrowUpRight, FolderOpenDot, Layers3, UsersRound } from "lucide-react";
import type { TActivityEntityData, TProjectAnalyticsCount } from "@plane/types";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { cn } from "@plane/utils";
import { CoverImage } from "@/components/common/cover-image";
import { HomeCardShell } from "./home-card-shell";
import { getActivityProjectId, getCompletionRate, type THomeProjectData } from "./home.utils";
type HomeProjectStackProps = {
projects: THomeProjectData[];
analyticsMap: Record<string, TProjectAnalyticsCount | undefined>;
recents?: TActivityEntityData[];
selectedProjectId: string | null;
onSelectProject: (projectId: string) => void;
workspaceSlug: string;
};
const STACK_VISIBLE_LIMIT = 4;
const ACTIVE_CARD_HEIGHT = 228;
const STACK_OFFSET = 76;
export function HomeProjectStack(props: HomeProjectStackProps) {
const { projects, analyticsMap, recents, selectedProjectId, onSelectProject, workspaceSlug } = props;
const activeProject = projects.find((project: THomeProjectData) => project.id === selectedProjectId);
const orderedProjects = activeProject
? [activeProject, ...projects.filter((project: THomeProjectData) => project.id !== activeProject.id)]
: projects;
const visibleProjects = orderedProjects.slice(0, STACK_VISIBLE_LIMIT);
const activityCountByProject = (recents ?? []).reduce<Record<string, number>>((acc, activity) => {
const projectId = getActivityProjectId(activity);
if (!projectId) return acc;
acc[projectId] = (acc[projectId] ?? 0) + 1;
return acc;
}, {});
const selectedProject =
orderedProjects.find((project: THomeProjectData) => project.id === selectedProjectId) ?? orderedProjects[0];
const selectedProjectPath = selectedProject ? `/${workspaceSlug}/projects/${selectedProject.id}/issues` : null;
const stackHeight =
visibleProjects.length > 0 ? ACTIVE_CARD_HEIGHT + (visibleProjects.length - 1) * STACK_OFFSET : 228;
if (projects.length === 0) {
return (
<HomeCardShell
eyebrow="Workspace"
title="Доступные проекты"
description="Когда проекты появятся в workspace, здесь появится интерактивный стек для быстрого переключения домашней сводки."
tone="accent"
>
<div className="rounded-[26px] border border-white/8 bg-black/10 p-5">
<div className="flex items-center gap-3">
<div className="grid size-12 place-items-center rounded-2xl bg-[rgba(var(--nodedc-accent-rgb),0.18)] text-[rgb(var(--nodedc-accent-rgb))]">
<FolderOpenDot className="size-5" />
</div>
<div>
<div className="text-15 font-semibold text-primary">Пока нет проектов для сводки</div>
<div className="text-13 text-secondary">
Откройте quickstart ниже и создайте первый проект для этой панели.
</div>
</div>
</div>
</div>
</HomeCardShell>
);
}
return (
<HomeCardShell
eyebrow="Workspace"
title="Доступные проекты"
description="Нажатие на карточку проекта перестраивает домашнюю сводку, recent activity и аналитический фокус справа."
tone="accent"
action={
selectedProjectPath ? (
<Link href={selectedProjectPath} className="nodedc-toolbar-pill inline-flex items-center gap-2">
<span>Открыть проект</span>
<ArrowUpRight className="size-4" />
</Link>
) : null
}
>
<div className="space-y-4">
<div className="relative" style={{ height: `${stackHeight}px` }}>
{visibleProjects.map((project: THomeProjectData, index: number) => {
const analytics = analyticsMap[project.id];
const completionRate = getCompletionRate(analytics);
const totalIssues = analytics?.total_issues ?? 0;
const completedIssues = analytics?.completed_issues ?? 0;
const activeItems = Math.max(totalIssues - completedIssues, 0);
const activityCount = activityCountByProject[project.id] ?? 0;
const isActive = project.id === selectedProject?.id;
return (
<button
key={project.id}
type="button"
className={cn("nodedc-home-project-card absolute inset-x-0 text-left", {
"cursor-default": isActive,
})}
data-active={isActive}
onClick={() => onSelectProject(project.id)}
style={{
top: `${index * STACK_OFFSET}px`,
zIndex: visibleProjects.length - index,
}}
>
<CoverImage
src={project.cover_image_url}
alt={project.name}
showDefaultWhenEmpty
className="absolute inset-0 h-full w-full"
/>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(var(--nodedc-accent-rgb),0.28),transparent_34%),linear-gradient(160deg,rgba(5,5,8,0.08)_0%,rgba(5,5,8,0.42)_34%,rgba(5,5,8,0.84)_100%)]" />
<div className="relative flex h-full flex-col justify-between p-4">
<div className="flex items-start justify-between gap-3">
<div className="inline-flex items-center gap-2 rounded-full bg-black/25 px-2.5 py-1 text-[11px] font-medium text-white/72 backdrop-blur-md">
<Logo logo={project.logo_props} size={14} />
<span>{project.identifier}</span>
</div>
<div
className={cn(
"rounded-full px-2.5 py-1 text-[11px] font-semibold backdrop-blur-md",
isActive
? "bg-[rgba(var(--nodedc-accent-rgb),0.82)] text-[rgb(var(--nodedc-on-card-active-rgb))]"
: "bg-white/12 text-white/72"
)}
>
{isActive ? "В фокусе" : `${completionRate}%`}
</div>
</div>
<div className="space-y-3">
<div className="space-y-1">
<div className="text-18 font-semibold text-white">{project.name}</div>
{project.description && isActive && (
<p className="line-clamp-2 max-w-[18rem] text-12 leading-5 text-white/72">
{project.description}
</p>
)}
</div>
<div className="grid grid-cols-3 gap-2">
<div className="rounded-2xl bg-black/24 px-3 py-2 backdrop-blur-md">
<div className="text-[11px] tracking-[0.18em] text-white/45 uppercase">Открыто</div>
<div className="text-15 mt-1 font-semibold text-white">{activeItems}</div>
</div>
<div className="rounded-2xl bg-black/24 px-3 py-2 backdrop-blur-md">
<div className="text-[11px] tracking-[0.18em] text-white/45 uppercase">Закрыто</div>
<div className="text-15 mt-1 font-semibold text-white">{completedIssues}</div>
</div>
<div className="rounded-2xl bg-black/24 px-3 py-2 backdrop-blur-md">
<div className="text-[11px] tracking-[0.18em] text-white/45 uppercase">Касания</div>
<div className="text-15 mt-1 font-semibold text-white">{activityCount}</div>
</div>
</div>
</div>
</div>
</button>
);
})}
</div>
<div className="rounded-[24px] border border-white/6 bg-black/10 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-13 font-semibold text-primary">Быстрый выбор</div>
<div className="text-12 text-secondary">Все проекты пользователя в текущем workspace.</div>
</div>
<div className="inline-flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5 text-12 text-secondary">
<Layers3 className="size-3.5" />
<span>{projects.length}</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
{orderedProjects.map((project: THomeProjectData) => {
const analytics = analyticsMap[project.id];
return (
<button
key={project.id}
type="button"
className={cn("nodedc-toolbar-pill inline-flex items-center gap-2", {
"!bg-[rgb(var(--nodedc-card-active-rgb))] !text-[rgb(var(--nodedc-on-card-active-rgb))]":
project.id === selectedProject?.id,
})}
onClick={() => onSelectProject(project.id)}
>
<Logo logo={project.logo_props} size={14} />
<span>{project.identifier}</span>
<span className="text-[11px] opacity-70">{getCompletionRate(analytics)}%</span>
</button>
);
})}
</div>
{selectedProject && (
<div className="mt-4 grid grid-cols-2 gap-3 rounded-[22px] bg-white/4 p-3 md:grid-cols-3">
<div className="rounded-2xl bg-black/10 px-3 py-2">
<div className="text-[11px] tracking-[0.18em] text-placeholder uppercase">Фокус</div>
<div className="mt-1 text-13 font-semibold text-primary">{selectedProject.identifier}</div>
</div>
<div className="rounded-2xl bg-black/10 px-3 py-2">
<div className="flex items-center gap-1 text-[11px] tracking-[0.18em] text-placeholder uppercase">
<UsersRound className="size-3.5" />
<span>Команда</span>
</div>
<div className="mt-1 text-13 font-semibold text-primary">
{analyticsMap[selectedProject.id]?.total_members ?? 0}
</div>
</div>
<div className="rounded-2xl bg-black/10 px-3 py-2">
<div className="text-[11px] tracking-[0.18em] text-placeholder uppercase">Контур</div>
<div className="mt-1 text-13 font-semibold text-primary">
{activityCountByProject[selectedProject.id] ?? 0} recent
</div>
</div>
</div>
)}
</div>
</div>
</HomeCardShell>
);
}

View File

@ -0,0 +1,68 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type {
IPartialProject,
IProject,
TActivityEntityData,
TIssueEntityData,
TPageEntityData,
TProjectAnalyticsCount,
TProjectEntityData,
} from "@plane/types";
export type THomeProjectData = Pick<IPartialProject, "id" | "name" | "identifier" | "logo_props" | "member_role"> &
Partial<Pick<IProject, "cover_image_url" | "description">>;
export const getActivityProjectId = (activity: TActivityEntityData): string | null => {
if (!activity?.entity_data) return null;
switch (activity.entity_name) {
case "project":
return (activity.entity_data as TProjectEntityData).id ?? null;
case "issue":
return (activity.entity_data as TIssueEntityData).project_id ?? null;
case "page":
case "workspace_page":
return (activity.entity_data as TPageEntityData).project_id ?? null;
default:
return null;
}
};
export const getCompletionRate = (analytics?: TProjectAnalyticsCount): number => {
const totalIssues = analytics?.total_issues ?? 0;
const completedIssues = analytics?.completed_issues ?? 0;
if (totalIssues === 0) return 0;
return Math.round((completedIssues / totalIssues) * 100);
};
export const aggregateProjectAnalytics = (
analytics: TProjectAnalyticsCount[] | undefined
): TProjectAnalyticsCount | undefined => {
if (!analytics || analytics.length === 0) return undefined;
return analytics.reduce<TProjectAnalyticsCount>(
(acc, item) => ({
id: "workspace-overview",
total_issues: (acc.total_issues ?? 0) + (item.total_issues ?? 0),
completed_issues: (acc.completed_issues ?? 0) + (item.completed_issues ?? 0),
total_members: (acc.total_members ?? 0) + (item.total_members ?? 0),
total_cycles: (acc.total_cycles ?? 0) + (item.total_cycles ?? 0),
total_modules: (acc.total_modules ?? 0) + (item.total_modules ?? 0),
}),
{
id: "workspace-overview",
total_issues: 0,
completed_issues: 0,
total_members: 0,
total_cycles: 0,
total_modules: 0,
}
);
};

View File

@ -17,7 +17,6 @@ import { HomePeekOverviewsRoot } from "@/plane-web/components/home";
import { TourRoot } from "@/plane-web/components/onboarding/tour/root"; import { TourRoot } from "@/plane-web/components/onboarding/tour/root";
// local imports // local imports
import { DashboardWidgets } from "./home-dashboard-widgets"; import { DashboardWidgets } from "./home-dashboard-widgets";
import { UserGreetingsView } from "./user-greetings";
// Temporary NodeDC toggle: keep product tour implementation in code, // Temporary NodeDC toggle: keep product tour implementation in code,
// but do not show it in the local PoC until the onboarding flow is revisited. // but do not show it in the local PoC until the onboarding flow is revisited.
@ -59,9 +58,8 @@ export const WorkspaceHomeView = observer(function WorkspaceHomeView() {
<> <>
<HomePeekOverviewsRoot /> <HomePeekOverviewsRoot />
<ContentWrapper className="mx-auto scrollbar-hide gap-6 bg-transparent px-page-x"> <ContentWrapper className="mx-auto scrollbar-hide gap-6 bg-transparent px-page-x">
<div className="nodedc-workspace-page-shell mx-auto w-full max-w-[980px]"> <div className="nodedc-workspace-page-shell mx-auto w-full max-w-[1480px]">
{currentUser && <UserGreetingsView user={currentUser} />} <DashboardWidgets currentUser={currentUser} />
<DashboardWidgets />
</div> </div>
</ContentWrapper> </ContentWrapper>
</> </>

View File

@ -6,17 +6,22 @@
// plane types // plane types
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import type { IUser } from "@plane/types"; import type { IUser, TProjectAnalyticsCount } from "@plane/types";
// plane ui // plane ui
// hooks // hooks
import { useCurrentTime } from "@/hooks/use-current-time"; import { useCurrentTime } from "@/hooks/use-current-time";
import { HomeCardShell } from "./home-card-shell";
import { getCompletionRate, type THomeProjectData } from "./home.utils";
export interface IUserGreetingsView { export interface IUserGreetingsView {
user: IUser; user: IUser;
workspaceName?: string | null;
selectedProject?: THomeProjectData;
selectedProjectAnalytics?: TProjectAnalyticsCount;
} }
export function UserGreetingsView(props: IUserGreetingsView) { export function UserGreetingsView(props: IUserGreetingsView) {
const { user } = props; const { user, workspaceName, selectedProject, selectedProjectAnalytics } = props;
// current time hook // current time hook
const { currentTime } = useCurrentTime(); const { currentTime } = useCurrentTime();
// store hooks // store hooks
@ -44,18 +49,52 @@ export function UserGreetingsView(props: IUserGreetingsView) {
}).format(currentTime); }).format(currentTime);
const greeting = parseInt(hour, 10) < 12 ? "morning" : parseInt(hour, 10) < 18 ? "afternoon" : "evening"; const greeting = parseInt(hour, 10) < 12 ? "morning" : parseInt(hour, 10) < 18 ? "afternoon" : "evening";
const completionRate = getCompletionRate(selectedProjectAnalytics);
return ( return (
<div className="my-6 flex flex-col items-center"> <HomeCardShell
<h2 className="text-center text-20 font-semibold"> tone="accent"
{t("good")} {t(greeting)}, {user?.first_name} {user?.last_name} eyebrow={workspaceName ?? "Workspace Home"}
</h2> title={`${t("good")} ${t(greeting)}, ${user?.first_name} ${user?.last_name}`}
<h5 className="flex items-center gap-2 font-medium text-placeholder"> description={`${weekDay}, ${date} ${timeString}`}
<div>{greeting === "morning" ? "🌤️" : greeting === "afternoon" ? "🌥️" : "🌙️"}</div> contentClassName="p-5"
<div> >
{weekDay}, {date} {timeString} <div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="rounded-[28px] border border-white/6 bg-black/10 p-4">
<div className="inline-flex items-center gap-2 rounded-full bg-white/8 px-3 py-1.5 text-12 text-secondary">
<span>{greeting === "morning" ? "🌤️" : greeting === "afternoon" ? "🌥️" : "🌙️"}</span>
<span>Главная панель workspace</span>
</div> </div>
</h5> <div className="mt-4 max-w-2xl text-13 leading-6 text-secondary">
Домашняя страница теперь собирает проектный фокус, recent activity, быстрые ссылки и стикеры в один рабочий
экран без переходов по разделам.
</div> </div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
<div className="rounded-[24px] border border-white/6 bg-black/10 p-4">
<div className="text-12 font-medium text-secondary">Текущий фокус</div>
<div className="mt-2 text-16 font-semibold text-primary">
{selectedProject ? selectedProject.name : "Выберите проект слева"}
</div>
<div className="mt-1 text-12 text-secondary">
{selectedProject ? selectedProject.identifier : "Домашняя сводка перестроится под выбранную карточку."}
</div>
</div>
<div className="rounded-[24px] border border-white/6 bg-black/10 p-4">
<div className="text-12 font-medium text-secondary">Прогресс фокуса</div>
<div className="mt-2 text-16 font-semibold text-primary">
{selectedProject ? `${completionRate}%` : "—"}
</div>
<div className="mt-1 text-12 text-secondary">
{selectedProject
? "Закрытые задачи относительно общего объёма."
: "Станет доступен после выбора проекта."}
</div>
</div>
</div>
</div>
</HomeCardShell>
); );
} }

View File

@ -4,7 +4,7 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import useSWR from "swr"; import useSWR from "swr";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
@ -16,6 +16,7 @@ import type { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys }
import { ContentOverflowWrapper } from "@/components/core/content-overflow-HOC"; import { ContentOverflowWrapper } from "@/components/core/content-overflow-HOC";
// plane web services // plane web services
import { WorkspaceService } from "@/services/workspace.service"; import { WorkspaceService } from "@/services/workspace.service";
import { getActivityProjectId } from "../../home.utils";
import { RecentsEmptyState } from "../empty-states"; import { RecentsEmptyState } from "../empty-states";
import { EWidgetKeys, WidgetLoader } from "../loaders"; import { EWidgetKeys, WidgetLoader } from "../loaders";
import { FiltersDropdown } from "./filters"; import { FiltersDropdown } from "./filters";
@ -35,18 +36,20 @@ const filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode; i18n_k
type TRecentWidgetProps = THomeWidgetProps & { type TRecentWidgetProps = THomeWidgetProps & {
presetFilter?: TRecentActivityFilterKeys; presetFilter?: TRecentActivityFilterKeys;
showFilterSelect?: boolean; showFilterSelect?: boolean;
projectId?: string | null;
recents?: TActivityEntityData[];
}; };
export const RecentActivityWidget = observer(function RecentActivityWidget(props: TRecentWidgetProps) { export const RecentActivityWidget = observer(function RecentActivityWidget(props: TRecentWidgetProps) {
const { presetFilter, showFilterSelect = true, workspaceSlug } = props; const { presetFilter, showFilterSelect = true, workspaceSlug, projectId, recents: preloadedRecents } = props;
// states // states
const [filter, setFilter] = useState<TRecentActivityFilterKeys>(presetFilter ?? filters[0].name); const [filter, setFilter] = useState<TRecentActivityFilterKeys>(presetFilter ?? filters[0].name);
const { t } = useTranslation(); const { t } = useTranslation();
// ref // ref
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const { data: recents, isLoading } = useSWR( const { data: fetchedRecents, isLoading } = useSWR(
workspaceSlug ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null, workspaceSlug && !preloadedRecents ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null,
workspaceSlug workspaceSlug
? () => ? () =>
workspaceService.fetchWorkspaceRecents( workspaceService.fetchWorkspaceRecents(
@ -61,6 +64,19 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
} }
); );
const recents = useMemo(() => {
const source = preloadedRecents ?? fetchedRecents ?? [];
const filteredByType = source.filter((activity) =>
filter === filters[0].name ? true : activity.entity_name === filter
);
return filteredByType.filter((activity) => {
if (!activity.entity_data) return false;
if (!projectId) return true;
return getActivityProjectId(activity) === projectId;
});
}, [fetchedRecents, filter, preloadedRecents, projectId]);
const resolveRecent = (activity: TActivityEntityData) => { const resolveRecent = (activity: TActivityEntityData) => {
switch (activity.entity_name) { switch (activity.entity_name) {
case "page": case "page":
@ -75,7 +91,7 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
} }
}; };
if (!isLoading && recents?.length === 0) if (!isLoading && recents.length === 0)
return ( return (
<div ref={ref} className="max-h-[500px] overflow-y-scroll"> <div ref={ref} className="max-h-[500px] overflow-y-scroll">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
@ -101,10 +117,7 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
</div> </div>
<div className="flex min-h-[250px] flex-col"> <div className="flex min-h-[250px] flex-col">
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />} {isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
{!isLoading && {!isLoading && recents.map((activity) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
recents
?.filter((recent) => recent.entity_data)
.map((activity) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
</div> </div>
</ContentOverflowWrapper> </ContentOverflowWrapper>
); );

View File

@ -220,8 +220,7 @@
@layer components { @layer components {
.nodedc-glass-sidebar { .nodedc-glass-sidebar {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.018) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.018) 100%), rgba(7, 7, 9, 0.84);
rgba(7, 7, 9, 0.84);
backdrop-filter: blur(40px); backdrop-filter: blur(40px);
border-right: 1px solid rgba(255, 255, 255, 0.08); border-right: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: box-shadow:
@ -231,8 +230,7 @@
.nodedc-glass-modal { .nodedc-glass-modal {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(6, 6, 8, 0.9) !important;
rgba(6, 6, 8, 0.9) !important;
border: 0 !important; border: 0 !important;
outline: none !important; outline: none !important;
-webkit-backdrop-filter: blur(42px); -webkit-backdrop-filter: blur(42px);
@ -244,8 +242,7 @@
.nodedc-glass-surface { .nodedc-glass-surface {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(9, 9, 12, 0.88);
rgba(9, 9, 12, 0.88);
@apply border border-subtle/70 backdrop-blur-2xl; @apply border border-subtle/70 backdrop-blur-2xl;
-webkit-backdrop-filter: blur(40px); -webkit-backdrop-filter: blur(40px);
backdrop-filter: blur(40px); backdrop-filter: blur(40px);
@ -256,8 +253,7 @@
.nodedc-glass-popup-surface { .nodedc-glass-popup-surface {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(8, 8, 11, 0.9);
rgba(8, 8, 11, 0.9);
border: 0 !important; border: 0 !important;
outline: none !important; outline: none !important;
-webkit-backdrop-filter: blur(44px); -webkit-backdrop-filter: blur(44px);
@ -269,8 +265,7 @@
.nodedc-bottom-dock { .nodedc-bottom-dock {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%), rgba(7, 7, 10, 0.72) !important;
rgba(7, 7, 10, 0.72) !important;
border: 0 !important; border: 0 !important;
outline: none !important; outline: none !important;
box-shadow: box-shadow:
@ -352,8 +347,7 @@
.nodedc-modal-field { .nodedc-modal-field {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.028);
rgba(255, 255, 255, 0.028);
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 1.25rem; border-radius: 1.25rem;
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
@ -368,8 +362,93 @@
.nodedc-modal-field:hover, .nodedc-modal-field:hover,
.nodedc-modal-field:focus-within { .nodedc-modal-field:focus-within {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.038) 0%, rgba(255, 255, 255, 0.016) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.038) 0%, rgba(255, 255, 255, 0.016) 100%), rgba(255, 255, 255, 0.04);
rgba(255, 255, 255, 0.04); }
.nodedc-cover-picker {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(9, 9, 12, 0.9);
}
.nodedc-cover-picker-tabs {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem;
border-radius: 1.1rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(255, 255, 255, 0.032);
}
.nodedc-cover-picker-tab {
display: inline-flex;
flex: 1 1 0%;
min-height: 2.75rem;
align-items: center;
justify-content: center;
border-radius: 0.9rem !important;
border: 0 !important;
color: var(--text-color-secondary) !important;
background: transparent !important;
font-size: 0.8125rem;
font-weight: 500;
transition:
color 160ms ease,
background-color 160ms ease;
}
.nodedc-cover-picker-tab[data-state="active"] {
color: var(--text-color-primary) !important;
background: rgba(255, 255, 255, 0.06) !important;
}
.nodedc-cover-picker-tile {
border: 1px solid transparent;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.014) 100%), rgba(255, 255, 255, 0.03);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.02),
0 12px 28px rgba(0, 0, 0, 0.18);
transition:
border-color 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
}
.nodedc-cover-picker-tile:hover {
border-color: rgba(255, 255, 255, 0.08);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 18px 34px rgba(0, 0, 0, 0.24);
}
.nodedc-cover-picker-tile[data-selected="true"] {
border-color: rgba(var(--nodedc-accent-rgb), 0.72);
box-shadow:
inset 0 0 0 1px rgba(var(--nodedc-accent-rgb), 0.38),
0 18px 34px rgba(0, 0, 0, 0.24);
}
.nodedc-cover-picker-upload {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.02),
0 16px 34px rgba(0, 0, 0, 0.18);
transition:
background 160ms ease,
border-color 160ms ease;
}
.nodedc-cover-picker-upload:hover {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.014) 100%), rgba(255, 255, 255, 0.032);
border-color: rgba(255, 255, 255, 0.08);
}
.nodedc-cover-picker-footer {
border-color: rgba(255, 255, 255, 0.06) !important;
} }
.nodedc-work-item-properties-row { .nodedc-work-item-properties-row {
@ -401,8 +480,7 @@
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.018) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.018) 100%), rgba(255, 255, 255, 0.04) !important;
rgba(255, 255, 255, 0.04) !important;
color: var(--text-color-primary) !important; color: var(--text-color-primary) !important;
} }
@ -424,8 +502,7 @@
.nodedc-modal-input { .nodedc-modal-input {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.028) !important;
rgba(255, 255, 255, 0.028) !important;
border: 1px solid transparent !important; border: 1px solid transparent !important;
border-radius: 1.25rem !important; border-radius: 1.25rem !important;
box-shadow: none !important; box-shadow: none !important;
@ -440,8 +517,7 @@
.nodedc-modal-editor { .nodedc-modal-editor {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.028) !important;
rgba(255, 255, 255, 0.028) !important;
border: 1px solid transparent !important; border: 1px solid transparent !important;
border-radius: 1.5rem !important; border-radius: 1.5rem !important;
overflow: hidden; overflow: hidden;
@ -460,8 +536,7 @@
.nodedc-dropdown-surface { .nodedc-dropdown-surface {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(8, 8, 11, 0.9);
rgba(8, 8, 11, 0.9);
@apply rounded-[1.25rem] px-3 py-3 text-12 outline-none; @apply rounded-[1.25rem] px-3 py-3 text-12 outline-none;
border: 0 !important; border: 0 !important;
-webkit-backdrop-filter: blur(44px); -webkit-backdrop-filter: blur(44px);
@ -711,22 +786,19 @@
box-shadow: none !important; box-shadow: none !important;
border-radius: 1.25rem !important; border-radius: 1.25rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.028) !important;
rgba(255, 255, 255, 0.028) !important;
color: var(--text-color-secondary) !important; color: var(--text-color-secondary) !important;
padding-inline: 1rem !important; padding-inline: 1rem !important;
} }
.nodedc-modal-chip:hover { .nodedc-modal-chip:hover {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.038) 0%, rgba(255, 255, 255, 0.016) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.038) 0%, rgba(255, 255, 255, 0.016) 100%), rgba(255, 255, 255, 0.04) !important;
rgba(255, 255, 255, 0.04) !important;
} }
.nodedc-settings-card { .nodedc-settings-card {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.026) 0%, rgba(255, 255, 255, 0.01) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.026) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(255, 255, 255, 0.032);
rgba(255, 255, 255, 0.032);
border: 0 !important; border: 0 !important;
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
@ -742,8 +814,7 @@
inset -1px 0 0 rgba(255, 255, 255, 0.06), inset -1px 0 0 rgba(255, 255, 255, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.015) !important; inset 0 1px 0 rgba(255, 255, 255, 0.015) !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%), rgba(8, 8, 11, 0.9) !important;
rgba(8, 8, 11, 0.9) !important;
-webkit-backdrop-filter: blur(28px); -webkit-backdrop-filter: blur(28px);
backdrop-filter: blur(28px); backdrop-filter: blur(28px);
} }
@ -761,15 +832,13 @@
.nodedc-settings-sidebar-item:hover { .nodedc-settings-sidebar-item:hover {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.014) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.014) 100%), rgba(255, 255, 255, 0.028) !important;
rgba(255, 255, 255, 0.028) !important;
color: var(--text-color-primary) !important; color: var(--text-color-primary) !important;
} }
.nodedc-settings-sidebar-item[data-active="true"] { .nodedc-settings-sidebar-item[data-active="true"] {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.016) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.016) 100%), rgba(255, 255, 255, 0.042) !important;
rgba(255, 255, 255, 0.042) !important;
color: rgb(var(--nodedc-accent-rgb)) !important; color: rgb(var(--nodedc-accent-rgb)) !important;
box-shadow: inset 0 0 0 1px rgba(var(--nodedc-accent-rgb), 0.24) !important; box-shadow: inset 0 0 0 1px rgba(var(--nodedc-accent-rgb), 0.24) !important;
} }
@ -780,8 +849,7 @@
.nodedc-settings-field { .nodedc-settings-field {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.03) !important;
rgba(255, 255, 255, 0.03) !important;
border: 0 !important; border: 0 !important;
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
@ -800,8 +868,7 @@
.nodedc-settings-input { .nodedc-settings-input {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.03) !important;
rgba(255, 255, 255, 0.03) !important;
border: 0 !important; border: 0 !important;
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
@ -826,8 +893,7 @@
box-shadow: none !important; box-shadow: none !important;
border-radius: 1.25rem !important; border-radius: 1.25rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.03) !important;
rgba(255, 255, 255, 0.03) !important;
color: var(--text-color-primary) !important; color: var(--text-color-primary) !important;
-webkit-backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
@ -847,8 +913,7 @@
box-shadow: none !important; box-shadow: none !important;
border-radius: 1.25rem !important; border-radius: 1.25rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.03) !important;
rgba(255, 255, 255, 0.03) !important;
color: var(--text-color-primary) !important; color: var(--text-color-primary) !important;
padding-inline: 1rem !important; padding-inline: 1rem !important;
-webkit-backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
@ -900,8 +965,7 @@
box-shadow: none !important; box-shadow: none !important;
border-radius: 1.25rem !important; border-radius: 1.25rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.02) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.02) 100%), rgba(9, 9, 12, 0.72) !important;
rgba(9, 9, 12, 0.72) !important;
color: #f5f7fb !important; color: #f5f7fb !important;
padding-inline: 1.05rem !important; padding-inline: 1.05rem !important;
-webkit-backdrop-filter: blur(22px); -webkit-backdrop-filter: blur(22px);
@ -914,8 +978,7 @@
.nodedc-overlay-button:hover { .nodedc-overlay-button:hover {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.03) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.03) 100%), rgba(9, 9, 12, 0.8) !important;
rgba(9, 9, 12, 0.8) !important;
color: #ffffff !important; color: #ffffff !important;
} }
@ -978,8 +1041,7 @@
.nodedc-filter-row-shell { .nodedc-filter-row-shell {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(8, 8, 11, 0.84);
rgba(8, 8, 11, 0.84);
border: 0 !important; border: 0 !important;
border-radius: 1.35rem !important; border-radius: 1.35rem !important;
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
@ -1085,8 +1147,7 @@
border-radius: 1.9rem !important; border-radius: 1.9rem !important;
padding: 2.2rem !important; padding: 2.2rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.048) 0%, rgba(255, 255, 255, 0.015) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.048) 0%, rgba(255, 255, 255, 0.015) 100%), rgba(9, 9, 12, 0.84) !important;
rgba(9, 9, 12, 0.84) !important;
-webkit-backdrop-filter: blur(40px); -webkit-backdrop-filter: blur(40px);
backdrop-filter: blur(40px); backdrop-filter: blur(40px);
} }
@ -1116,16 +1177,14 @@
border-radius: 1.15rem !important; border-radius: 1.15rem !important;
min-height: 3rem; min-height: 3rem;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.03) !important;
rgba(255, 255, 255, 0.03) !important;
-webkit-backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
} }
.nodedc-auth-input-shell[data-error="true"] { .nodedc-auth-input-shell[data-error="true"] {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 82, 82, 0.08) !important;
rgba(255, 82, 82, 0.08) !important;
} }
.nodedc-auth-input { .nodedc-auth-input {
@ -1176,8 +1235,7 @@
border-radius: 1.95rem !important; border-radius: 1.95rem !important;
padding: 2.15rem !important; padding: 2.15rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.016) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.016) 100%), rgba(9, 9, 12, 0.86) !important;
rgba(9, 9, 12, 0.86) !important;
-webkit-backdrop-filter: blur(40px); -webkit-backdrop-filter: blur(40px);
backdrop-filter: blur(40px); backdrop-filter: blur(40px);
} }
@ -1242,8 +1300,7 @@
.nodedc-external-sidebar-shell { .nodedc-external-sidebar-shell {
border: 0 !important; border: 0 !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(8, 8, 11, 0.86) !important;
rgba(8, 8, 11, 0.86) !important;
-webkit-backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px);
backdrop-filter: blur(30px); backdrop-filter: blur(30px);
} }
@ -1312,8 +1369,7 @@
inset 0 1px 0 rgba(255, 255, 255, 0.02) !important; inset 0 1px 0 rgba(255, 255, 255, 0.02) !important;
border-radius: 2rem !important; border-radius: 2rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.034) 0%, rgba(255, 255, 255, 0.014) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.034) 0%, rgba(255, 255, 255, 0.014) 100%), rgba(255, 255, 255, 0.03) !important;
rgba(255, 255, 255, 0.03) !important;
-webkit-backdrop-filter: blur(28px); -webkit-backdrop-filter: blur(28px);
backdrop-filter: blur(28px); backdrop-filter: blur(28px);
} }
@ -1348,8 +1404,7 @@
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.018) !important; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.018) !important;
border-radius: 1.6rem !important; border-radius: 1.6rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.028) !important;
rgba(255, 255, 255, 0.028) !important;
-webkit-backdrop-filter: blur(22px); -webkit-backdrop-filter: blur(22px);
backdrop-filter: blur(22px); backdrop-filter: blur(22px);
} }
@ -1454,8 +1509,7 @@
outline: none !important; outline: none !important;
border-radius: 1.5rem !important; border-radius: 1.5rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.016) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.016) 100%), rgba(8, 8, 11, 0.76) !important;
rgba(8, 8, 11, 0.76) !important;
box-shadow: box-shadow:
0 20px 52px rgba(0, 0, 0, 0.22), 0 20px 52px rgba(0, 0, 0, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.025) !important; inset 0 1px 0 rgba(255, 255, 255, 0.025) !important;
@ -1483,6 +1537,113 @@
box-shadow 160ms ease; box-shadow 160ms ease;
} }
.nodedc-home-card {
position: relative;
overflow: hidden;
isolation: isolate;
border: 0 !important;
outline: none !important;
border-radius: 2rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.034) 0%, rgba(255, 255, 255, 0.012) 100%),
rgba(255, 255, 255, 0.028) !important;
box-shadow:
0 18px 40px rgba(0, 0, 0, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.028) !important;
-webkit-backdrop-filter: blur(28px);
backdrop-filter: blur(28px);
}
.nodedc-home-card::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
background:
radial-gradient(circle at top right, rgba(var(--nodedc-accent-rgb), 0.12), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.014) 0%, transparent 100%);
pointer-events: none;
}
.nodedc-home-card[data-tone="accent"] {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.042) 0%, rgba(255, 255, 255, 0.016) 100%),
rgba(var(--nodedc-accent-rgb), 0.12) !important;
}
.nodedc-home-card[data-tone="accent"]::before {
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.16), transparent 30%),
radial-gradient(circle at bottom left, rgba(var(--nodedc-accent-rgb), 0.24), transparent 38%);
}
.nodedc-home-project-card {
height: 14.25rem;
border: 0 !important;
outline: none !important;
overflow: hidden;
border-radius: 1.75rem !important;
box-shadow:
0 18px 38px rgba(0, 0, 0, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.03) !important;
transition:
transform 180ms ease,
box-shadow 180ms ease,
filter 180ms ease;
}
.nodedc-home-project-card[data-active="true"] {
box-shadow:
0 28px 48px rgba(0, 0, 0, 0.28),
inset 0 0 0 1px rgba(var(--nodedc-accent-rgb), 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.03) !important;
}
.nodedc-home-project-card[data-active="false"] {
filter: saturate(0.88);
transform: scale(0.965);
}
.nodedc-home-project-card[data-active="false"]:hover {
transform: translateY(-0.25rem) scale(0.972);
filter: saturate(1);
}
.nodedc-home-metric-card {
border-radius: 1.5rem !important;
border: 1px solid rgba(255, 255, 255, 0.06);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.032) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(0, 0, 0, 0.14);
padding: 1rem;
}
.nodedc-home-metric-card-accent {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.042) 0%, rgba(255, 255, 255, 0.016) 100%),
rgba(var(--nodedc-accent-rgb), 0.12);
}
.nodedc-home-chart-panel {
border-radius: 1.75rem !important;
border: 1px solid rgba(255, 255, 255, 0.06);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.032) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(0, 0, 0, 0.14);
padding: 1rem;
}
.nodedc-home-progress-track {
height: 0.55rem;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.nodedc-home-progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, rgba(var(--nodedc-accent-rgb), 0.94) 0%, rgba(255, 255, 255, 0.92) 100%);
}
.nodedc-workspace-list-row:hover { .nodedc-workspace-list-row:hover {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.044) 0%, rgba(255, 255, 255, 0.018) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.044) 0%, rgba(255, 255, 255, 0.018) 100%),
@ -1497,8 +1658,7 @@
outline: none !important; outline: none !important;
border-radius: 1.3rem !important; border-radius: 1.3rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.01) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(255, 255, 255, 0.022) !important;
rgba(255, 255, 255, 0.022) !important;
box-shadow: box-shadow:
0 14px 32px rgba(0, 0, 0, 0.14), 0 14px 32px rgba(0, 0, 0, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.018) !important; inset 0 1px 0 rgba(255, 255, 255, 0.018) !important;
@ -1545,8 +1705,7 @@
box-shadow: none !important; box-shadow: none !important;
border-radius: 1.25rem !important; border-radius: 1.25rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.028) !important;
rgba(255, 255, 255, 0.028) !important;
color: var(--text-color-primary) !important; color: var(--text-color-primary) !important;
padding: 0.65rem 0.95rem !important; padding: 0.65rem 0.95rem !important;
} }
@ -1570,8 +1729,7 @@
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.018) !important; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.018) !important;
border-radius: 1.6rem !important; border-radius: 1.6rem !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.028) !important;
rgba(255, 255, 255, 0.028) !important;
-webkit-backdrop-filter: blur(22px); -webkit-backdrop-filter: blur(22px);
backdrop-filter: blur(22px); backdrop-filter: blur(22px);
padding: 0.9rem 1rem !important; padding: 0.9rem 1rem !important;
@ -1735,8 +1893,7 @@
inset 0 1px 0 rgba(255, 255, 255, 0.018), inset 0 1px 0 rgba(255, 255, 255, 0.018),
0 10px 28px rgba(0, 0, 0, 0.08) !important; 0 10px 28px rgba(0, 0, 0, 0.08) !important;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.03) !important;
rgba(255, 255, 255, 0.03) !important;
color: var(--text-color-primary) !important; color: var(--text-color-primary) !important;
} }
} }