From 3034ba50897d452e254c74ec5e2a5e3811acfef2 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 23 Apr 2026 13:42:06 +0300 Subject: [PATCH] =?UTF-8?q?UI=20-=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E?= =?UTF-8?q?=D0=95=D0=9A=D0=A2=D0=9D=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C?= =?UTF-8?q?=D0=A3=D0=9D=D0=98=D0=9A=D0=90=D0=A6=D0=98=D0=AF:=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D0=B0=D1=86=D0=B8=D1=8F=20active-=D1=81?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=BD?= =?UTF-8?q?=D1=83=D1=82=D1=80=D0=B5=D0=BD=D0=BD=D0=B5=D0=B9=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B4=D1=8B=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=20?= =?UTF-8?q?=D0=BD=D0=B0=20home?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/home-dashboard-widgets.tsx | 3 + .../home/home-recent-issue-decks.tsx | 454 ++++++++++++++++++ plane-src/apps/web/styles/globals.css | 102 ++++ 3 files changed, 559 insertions(+) create mode 100644 plane-src/apps/web/core/components/home/home-recent-issue-decks.tsx 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 e019c8b..26f8828 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 @@ -28,6 +28,7 @@ import { ProjectService } from "@/services/project"; import { WorkspaceService } from "@/services/workspace.service"; // local imports import { HomeCardShell } from "./home-card-shell"; +import { HomeRecentIssueDecks } from "./home-recent-issue-decks"; import { HomeProjectInsights } from "./home-project-insights"; import { HomeProjectStack } from "./home-project-stack"; import { aggregateProjectAnalytics, type THomeProjectData } from "./home.utils"; @@ -239,6 +240,8 @@ export const DashboardWidgets = observer(function DashboardWidgets(props: Dashbo locale={currentLocale || "ru-RU"} /> + + {!isWikiApp && } {hasDashboardContent ? ( diff --git a/plane-src/apps/web/core/components/home/home-recent-issue-decks.tsx b/plane-src/apps/web/core/components/home/home-recent-issue-decks.tsx new file mode 100644 index 0000000..f0276bd --- /dev/null +++ b/plane-src/apps/web/core/components/home/home-recent-issue-decks.tsx @@ -0,0 +1,454 @@ +/** + * 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 { useEffect, useMemo, useState } from "react"; +import { CalendarDays, Sparkles } from "lucide-react"; +import { observer } from "mobx-react"; +import useSWR from "swr"; +import { useTranslation } from "@plane/i18n"; +import { PriorityIcon, StateGroupIcon } from "@plane/propel/icons"; +import type { TExternalContourRequest, TIssue } from "@plane/types"; +import { Avatar } from "@plane/ui"; +import { cn, getFileURL, renderFormattedDate } from "@plane/utils"; +import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; +import { useMember } from "@/hooks/store/use-member"; +import { useProject } from "@/hooks/store/use-project"; +import { useProjectState } from "@/hooks/store/use-project-state"; +import { + NodedcWorkItemCard, + getNodedcWorkItemCardAppearance, +} from "@/components/issues/issue-layouts/shared/nodedc-work-item-card"; +import { ExternalContourService } from "@/services/external-contours"; +import { IssueService } from "@/services/issue"; +import { HomeCardShell } from "./home-card-shell"; +import type { THomeProjectData } from "./home.utils"; + +const issueService = new IssueService(); +const externalContourService = new ExternalContourService(); +const INTERNAL_DECK_LIMIT = 10; +const EXTERNAL_DECK_LIMIT = 10; +const INTERNAL_CURSOR = `${INTERNAL_DECK_LIMIT}:0:0`; + +type HomeRecentIssueDecksProps = { + project?: THomeProjectData; + workspaceSlug: string; +}; + +type DeckSectionProps = { + count: number; + description: string; + emptyDescription: string; + emptyTitle: string; + isLoading: boolean; + items: ReactNode[]; + title: string; +}; + +type InternalIssueCardProps = { + isActive: boolean; + issue: TIssue; + onSelect: () => void; + project: THomeProjectData; +}; + +type ExternalIssueCardProps = { + isActive: boolean; + onSelect: () => void; + project: THomeProjectData; + request: TExternalContourRequest; +}; + +const sortByRecentCreatedDate = < + T extends { created_at?: string | null; requested_at?: string | null; updated_at?: string | null }, +>( + items: T[] +) => { + // oxlint-disable-next-line unicorn/no-array-sort + return [...items].sort((left: T, right: T) => { + const leftDate = Date.parse(left.requested_at ?? left.created_at ?? left.updated_at ?? "") || 0; + const rightDate = Date.parse(right.requested_at ?? right.created_at ?? right.updated_at ?? "") || 0; + return rightDate - leftDate; + }); +}; + +const DeckSection = (props: DeckSectionProps) => { + const { count, description, emptyDescription, emptyTitle, isLoading, items, title } = props; + + return ( +
+
+
+
{title}
+
{description}
+
+
+ + {count} +
+
+ +
+
+ {isLoading + ? Array.from({ length: 4 }, (_, index) => ( +
0, + })} + style={{ zIndex: 5 - index }} + /> + )) + : items} +
+
+ + {!isLoading && items.length === 0 && ( +
+
{emptyTitle}
+
{emptyDescription}
+
+ )} +
+ ); +}; + +const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCard(props: InternalIssueCardProps) { + const { isActive, issue, onSelect, project } = props; + const { t } = useTranslation(); + const { getProjectById } = useProject(); + const { getUserDetails } = useMember(); + const { getStateById } = useProjectState(); + + const creatorDetails = useMemo(() => { + if (issue.created_by_detail) return issue.created_by_detail; + if (issue.created_by && getUserDetails(issue.created_by)) return getUserDetails(issue.created_by); + if (issue.created_by_display_name || issue.created_by_avatar_url) { + return { + id: issue.created_by, + display_name: issue.created_by_display_name ?? t("common.none"), + avatar_url: issue.created_by_avatar_url ?? "", + first_name: "", + last_name: "", + is_bot: false, + }; + } + + return undefined; + }, [ + getUserDetails, + issue.created_by, + issue.created_by_avatar_url, + issue.created_by_detail, + issue.created_by_display_name, + t, + ]); + + const sourceContourName = issue.source_project_name ?? getProjectById(issue.project_id)?.name ?? project.name; + const selectedState = getStateById(issue.state_id); + const { iconBubbleClasses, pillBackgroundClasses } = getNodedcWorkItemCardAppearance(isActive); + const statusIconColor = + selectedState?.color ?? (isActive ? "rgb(var(--nodedc-on-card-active-rgb))" : "var(--text-color-primary)"); + const creatorName = creatorDetails?.display_name ?? t("common.none"); + const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none"); + + const header = ( +
+
+
+ +
+
{creatorName}
+
+ +
+
+ +
+
+ +
+
+
+ ); + + const footer = ( + <> +
+ {(issue.assignee_ids?.length ?? 0) > 0 ? ( + + ) : ( + {t("external_contours_page.list.unassigned")} + )} +
+ +
+ + {dueDateLabel} +
+ + ); + + return ( + + ); +}); + +const HomeExternalContourDeckCard = observer(function HomeExternalContourDeckCard(props: ExternalIssueCardProps) { + const { isActive, onSelect, project, request } = props; + const { t } = useTranslation(); + const { getStateById } = useProjectState(); + const { iconBubbleClasses, pillBackgroundClasses } = getNodedcWorkItemCardAppearance(isActive); + + const issue = request.issue; + const isOutgoing = request.direction + ? request.direction === "outgoing" + : request.source_project_id === project.id; + const requester = + request.requested_by?.display_name || + request.requested_by_name || + issue.created_by_detail?.display_name || + t("external_contours_page.mirror.system_actor"); + const requesterAvatar = issue.created_by_detail?.avatar_url || ""; + const counterpartContourName = isOutgoing + ? request.target_project?.name || request.target_project_name || issue.project_detail?.name || t("common.none") + : request.source_project?.name || request.source_project_name || t("common.none"); + const fallbackState = getStateById(issue.state_id); + const selectedState = issue.state_detail ?? fallbackState; + const statusIconColor = + selectedState?.color ?? (isActive ? "rgb(var(--nodedc-on-card-active-rgb))" : "var(--text-color-primary)"); + const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none"); + + return ( + + ); +}); + +export const HomeRecentIssueDecks = observer(function HomeRecentIssueDecks(props: HomeRecentIssueDecksProps) { + const { project, workspaceSlug } = props; + const [selectedInternalIssueId, setSelectedInternalIssueId] = useState(null); + const [selectedExternalRequestId, setSelectedExternalRequestId] = useState(null); + + const { data: internalIssueResponse, isLoading: isInternalIssuesLoading } = useSWR( + project ? `HOME_PROJECT_INTERNAL_ISSUES_${workspaceSlug}_${project.id}` : null, + project + ? () => + issueService.getIssues(workspaceSlug, project.id, { + order_by: "-created_at", + per_page: INTERNAL_DECK_LIMIT.toString(), + cursor: INTERNAL_CURSOR, + }) + : null, + { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ); + + const { data: externalRequestsResponse, isLoading: isExternalRequestsLoading } = useSWR( + project ? `HOME_PROJECT_EXTERNAL_CONTOURS_${workspaceSlug}_${project.id}` : null, + project ? () => externalContourService.list(workspaceSlug, project.id) : null, + { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ); + + const internalIssues = useMemo(() => { + const results = internalIssueResponse?.results; + return Array.isArray(results) ? results.slice(0, INTERNAL_DECK_LIMIT) : []; + }, [internalIssueResponse]); + + const externalRequests = useMemo( + () => sortByRecentCreatedDate(externalRequestsResponse?.results ?? []).slice(0, EXTERNAL_DECK_LIMIT), + [externalRequestsResponse] + ); + + useEffect(() => { + if (internalIssues.length === 0) { + if (selectedInternalIssueId !== null) setSelectedInternalIssueId(null); + return; + } + + if (!selectedInternalIssueId || !internalIssues.some((issue) => issue.id === selectedInternalIssueId)) { + setSelectedInternalIssueId(internalIssues[0].id); + } + }, [internalIssues, selectedInternalIssueId]); + + useEffect(() => { + if (externalRequests.length === 0) { + if (selectedExternalRequestId !== null) setSelectedExternalRequestId(null); + return; + } + + if (!selectedExternalRequestId || !externalRequests.some((request) => request.id === selectedExternalRequestId)) { + setSelectedExternalRequestId(externalRequests[0].id); + } + }, [externalRequests, selectedExternalRequestId]); + + if (!project) { + return ( + +
+ Фокус проекта пока не выбран. +
+
+ ); + } + + const internalIssueCards = internalIssues.map((issue, index) => ( +
0 })} + style={{ zIndex: issue.id === selectedInternalIssueId ? internalIssues.length + 6 : index + 1 }} + > + setSelectedInternalIssueId(issue.id)} + project={project} + /> +
+ )); + + const externalIssueCards = externalRequests.map((request, index) => ( +
0 })} + style={{ zIndex: request.id === selectedExternalRequestId ? externalRequests.length + 6 : index + 1 }} + > + setSelectedExternalRequestId(request.id)} + project={project} + request={request} + /> +
+ )); + + return ( + + + + + + ); +}); diff --git a/plane-src/apps/web/styles/globals.css b/plane-src/apps/web/styles/globals.css index 9173ba0..5dd3b54 100644 --- a/plane-src/apps/web/styles/globals.css +++ b/plane-src/apps/web/styles/globals.css @@ -1609,6 +1609,108 @@ filter: saturate(1); } + .nodedc-home-task-deck-scroller { + overflow-x: auto; + overflow-y: visible; + padding-bottom: 0.25rem; + scrollbar-width: none; + } + + .nodedc-home-task-deck-scroller::-webkit-scrollbar { + display: none; + } + + .nodedc-home-task-card { + width: 18.5rem; + min-width: 18.5rem; + border: 0 !important; + outline: none !important; + background: transparent !important; + padding: 0 !important; + position: relative; + display: block; + cursor: pointer; + text-align: left; + transition: + transform 180ms ease, + filter 180ms ease; + } + + .nodedc-home-task-card[data-active="true"] { + transform: translateY(-0.85rem) scale(1.015); + } + + .nodedc-home-task-card[data-active="false"] { + filter: saturate(0.88); + transform: scale(0.975); + } + + .nodedc-home-task-card[data-active="false"]:hover { + transform: translateY(-0.2rem) scale(0.985); + filter: saturate(1); + } + + .nodedc-home-task-card-surface { + overflow: hidden; + isolation: isolate; + border-radius: 2rem !important; + box-shadow: + 0 24px 48px rgba(0, 0, 0, 0.24), + inset 0 1px 0 rgba(255, 255, 255, 0.03) !important; + -webkit-backdrop-filter: blur(24px); + backdrop-filter: blur(24px); + transition: + background 180ms ease, + box-shadow 180ms ease, + color 180ms ease; + } + + .nodedc-home-task-card[data-active="false"] .nodedc-home-task-card-surface { + -webkit-backdrop-filter: blur(24px); + backdrop-filter: blur(24px); + } + + .nodedc-home-task-card-surface-passive { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.038) 0%, rgba(255, 255, 255, 0.012) 100%), + rgba(7, 7, 9, 0.74) !important; + } + + .nodedc-home-task-card-surface-active { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.018) 100%), + rgba(var(--nodedc-card-active-rgb), 0.96) !important; + color: rgb(var(--nodedc-on-card-active-rgb)) !important; + box-shadow: + 0 30px 56px rgba(0, 0, 0, 0.28), + inset 0 0 0 1px rgba(255, 255, 255, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.04) !important; + } + + .nodedc-home-task-card[data-active="true"] .nodedc-home-task-card-surface { + background: rgb(var(--nodedc-card-active-rgb)) !important; + color: rgb(var(--nodedc-on-card-active-rgb)) !important; + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + box-shadow: + 0 30px 56px rgba(0, 0, 0, 0.28), + inset 0 0 0 1px rgba(255, 255, 255, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.04) !important; + } + + .nodedc-home-task-card-skeleton { + height: 14.75rem; + border-radius: 2rem !important; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.01) 100%), + rgba(7, 7, 9, 0.68) !important; + box-shadow: + 0 24px 48px rgba(0, 0, 0, 0.22), + inset 0 1px 0 rgba(255, 255, 255, 0.02) !important; + -webkit-backdrop-filter: blur(24px); + backdrop-filter: blur(24px); + } + .nodedc-home-metric-card { border-radius: 1.5rem !important; border: 1px solid rgba(255, 255, 255, 0.06);