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 = (
+
+ );
+
+ 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);