UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: фильтры активности и единый voice loader

This commit is contained in:
DCCONSTRUCTIONS
2026-04-26 21:17:23 +03:00
parent 9a91af372e
commit 7ac9a3dbd3
10 changed files with 509 additions and 40 deletions
@@ -4,19 +4,12 @@
* See the LICENSE file for details.
*/
import { useTheme } from "next-themes";
// assets
import LogoSpinnerDark from "@/app/assets/images/logo-spinner-dark.gif?url";
import LogoSpinnerLight from "@/app/assets/images/logo-spinner-light.gif?url";
import { NodedcProcessingLoader } from "./nodedc-processing-loader";
export function LogoSpinner() {
const { resolvedTheme } = useTheme();
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight;
return (
<div className="flex items-center justify-center">
<img src={logoSrc} alt="logo" className="h-6 w-auto object-contain sm:h-11" />
<NodedcProcessingLoader tone="white" variant="fluid" />
</div>
);
}
@@ -0,0 +1,29 @@
import { cn } from "@plane/utils";
type TNodedcProcessingLoaderProps = {
className?: string;
label?: string;
tone?: "accent" | "white";
variant?: "orbit" | "fluid";
};
export function NodedcProcessingLoader({
className,
label,
tone = "accent",
variant = "orbit",
}: TNodedcProcessingLoaderProps) {
return (
<div className={cn("flex flex-col items-center justify-center gap-5 text-center", className)}>
<span
className={cn(
"nodedc-processing-loader",
variant === "fluid" && "nodedc-processing-loader-fluid",
tone === "white" && "nodedc-processing-loader-white"
)}
aria-hidden="true"
/>
{label && <div className="text-18 font-semibold text-primary">{label}</div>}
</div>
);
}
@@ -6,13 +6,14 @@
import { useMemo, useRef, useState } from "react";
import { observer } from "mobx-react";
import { ArrowDownUp, Check, Filter } from "lucide-react";
import useSWR from "swr";
import { useTranslation } from "@plane/i18n";
// plane types
import { PageIcon, ProjectIcon, WorkItemsIcon } from "@plane/propel/icons";
import { Avatar } from "@plane/propel/avatar";
import type { IIssueActivity, TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types";
import { calculateTimeAgo, generateWorkItemLink, getFileURL, renderFormattedDate } from "@plane/utils";
import { calculateTimeAgo, cn, generateWorkItemLink, getFileURL, renderFormattedDate } from "@plane/utils";
import { ActivityIcon } from "@/components/core/activity";
// plane web services
import { WorkspaceService } from "@/services/workspace.service";
@@ -34,6 +35,35 @@ const filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode; i18n_k
{ name: "project", icon: <ProjectIcon height={16} width={16} />, i18n_key: "home.recents.filters.projects" },
];
type TActivityTraceFilterKey =
| "archive"
| "assignees"
| "content"
| "created"
| "dates"
| "labels"
| "mine"
| "priority"
| "state";
type TActivityTraceSortMode = "newest" | "oldest";
const ACTIVITY_TRACE_FILTER_OPTIONS: { key: TActivityTraceFilterKey; label: string }[] = [
{ key: "created", label: "Новые задачи" },
{ key: "assignees", label: "Исполнители" },
{ key: "state", label: "Статусы" },
{ key: "priority", label: "Приоритеты" },
{ key: "dates", label: "Сроки и даты" },
{ key: "content", label: "Название и описание" },
{ key: "labels", label: "Метки" },
{ key: "archive", label: "Архив и восстановление" },
{ key: "mine", label: "Только мои действия" },
];
const ACTIVITY_TRACE_SORT_OPTIONS: { key: TActivityTraceSortMode; label: string }[] = [
{ key: "newest", label: "Новые сверху" },
{ key: "oldest", label: "Старые сверху" },
];
const PRIORITY_LABELS: Record<string, string> = {
urgent: "Срочный",
high: "Высокий",
@@ -83,6 +113,39 @@ const activityMessage = (activity: IIssueActivity) => {
}
};
const getActivityTraceFilterKeys = (activity: IIssueActivity): TActivityTraceFilterKey[] => {
if (!activity.field && activity.verb === "created") return ["created"];
switch (activity.field) {
case "assignees":
return ["assignees"];
case "state":
return ["state"];
case "priority":
return ["priority"];
case "start_date":
case "target_date":
return ["dates"];
case "name":
case "description":
return ["content"];
case "labels":
return ["labels"];
case "archived_at":
return ["archive"];
default:
return [];
}
};
const sortActivityTrace = (activities: IIssueActivity[], sortMode: TActivityTraceSortMode) =>
[...activities].sort((firstActivity, secondActivity) => {
const firstTime = new Date(firstActivity.created_at).getTime();
const secondTime = new Date(secondActivity.created_at).getTime();
return sortMode === "oldest" ? firstTime - secondTime : secondTime - firstTime;
});
type TRecentWidgetProps = THomeWidgetProps & {
presetFilter?: TRecentActivityFilterKeys;
showFilterSelect?: boolean;
@@ -158,6 +221,9 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
const { presetFilter, showFilterSelect = true, workspaceSlug, projectId, recents: preloadedRecents } = props;
// states
const [filter, setFilter] = useState<TRecentActivityFilterKeys>(presetFilter ?? filters[0].name);
const [activeTraceFilters, setActiveTraceFilters] = useState<TActivityTraceFilterKey[]>([]);
const [isTraceFilterOpen, setIsTraceFilterOpen] = useState(false);
const [traceSortMode, setTraceSortMode] = useState<TActivityTraceSortMode>("newest");
const { t } = useTranslation();
const { data: currentUser } = useUser();
// ref
@@ -171,7 +237,7 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
workspaceSlug
? () =>
workspaceService.fetchWorkspaceActivity(workspaceSlug.toString(), {
per_page: 20,
per_page: 60,
...(projectId ? { project: projectId } : {}),
})
: null,
@@ -200,7 +266,20 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
}
);
const activityTrace = useMemo(() => fetchedActivity?.results ?? [], [fetchedActivity?.results]);
const activityTrace = useMemo(() => {
const source = fetchedActivity?.results ?? [];
const onlyMine = activeTraceFilters.includes("mine");
const fieldFilters = activeTraceFilters.filter((filterKey) => filterKey !== "mine");
const filteredTrace = source.filter((activity) => {
if (onlyMine && currentUser?.id !== activity.actor_detail?.id) return false;
if (fieldFilters.length === 0) return true;
const activityFilterKeys = getActivityTraceFilterKeys(activity);
return fieldFilters.some((filterKey) => activityFilterKeys.includes(filterKey));
});
return sortActivityTrace(filteredTrace, traceSortMode);
}, [activeTraceFilters, currentUser?.id, fetchedActivity?.results, traceSortMode]);
const recents = useMemo(() => {
const source = preloadedRecents ?? fetchedRecents ?? [];
@@ -217,6 +296,106 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
const isWidgetLoading = shouldUseActivityTrace ? isActivityLoading : isLoading;
const isWidgetEmpty = shouldUseActivityTrace ? activityTrace.length === 0 : recents.length === 0;
const activeTraceFilterCount = activeTraceFilters.length + (traceSortMode === "newest" ? 0 : 1);
const toggleTraceFilter = (filterKey: TActivityTraceFilterKey) =>
setActiveTraceFilters((currentFilters) =>
currentFilters.includes(filterKey)
? currentFilters.filter((currentFilter) => currentFilter !== filterKey)
: [...currentFilters, filterKey]
);
const resetTraceFilters = () => {
setActiveTraceFilters([]);
setTraceSortMode("newest");
};
const headerActions = (
<div className="flex items-center gap-2">
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
{shouldUseActivityTrace && (
<div className="nodedc-home-gantt-action-group">
<button
type="button"
className={cn("nodedc-home-gantt-round-button", {
"nodedc-home-gantt-round-button-active": activeTraceFilterCount > 0 || isTraceFilterOpen,
"nodedc-home-gantt-filter-button-has-count": activeTraceFilterCount > 0,
})}
aria-expanded={isTraceFilterOpen}
aria-label="Фильтры недавних действий"
aria-pressed={activeTraceFilterCount > 0}
onClick={() => setIsTraceFilterOpen((isOpen) => !isOpen)}
>
<Filter className="size-4" />
{activeTraceFilterCount > 0 && (
<span className="nodedc-home-gantt-filter-count">{activeTraceFilterCount}</span>
)}
</button>
{isTraceFilterOpen && (
<div className="nodedc-home-gantt-popover nodedc-home-gantt-popover-wide">
<div className="nodedc-home-gantt-popover-section">
<div className="nodedc-home-gantt-popover-title">Тип действия</div>
{ACTIVITY_TRACE_FILTER_OPTIONS.map((option) => {
const isActive = activeTraceFilters.includes(option.key);
return (
<button
key={option.key}
type="button"
className={cn("nodedc-home-gantt-popover-option", {
"nodedc-home-gantt-popover-option-active": isActive,
})}
onClick={() => toggleTraceFilter(option.key)}
>
<span className="nodedc-home-gantt-popover-option-left">
<span className="nodedc-home-gantt-popover-check">
{isActive && <Check className="size-3" />}
</span>
<span>{option.label}</span>
</span>
</button>
);
})}
</div>
<div className="nodedc-home-gantt-popover-section">
<div className="nodedc-home-gantt-popover-title">Сортировка</div>
{ACTIVITY_TRACE_SORT_OPTIONS.map((option) => {
const isActive = traceSortMode === option.key;
return (
<button
key={option.key}
type="button"
className={cn("nodedc-home-gantt-popover-option", {
"nodedc-home-gantt-popover-option-active": isActive,
})}
onClick={() => setTraceSortMode(option.key)}
>
<span className="nodedc-home-gantt-popover-option-left">
<span className="nodedc-home-gantt-popover-check">
{isActive && <Check className="size-3" />}
</span>
<span>{option.label}</span>
</span>
<ArrowDownUp className="size-3 text-tertiary" />
</button>
);
})}
</div>
{activeTraceFilterCount > 0 && (
<button type="button" className="nodedc-home-gantt-popover-reset" onClick={resetTraceFilters}>
Сбросить фильтры
</button>
)}
</div>
)}
</div>
)}
</div>
);
const resolveRecent = (activity: TActivityEntityData) => {
switch (activity.entity_name) {
@@ -237,7 +416,7 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
<div ref={ref} className="max-h-[500px] overflow-y-scroll">
<div className="mb-4 flex items-center justify-between">
<div className="text-14 font-semibold text-tertiary">{t("home.recents.title")}</div>
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
{headerActions}
</div>
<div className="flex flex-col items-center justify-center">
<RecentsEmptyState type={filter} />
@@ -249,7 +428,7 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
<div className="box-border min-h-[250px]">
<div className="mb-2 flex items-center justify-between">
<div className="text-14 font-semibold text-tertiary">{t("home.recents.title")}</div>
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
{headerActions}
</div>
<div className="flex max-h-[415px] min-h-[250px] flex-col overflow-y-auto">
{isWidgetLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
@@ -7,7 +7,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ElementType, MouseEvent, ReactNode } from "react";
import { useParams } from "next/navigation";
import { LiveAudioVisualizer } from "react-audio-visualize";
import useSWR from "swr";
import {
AudioLines,
@@ -51,9 +50,11 @@ import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority";
import { ProjectDropdown } from "@/components/dropdowns/project/dropdown";
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
import { NodedcProcessingLoader } from "@/components/common/nodedc-processing-loader";
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
import { useIssues } from "@/hooks/store/use-issues";
import useDebounce from "@/hooks/use-debounce";
import { LiveAudioVisualizer } from "@/lib/vendor/react-audio-visualize";
// services
import { ProjectService } from "@/services/project";
import { WorkspaceAIService } from "@/services/workspace-ai.service";
@@ -442,8 +443,8 @@ function VoiceTaskWaveform({ mediaRecorder }: { mediaRecorder: MediaRecorder | n
barColor={accentColor}
fftSize={1024}
minDecibels={-92}
maxDecibels={-18}
smoothingTimeConstant={0.68}
maxDecibels={-30}
smoothingTimeConstant={0.52}
/>
</div>
</div>
@@ -452,8 +453,8 @@ function VoiceTaskWaveform({ mediaRecorder }: { mediaRecorder: MediaRecorder | n
function VoiceTaskProcessingState() {
return (
<div className="mt-2 flex h-40 items-center justify-center">
<span className="nodedc-voice-task-processing-loader" aria-hidden="true" />
<div className="mt-2 flex h-44 items-center justify-center">
<NodedcProcessingLoader label="Формируем карточку" />
</div>
);
}