diff --git a/plane-src/apps/web/ce/components/home/header.tsx b/plane-src/apps/web/ce/components/home/header.tsx index 5fab957..09370cf 100644 --- a/plane-src/apps/web/ce/components/home/header.tsx +++ b/plane-src/apps/web/ce/components/home/header.tsx @@ -4,6 +4,37 @@ * 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() { - return <>; + const { t } = useTranslation(); + const { toggleWidgetSettings } = useHome(); + const { currentWorkspace } = useWorkspace(); + + return ( +
+
+
+ Workspace Home +
+
+ {currentWorkspace?.name ? `Стартовый экран для ${currentWorkspace.name}` : "Главная страница workspace"} +
+
+ + +
+ ); } diff --git a/plane-src/apps/web/core/components/home/home-card-shell.tsx b/plane-src/apps/web/core/components/home/home-card-shell.tsx new file mode 100644 index 0000000..5afa07c --- /dev/null +++ b/plane-src/apps/web/core/components/home/home-card-shell.tsx @@ -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 ( +
+ {(title || eyebrow || description || action) && ( +
+
+ {eyebrow && ( +
{eyebrow}
+ )} + {title &&

{title}

} + {description &&

{description}

} +
+ {action &&
{action}
} +
+ )} + +
{children}
+
+ ); +} diff --git a/plane-src/apps/web/core/components/home/home-dashboard-widgets.tsx b/plane-src/apps/web/core/components/home/home-dashboard-widgets.tsx index c37b4be..e019c8b 100644 --- a/plane-src/apps/web/core/components/home/home-dashboard-widgets.tsx +++ b/plane-src/apps/web/core/components/home/home-dashboard-widgets.tsx @@ -4,12 +4,15 @@ * See the LICENSE file for details. */ +import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useParams, usePathname } from "next/navigation"; import { useTheme } from "next-themes"; +import useSWR from "swr"; // plane imports 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 import darkWidgetsAsset from "@/app/assets/empty-state/dashboard/widgets-dark.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 import { useHome } from "@/hooks/store/use-home"; import { useProject } from "@/hooks/store/use-project"; +import { useWorkspace } from "@/hooks/store/use-workspace"; // plane web components import { HomePageHeader } from "@/plane-web/components/home/header"; +import { ProjectService } from "@/services/project"; +import { WorkspaceService } from "@/services/workspace.service"; // 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 { HomeLoader, NoProjectsEmptyState, RecentActivityWidget } from "./widgets"; import { DashboardQuickLinks } from "./widgets/links"; import { ManageWidgetsModal } from "./widgets/manage"; +import { UserGreetingsView } from "./user-greetings"; + +const projectService = new ProjectService(); +const workspaceService = new WorkspaceService(); export const HOME_WIDGETS_LIST: { [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 const { workspaceSlug } = useParams(); + const workspaceSlugValue = Array.isArray(workspaceSlug) ? workspaceSlug[0] : workspaceSlug?.toString(); // navigation const pathname = usePathname(); // theme hook const { resolvedTheme } = useTheme(); // store hooks - const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets, isAnyWidgetEnabled, loading } = - useHome(); - const { loader } = useProject(); + const { toggleWidgetSettings, widgetsMap, showWidgetSettings, loading } = useHome(); + const { loader, joinedProjectIds, getPartialProjectById, fetchProjectAnalyticsCount, getProjectAnalyticsCountById } = + useProject(); + const { currentWorkspace } = useWorkspace(); // plane hooks - const { t } = useTranslation(); + const { t, currentLocale } = useTranslation(); + // states + const [selectedProjectId, setSelectedProjectId] = useState(null); // derived values const noWidgetsResolvedPath = resolvedTheme === "light" ? lightWidgetsAsset : darkWidgetsAsset; // derived values - const isWikiApp = pathname.includes(`/${workspaceSlug.toString()}/pages`); - if (!workspaceSlug) return null; + const isWikiApp = workspaceSlugValue ? pathname.includes(`/${workspaceSlugValue}/pages`) : false; + + 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>((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 ; + const recentsCard = isRecentsEnabled ? ( + + + + ) : null; + + const sideWidgetCards = [ + isQuickLinksEnabled ? ( + + + + ) : null, + isStickiesEnabled ? ( + + + + ) : null, + ].filter(Boolean); + return ( -
+
toggleWidgetSettings(false)} /> - {!isWikiApp && } - {isAnyWidgetEnabled ? ( -
- {orderedWidgets.map((key) => { - const WidgetComponent = HOME_WIDGETS_LIST[key]?.component; - const isEnabled = widgetsMap[key]?.is_enabled; - if (!WidgetComponent || !isEnabled) return null; - return ( -
- -
- ); - })} -
- ) : ( -
- +
+
- )} +
+ {currentUser && ( + + )} + + + + {!isWikiApp && } + + {hasDashboardContent ? ( + <> + {recentsCard && sideWidgetCards.length > 0 ? ( +
+ {recentsCard} +
{sideWidgetCards}
+
+ ) : recentsCard ? ( + recentsCard + ) : ( +
1, + })} + > + {sideWidgetCards} +
+ )} + + ) : ( + +
+ +
+
+ )} +
+
); }); diff --git a/plane-src/apps/web/core/components/home/home-project-insights.tsx b/plane-src/apps/web/core/components/home/home-project-insights.tsx new file mode 100644 index 0000000..a443148 --- /dev/null +++ b/plane-src/apps/web/core/components/home/home-project-insights.tsx @@ -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(() => { + 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: , + accent: true, + }, + { + label: "Открытые задачи", + value: formatCompactNumber(openIssues), + caption: "Текущая незакрытая нагрузка", + icon: , + }, + { + label: "Касания за 7 дней", + value: formatCompactNumber(recentTouchpoints), + caption: "Recent activity по этому фокусу", + icon: , + }, + ]; + + 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 ( + +
+
+
+ {metricCards.map((metric) => ( +
+
+
{metric.label}
+
{metric.icon}
+
+
{metric.value}
+
{metric.caption}
+
+ ))} +
+ +
+
+
+
Темп активности
+
Последние 7 дней переходов и взаимодействий внутри сводки.
+
+
+ {recentTouchpoints} событий +
+
+ +
+
+ {["col-1", "col-2", "col-3", "col-4"].map((key) => ( +
+ ))} +
+ + + + + + + + + {activitySeries.map((point, index) => { + const x = chart.points[index]?.x ?? 0; + return ( + + ); + })} + {[0.25, 0.5, 0.75].map((position) => { + const y = chart.height - chart.paddingY - position * (chart.height - chart.paddingY * 2); + return ( + + ); + })} + + + {activitySeries.map((activityPoint, index) => { + const point = chart.points[index]; + if (!point) return null; + + return ( + + ); + })} + + +
+ {activitySeries.map((point) => ( +
+
{point.label}
+
{point.value}
+
+ ))} +
+
+
+
+ +
+
+
+
+ +
+
+
Операционный срез
+
+ Нагрузка команды, циклов и модулей относительно остального workspace. +
+
+
+ +
+ {progressRows.map((row) => { + const percent = row.max > 0 ? Math.max((row.value / row.max) * 100, row.value > 0 ? 10 : 0) : 0; + + return ( +
+
+ {row.label} + {row.value} +
+
+
+
+
+ ); + })} +
+
+ +
+
+
+
Ритм исполнения
+
Сколько уже закрыто и какой объём ещё держим открытым.
+
+
{completionRate}%
+
+ +
+
+
+ Закрытые задачи + {completedIssues} +
+
+
0 ? (completedIssues / totalIssues) * 100 : 0}%` }} + /> +
+
+ +
+
+ Открытый остаток + {openIssues} +
+
+
0 ? (openIssues / totalIssues) * 100 : 0}%`, + height: "100%", + }} + /> +
+
+ +
+ {project ? project.identifier : "Workspace"} + держит + {totalIssues} + задач в общей матрице и + {recentTouchpoints} + недавних касаний за неделю. +
+
+
+
+
+ + ); +} diff --git a/plane-src/apps/web/core/components/home/home-project-stack.tsx b/plane-src/apps/web/core/components/home/home-project-stack.tsx new file mode 100644 index 0000000..6b2eda7 --- /dev/null +++ b/plane-src/apps/web/core/components/home/home-project-stack.tsx @@ -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; + 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>((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 ( + +
+
+
+ +
+
+
Пока нет проектов для сводки
+
+ Откройте quickstart ниже и создайте первый проект для этой панели. +
+
+
+
+
+ ); + } + + return ( + + Открыть проект + + + ) : null + } + > +
+
+ {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 ( + + ); + })} +
+ +
+
+
+
Быстрый выбор
+
Все проекты пользователя в текущем workspace.
+
+
+ + {projects.length} +
+
+ +
+ {orderedProjects.map((project: THomeProjectData) => { + const analytics = analyticsMap[project.id]; + return ( + + ); + })} +
+ + {selectedProject && ( +
+
+
Фокус
+
{selectedProject.identifier}
+
+
+
+ + Команда +
+
+ {analyticsMap[selectedProject.id]?.total_members ?? 0} +
+
+
+
Контур
+
+ {activityCountByProject[selectedProject.id] ?? 0} recent +
+
+
+ )} +
+
+
+ ); +} diff --git a/plane-src/apps/web/core/components/home/home.utils.ts b/plane-src/apps/web/core/components/home/home.utils.ts new file mode 100644 index 0000000..1d9e59d --- /dev/null +++ b/plane-src/apps/web/core/components/home/home.utils.ts @@ -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 & + Partial>; + +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( + (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, + } + ); +}; diff --git a/plane-src/apps/web/core/components/home/root.tsx b/plane-src/apps/web/core/components/home/root.tsx index a4163d6..b1aeb2f 100644 --- a/plane-src/apps/web/core/components/home/root.tsx +++ b/plane-src/apps/web/core/components/home/root.tsx @@ -17,7 +17,6 @@ import { HomePeekOverviewsRoot } from "@/plane-web/components/home"; import { TourRoot } from "@/plane-web/components/onboarding/tour/root"; // local imports import { DashboardWidgets } from "./home-dashboard-widgets"; -import { UserGreetingsView } from "./user-greetings"; // Temporary NodeDC toggle: keep product tour implementation in code, // 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() { <> -
- {currentUser && } - +
+
diff --git a/plane-src/apps/web/core/components/home/user-greetings.tsx b/plane-src/apps/web/core/components/home/user-greetings.tsx index fd5312f..2fda590 100644 --- a/plane-src/apps/web/core/components/home/user-greetings.tsx +++ b/plane-src/apps/web/core/components/home/user-greetings.tsx @@ -6,17 +6,22 @@ // plane types import { useTranslation } from "@plane/i18n"; -import type { IUser } from "@plane/types"; +import type { IUser, TProjectAnalyticsCount } from "@plane/types"; // plane ui // hooks import { useCurrentTime } from "@/hooks/use-current-time"; +import { HomeCardShell } from "./home-card-shell"; +import { getCompletionRate, type THomeProjectData } from "./home.utils"; export interface IUserGreetingsView { user: IUser; + workspaceName?: string | null; + selectedProject?: THomeProjectData; + selectedProjectAnalytics?: TProjectAnalyticsCount; } export function UserGreetingsView(props: IUserGreetingsView) { - const { user } = props; + const { user, workspaceName, selectedProject, selectedProjectAnalytics } = props; // current time hook const { currentTime } = useCurrentTime(); // store hooks @@ -44,18 +49,52 @@ export function UserGreetingsView(props: IUserGreetingsView) { }).format(currentTime); const greeting = parseInt(hour, 10) < 12 ? "morning" : parseInt(hour, 10) < 18 ? "afternoon" : "evening"; + const completionRate = getCompletionRate(selectedProjectAnalytics); return ( -
-

- {t("good")} {t(greeting)}, {user?.first_name} {user?.last_name} -

-
-
{greeting === "morning" ? "🌤️" : greeting === "afternoon" ? "🌥️" : "🌙️"}
-
- {weekDay}, {date} {timeString} + +
+
+
+ {greeting === "morning" ? "🌤️" : greeting === "afternoon" ? "🌥️" : "🌙️"} + Главная панель workspace +
+
+ Домашняя страница теперь собирает проектный фокус, recent activity, быстрые ссылки и стикеры в один рабочий + экран без переходов по разделам. +
-
-
+ +
+
+
Текущий фокус
+
+ {selectedProject ? selectedProject.name : "Выберите проект слева"} +
+
+ {selectedProject ? selectedProject.identifier : "Домашняя сводка перестроится под выбранную карточку."} +
+
+ +
+
Прогресс фокуса
+
+ {selectedProject ? `${completionRate}%` : "—"} +
+
+ {selectedProject + ? "Закрытые задачи относительно общего объёма." + : "Станет доступен после выбора проекта."} +
+
+
+
+ ); } diff --git a/plane-src/apps/web/core/components/home/widgets/recents/index.tsx b/plane-src/apps/web/core/components/home/widgets/recents/index.tsx index 9a08be4..0fac9f1 100644 --- a/plane-src/apps/web/core/components/home/widgets/recents/index.tsx +++ b/plane-src/apps/web/core/components/home/widgets/recents/index.tsx @@ -4,7 +4,7 @@ * See the LICENSE file for details. */ -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; import { useTranslation } from "@plane/i18n"; @@ -16,6 +16,7 @@ import type { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } import { ContentOverflowWrapper } from "@/components/core/content-overflow-HOC"; // plane web services import { WorkspaceService } from "@/services/workspace.service"; +import { getActivityProjectId } from "../../home.utils"; import { RecentsEmptyState } from "../empty-states"; import { EWidgetKeys, WidgetLoader } from "../loaders"; import { FiltersDropdown } from "./filters"; @@ -35,18 +36,20 @@ const filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode; i18n_k type TRecentWidgetProps = THomeWidgetProps & { presetFilter?: TRecentActivityFilterKeys; showFilterSelect?: boolean; + projectId?: string | null; + recents?: TActivityEntityData[]; }; export const RecentActivityWidget = observer(function RecentActivityWidget(props: TRecentWidgetProps) { - const { presetFilter, showFilterSelect = true, workspaceSlug } = props; + const { presetFilter, showFilterSelect = true, workspaceSlug, projectId, recents: preloadedRecents } = props; // states const [filter, setFilter] = useState(presetFilter ?? filters[0].name); const { t } = useTranslation(); // ref const ref = useRef(null); - const { data: recents, isLoading } = useSWR( - workspaceSlug ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null, + const { data: fetchedRecents, isLoading } = useSWR( + workspaceSlug && !preloadedRecents ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null, workspaceSlug ? () => 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) => { switch (activity.entity_name) { case "page": @@ -75,7 +91,7 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props } }; - if (!isLoading && recents?.length === 0) + if (!isLoading && recents.length === 0) return (
@@ -101,10 +117,7 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
{isLoading && } - {!isLoading && - recents - ?.filter((recent) => recent.entity_data) - .map((activity) =>
{resolveRecent(activity)}
)} + {!isLoading && recents.map((activity) =>
{resolveRecent(activity)}
)}
); diff --git a/plane-src/apps/web/styles/globals.css b/plane-src/apps/web/styles/globals.css index c5c6755..9173ba0 100644 --- a/plane-src/apps/web/styles/globals.css +++ b/plane-src/apps/web/styles/globals.css @@ -220,8 +220,7 @@ @layer components { .nodedc-glass-sidebar { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.018) 100%), - rgba(7, 7, 9, 0.84); + linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.018) 100%), rgba(7, 7, 9, 0.84); backdrop-filter: blur(40px); border-right: 1px solid rgba(255, 255, 255, 0.08); box-shadow: @@ -231,8 +230,7 @@ .nodedc-glass-modal { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(6, 6, 8, 0.9) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(6, 6, 8, 0.9) !important; border: 0 !important; outline: none !important; -webkit-backdrop-filter: blur(42px); @@ -244,8 +242,7 @@ .nodedc-glass-surface { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%), - rgba(9, 9, 12, 0.88); + linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(9, 9, 12, 0.88); @apply border border-subtle/70 backdrop-blur-2xl; -webkit-backdrop-filter: blur(40px); backdrop-filter: blur(40px); @@ -256,8 +253,7 @@ .nodedc-glass-popup-surface { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(8, 8, 11, 0.9); + linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(8, 8, 11, 0.9); border: 0 !important; outline: none !important; -webkit-backdrop-filter: blur(44px); @@ -269,8 +265,7 @@ .nodedc-bottom-dock { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%), - rgba(7, 7, 10, 0.72) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%), rgba(7, 7, 10, 0.72) !important; border: 0 !important; outline: none !important; box-shadow: @@ -352,8 +347,7 @@ .nodedc-modal-field { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(255, 255, 255, 0.028); + linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.028); border: 1px solid transparent; border-radius: 1.25rem; backdrop-filter: blur(18px); @@ -368,8 +362,93 @@ .nodedc-modal-field:hover, .nodedc-modal-field:focus-within { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.038) 0%, rgba(255, 255, 255, 0.016) 100%), - rgba(255, 255, 255, 0.04); + linear-gradient(180deg, rgba(255, 255, 255, 0.038) 0%, rgba(255, 255, 255, 0.016) 100%), 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 { @@ -401,8 +480,7 @@ outline: none !important; box-shadow: none !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.018) 100%), - rgba(255, 255, 255, 0.04) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.018) 100%), rgba(255, 255, 255, 0.04) !important; color: var(--text-color-primary) !important; } @@ -424,8 +502,7 @@ .nodedc-modal-input { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(255, 255, 255, 0.028) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.028) !important; border: 1px solid transparent !important; border-radius: 1.25rem !important; box-shadow: none !important; @@ -440,8 +517,7 @@ .nodedc-modal-editor { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(255, 255, 255, 0.028) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.028) !important; border: 1px solid transparent !important; border-radius: 1.5rem !important; overflow: hidden; @@ -460,8 +536,7 @@ .nodedc-dropdown-surface { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%), - rgba(8, 8, 11, 0.9); + linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(8, 8, 11, 0.9); @apply rounded-[1.25rem] px-3 py-3 text-12 outline-none; border: 0 !important; -webkit-backdrop-filter: blur(44px); @@ -711,22 +786,19 @@ box-shadow: none !important; border-radius: 1.25rem !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(255, 255, 255, 0.028) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.028) !important; color: var(--text-color-secondary) !important; padding-inline: 1rem !important; } .nodedc-modal-chip:hover { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.038) 0%, rgba(255, 255, 255, 0.016) 100%), - rgba(255, 255, 255, 0.04) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.038) 0%, rgba(255, 255, 255, 0.016) 100%), rgba(255, 255, 255, 0.04) !important; } .nodedc-settings-card { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.026) 0%, rgba(255, 255, 255, 0.01) 100%), - rgba(255, 255, 255, 0.032); + linear-gradient(180deg, rgba(255, 255, 255, 0.026) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(255, 255, 255, 0.032); border: 0 !important; outline: none !important; box-shadow: none !important; @@ -742,8 +814,7 @@ inset -1px 0 0 rgba(255, 255, 255, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.015) !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%), - rgba(8, 8, 11, 0.9) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%), rgba(8, 8, 11, 0.9) !important; -webkit-backdrop-filter: blur(28px); backdrop-filter: blur(28px); } @@ -761,15 +832,13 @@ .nodedc-settings-sidebar-item:hover { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.014) 100%), - rgba(255, 255, 255, 0.028) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.014) 100%), rgba(255, 255, 255, 0.028) !important; color: var(--text-color-primary) !important; } .nodedc-settings-sidebar-item[data-active="true"] { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.016) 100%), - rgba(255, 255, 255, 0.042) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.016) 100%), rgba(255, 255, 255, 0.042) !important; color: rgb(var(--nodedc-accent-rgb)) !important; box-shadow: inset 0 0 0 1px rgba(var(--nodedc-accent-rgb), 0.24) !important; } @@ -780,8 +849,7 @@ .nodedc-settings-field { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(255, 255, 255, 0.03) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.03) !important; border: 0 !important; outline: none !important; box-shadow: none !important; @@ -800,8 +868,7 @@ .nodedc-settings-input { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(255, 255, 255, 0.03) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.03) !important; border: 0 !important; outline: none !important; box-shadow: none !important; @@ -826,8 +893,7 @@ box-shadow: none !important; border-radius: 1.25rem !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(255, 255, 255, 0.03) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.03) !important; color: var(--text-color-primary) !important; -webkit-backdrop-filter: blur(18px); backdrop-filter: blur(18px); @@ -847,8 +913,7 @@ box-shadow: none !important; border-radius: 1.25rem !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(255, 255, 255, 0.03) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.03) !important; color: var(--text-color-primary) !important; padding-inline: 1rem !important; -webkit-backdrop-filter: blur(18px); @@ -900,8 +965,7 @@ box-shadow: none !important; border-radius: 1.25rem !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.02) 100%), - rgba(9, 9, 12, 0.72) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.02) 100%), rgba(9, 9, 12, 0.72) !important; color: #f5f7fb !important; padding-inline: 1.05rem !important; -webkit-backdrop-filter: blur(22px); @@ -914,8 +978,7 @@ .nodedc-overlay-button:hover { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.03) 100%), - rgba(9, 9, 12, 0.8) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.07) 0%, rgba(255, 255, 255, 0.03) 100%), rgba(9, 9, 12, 0.8) !important; color: #ffffff !important; } @@ -978,8 +1041,7 @@ .nodedc-filter-row-shell { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%), - rgba(8, 8, 11, 0.84); + linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(8, 8, 11, 0.84); border: 0 !important; border-radius: 1.35rem !important; -webkit-backdrop-filter: blur(20px); @@ -1085,8 +1147,7 @@ border-radius: 1.9rem !important; padding: 2.2rem !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.048) 0%, rgba(255, 255, 255, 0.015) 100%), - rgba(9, 9, 12, 0.84) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.048) 0%, rgba(255, 255, 255, 0.015) 100%), rgba(9, 9, 12, 0.84) !important; -webkit-backdrop-filter: blur(40px); backdrop-filter: blur(40px); } @@ -1116,16 +1177,14 @@ border-radius: 1.15rem !important; min-height: 3rem; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(255, 255, 255, 0.03) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.03) !important; -webkit-backdrop-filter: blur(18px); backdrop-filter: blur(18px); } .nodedc-auth-input-shell[data-error="true"] { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(255, 82, 82, 0.08) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 82, 82, 0.08) !important; } .nodedc-auth-input { @@ -1176,8 +1235,7 @@ border-radius: 1.95rem !important; padding: 2.15rem !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.016) 100%), - rgba(9, 9, 12, 0.86) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.045) 0%, rgba(255, 255, 255, 0.016) 100%), rgba(9, 9, 12, 0.86) !important; -webkit-backdrop-filter: blur(40px); backdrop-filter: blur(40px); } @@ -1242,8 +1300,7 @@ .nodedc-external-sidebar-shell { border: 0 !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(8, 8, 11, 0.86) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(8, 8, 11, 0.86) !important; -webkit-backdrop-filter: blur(30px); backdrop-filter: blur(30px); } @@ -1312,8 +1369,7 @@ inset 0 1px 0 rgba(255, 255, 255, 0.02) !important; border-radius: 2rem !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.034) 0%, rgba(255, 255, 255, 0.014) 100%), - rgba(255, 255, 255, 0.03) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.034) 0%, rgba(255, 255, 255, 0.014) 100%), rgba(255, 255, 255, 0.03) !important; -webkit-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; border-radius: 1.6rem !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(255, 255, 255, 0.028) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.028) !important; -webkit-backdrop-filter: blur(22px); backdrop-filter: blur(22px); } @@ -1454,8 +1509,7 @@ outline: none !important; border-radius: 1.5rem !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.016) 100%), - rgba(8, 8, 11, 0.76) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.016) 100%), rgba(8, 8, 11, 0.76) !important; box-shadow: 0 20px 52px rgba(0, 0, 0, 0.22), inset 0 1px 0 rgba(255, 255, 255, 0.025) !important; @@ -1483,6 +1537,113 @@ 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 { background: linear-gradient(180deg, rgba(255, 255, 255, 0.044) 0%, rgba(255, 255, 255, 0.018) 100%), @@ -1497,8 +1658,7 @@ outline: none !important; border-radius: 1.3rem !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.01) 100%), - rgba(255, 255, 255, 0.022) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(255, 255, 255, 0.022) !important; box-shadow: 0 14px 32px rgba(0, 0, 0, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.018) !important; @@ -1545,8 +1705,7 @@ box-shadow: none !important; border-radius: 1.25rem !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(255, 255, 255, 0.028) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.028) !important; color: var(--text-color-primary) !important; padding: 0.65rem 0.95rem !important; } @@ -1570,8 +1729,7 @@ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.018) !important; border-radius: 1.6rem !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(255, 255, 255, 0.028) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.028) !important; -webkit-backdrop-filter: blur(22px); backdrop-filter: blur(22px); padding: 0.9rem 1rem !important; @@ -1735,8 +1893,7 @@ inset 0 1px 0 rgba(255, 255, 255, 0.018), 0 10px 28px rgba(0, 0, 0, 0.08) !important; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), - rgba(255, 255, 255, 0.03) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), rgba(255, 255, 255, 0.03) !important; color: var(--text-color-primary) !important; } }