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

View File

@ -4,19 +4,12 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useTheme } from "next-themes"; import { NodedcProcessingLoader } from "./nodedc-processing-loader";
// assets
import LogoSpinnerDark from "@/app/assets/images/logo-spinner-dark.gif?url";
import LogoSpinnerLight from "@/app/assets/images/logo-spinner-light.gif?url";
export function LogoSpinner() { export function LogoSpinner() {
const { resolvedTheme } = useTheme();
const logoSrc = resolvedTheme === "dark" ? LogoSpinnerDark : LogoSpinnerLight;
return ( return (
<div className="flex items-center justify-center"> <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> </div>
); );
} }

View File

@ -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>
);
}

View File

@ -6,13 +6,14 @@
import { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { ArrowDownUp, Check, Filter } from "lucide-react";
import useSWR from "swr"; import useSWR from "swr";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
// plane types // plane types
import { PageIcon, ProjectIcon, WorkItemsIcon } from "@plane/propel/icons"; import { PageIcon, ProjectIcon, WorkItemsIcon } from "@plane/propel/icons";
import { Avatar } from "@plane/propel/avatar"; import { Avatar } from "@plane/propel/avatar";
import type { IIssueActivity, TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types"; 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"; import { ActivityIcon } from "@/components/core/activity";
// plane web services // plane web services
import { WorkspaceService } from "@/services/workspace.service"; 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" }, { 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> = { const PRIORITY_LABELS: Record<string, string> = {
urgent: "Срочный", urgent: "Срочный",
high: "Высокий", 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 & { type TRecentWidgetProps = THomeWidgetProps & {
presetFilter?: TRecentActivityFilterKeys; presetFilter?: TRecentActivityFilterKeys;
showFilterSelect?: boolean; showFilterSelect?: boolean;
@ -158,6 +221,9 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
const { presetFilter, showFilterSelect = true, workspaceSlug, projectId, recents: preloadedRecents } = 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 [activeTraceFilters, setActiveTraceFilters] = useState<TActivityTraceFilterKey[]>([]);
const [isTraceFilterOpen, setIsTraceFilterOpen] = useState(false);
const [traceSortMode, setTraceSortMode] = useState<TActivityTraceSortMode>("newest");
const { t } = useTranslation(); const { t } = useTranslation();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
// ref // ref
@ -171,7 +237,7 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
workspaceSlug workspaceSlug
? () => ? () =>
workspaceService.fetchWorkspaceActivity(workspaceSlug.toString(), { workspaceService.fetchWorkspaceActivity(workspaceSlug.toString(), {
per_page: 20, per_page: 60,
...(projectId ? { project: projectId } : {}), ...(projectId ? { project: projectId } : {}),
}) })
: null, : 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 recents = useMemo(() => {
const source = preloadedRecents ?? fetchedRecents ?? []; const source = preloadedRecents ?? fetchedRecents ?? [];
@ -217,6 +296,106 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
const isWidgetLoading = shouldUseActivityTrace ? isActivityLoading : isLoading; const isWidgetLoading = shouldUseActivityTrace ? isActivityLoading : isLoading;
const isWidgetEmpty = shouldUseActivityTrace ? activityTrace.length === 0 : recents.length === 0; 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) => { const resolveRecent = (activity: TActivityEntityData) => {
switch (activity.entity_name) { 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 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">
<div className="text-14 font-semibold text-tertiary">{t("home.recents.title")}</div> <div className="text-14 font-semibold text-tertiary">{t("home.recents.title")}</div>
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />} {headerActions}
</div> </div>
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
<RecentsEmptyState type={filter} /> <RecentsEmptyState type={filter} />
@ -249,7 +428,7 @@ export const RecentActivityWidget = observer(function RecentActivityWidget(props
<div className="box-border min-h-[250px]"> <div className="box-border min-h-[250px]">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<div className="text-14 font-semibold text-tertiary">{t("home.recents.title")}</div> <div className="text-14 font-semibold text-tertiary">{t("home.recents.title")}</div>
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />} {headerActions}
</div> </div>
<div className="flex max-h-[415px] min-h-[250px] flex-col overflow-y-auto"> <div className="flex max-h-[415px] min-h-[250px] flex-col overflow-y-auto">
{isWidgetLoading && <WidgetLoader widgetKey={WIDGET_KEY} />} {isWidgetLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}

View File

@ -7,7 +7,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ElementType, MouseEvent, ReactNode } from "react"; import type { ElementType, MouseEvent, ReactNode } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { LiveAudioVisualizer } from "react-audio-visualize";
import useSWR from "swr"; import useSWR from "swr";
import { import {
AudioLines, AudioLines,
@ -51,9 +50,11 @@ import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { PriorityDropdown } from "@/components/dropdowns/priority"; import { PriorityDropdown } from "@/components/dropdowns/priority";
import { ProjectDropdown } from "@/components/dropdowns/project/dropdown"; import { ProjectDropdown } from "@/components/dropdowns/project/dropdown";
import { StateDropdown } from "@/components/dropdowns/state/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 { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
import { useIssues } from "@/hooks/store/use-issues"; import { useIssues } from "@/hooks/store/use-issues";
import useDebounce from "@/hooks/use-debounce"; import useDebounce from "@/hooks/use-debounce";
import { LiveAudioVisualizer } from "@/lib/vendor/react-audio-visualize";
// services // services
import { ProjectService } from "@/services/project"; import { ProjectService } from "@/services/project";
import { WorkspaceAIService } from "@/services/workspace-ai.service"; import { WorkspaceAIService } from "@/services/workspace-ai.service";
@ -442,8 +443,8 @@ function VoiceTaskWaveform({ mediaRecorder }: { mediaRecorder: MediaRecorder | n
barColor={accentColor} barColor={accentColor}
fftSize={1024} fftSize={1024}
minDecibels={-92} minDecibels={-92}
maxDecibels={-18} maxDecibels={-30}
smoothingTimeConstant={0.68} smoothingTimeConstant={0.52}
/> />
</div> </div>
</div> </div>
@ -452,8 +453,8 @@ function VoiceTaskWaveform({ mediaRecorder }: { mediaRecorder: MediaRecorder | n
function VoiceTaskProcessingState() { function VoiceTaskProcessingState() {
return ( return (
<div className="mt-2 flex h-40 items-center justify-center"> <div className="mt-2 flex h-44 items-center justify-center">
<span className="nodedc-voice-task-processing-loader" aria-hidden="true" /> <NodedcProcessingLoader label="Формируем карточку" />
</div> </div>
); );
} }

View File

@ -4,9 +4,10 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { BProgress } from "@bprogress/core"; import { BProgress } from "@bprogress/core";
import { useNavigation } from "react-router"; import { useNavigation } from "react-router";
import { NodedcProcessingLoader } from "@/components/common/nodedc-processing-loader";
import "@bprogress/core/css"; import "@bprogress/core/css";
/** /**
@ -66,10 +67,11 @@ const PROGRESS_CONFIG: Readonly<ProgressConfig> = {
* } * }
* ``` * ```
*/ */
export default function AppProgressBar(): null { export default function AppProgressBar() {
const navigation = useNavigation(); const navigation = useNavigation();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const startedRef = useRef<boolean>(false); const startedRef = useRef<boolean>(false);
const [isLoaderVisible, setIsLoaderVisible] = useState(false);
// Initialize BProgress once on mount // Initialize BProgress once on mount
useEffect(() => { useEffect(() => {
@ -118,6 +120,7 @@ export default function AppProgressBar(): null {
BProgress.done(); BProgress.done();
startedRef.current = false; startedRef.current = false;
} }
setIsLoaderVisible(false);
} else { } else {
// Navigation in progress (loading or submitting) // Navigation in progress (loading or submitting)
// Only start if not already started and no timer pending // Only start if not already started and no timer pending
@ -127,6 +130,7 @@ export default function AppProgressBar(): null {
BProgress.start(); BProgress.start();
startedRef.current = true; startedRef.current = true;
} }
setIsLoaderVisible(true);
timerRef.current = null; timerRef.current = null;
}, PROGRESS_CONFIG.delay); }, PROGRESS_CONFIG.delay);
} }
@ -139,5 +143,11 @@ export default function AppProgressBar(): null {
}; };
}, [navigation.state]); }, [navigation.state]);
return null; if (!isLoaderVisible) return null;
return (
<div className="pointer-events-none fixed inset-0 z-[9999] grid place-items-center bg-black/10 backdrop-blur-[1.5px]">
<NodedcProcessingLoader tone="white" variant="fluid" />
</div>
);
} }

View File

@ -0,0 +1 @@
export { LiveAudioVisualizer } from "./live-audio-visualizer";

View File

@ -0,0 +1,172 @@
/**
* Localized audio visualizer based on the public API shape of `react-audio-visualize`.
* It stays in-repo so the Voice Tasker recording UI can keep the package behavior
* while preserving NODEDC styling and silent-state dots.
*/
import { useCallback, useEffect, useRef } from "react";
type LiveAudioVisualizerProps = {
backgroundColor?: string;
barColor?: string;
barWidth?: number;
fftSize?: 32 | 64 | 128 | 256 | 512 | 1024 | 2048 | 4096 | 8192 | 16384 | 32768;
gap?: number;
height?: number;
maxDecibels?: number;
mediaRecorder: MediaRecorder;
minDecibels?: number;
smoothingTimeConstant?: number;
width?: number;
};
const averageVoiceFrequencyBins = (data: Uint8Array, width: number, barWidth: number, gap: number) => {
let barsCount = Math.max(1, Math.floor(width / (barWidth + gap)));
const voiceBandBinCount = Math.max(barsCount, Math.floor(data.length * 0.32));
const voiceBand = data.slice(1, voiceBandBinCount);
let binWindow = Math.floor(voiceBand.length / barsCount);
if (barsCount > voiceBand.length) {
barsCount = voiceBand.length;
binWindow = 1;
}
return Array.from({ length: barsCount }, (_, index) => {
let peak = 0;
let sumSquares = 0;
let count = 0;
for (let offset = 0; offset < binWindow && index * binWindow + offset < voiceBand.length; offset++) {
const value = voiceBand[index * binWindow + offset] ?? 0;
peak = Math.max(peak, value);
sumSquares += value * value;
count++;
}
const rms = Math.sqrt(sumSquares / Math.max(1, count));
return Math.max(rms, peak * 0.72);
});
};
export function LiveAudioVisualizer(props: LiveAudioVisualizerProps) {
const {
backgroundColor = "transparent",
barColor = "rgb(160, 198, 255)",
barWidth = 2,
fftSize = 1024,
gap = 1,
height = 100,
maxDecibels = -10,
mediaRecorder,
minDecibels = -90,
smoothingTimeConstant = 0.4,
width = 300,
} = props;
const animationFrameRef = useRef<number | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const latestHeightsRef = useRef<number[]>([]);
const draw = useCallback(
(rawValues: number[]) => {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (!canvas || !context) return;
const pixelRatio = window.devicePixelRatio || 1;
const canvasWidth = Math.max(1, Math.floor(width));
const canvasHeight = Math.max(1, Math.floor(height));
if (canvas.width !== canvasWidth * pixelRatio || canvas.height !== canvasHeight * pixelRatio) {
canvas.width = canvasWidth * pixelRatio;
canvas.height = canvasHeight * pixelRatio;
}
canvas.style.width = `${canvasWidth}px`;
canvas.style.height = `${canvasHeight}px`;
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
context.clearRect(0, 0, canvasWidth, canvasHeight);
if (backgroundColor !== "transparent") {
context.fillStyle = backgroundColor;
context.fillRect(0, 0, canvasWidth, canvasHeight);
}
const previousHeights = latestHeightsRef.current;
const centerY = canvasHeight / 2;
const maxBarHeight = canvasHeight * 0.94;
const nextHeights = rawValues.map((value, index) => {
const normalized = Math.max(0, Math.min(1, (value - 7) / 118));
const shapedValue = Math.min(1, Math.pow(normalized, 0.54) * 1.18);
const targetHeight = barWidth + shapedValue * (maxBarHeight - barWidth);
const previousHeight = previousHeights[index] ?? barWidth;
const smoothing = targetHeight > previousHeight ? 0.88 : 0.46;
return previousHeight + (targetHeight - previousHeight) * smoothing;
});
latestHeightsRef.current = nextHeights;
context.fillStyle = barColor;
nextHeights.forEach((barHeight, index) => {
const x = index * (barWidth + gap);
const y = centerY - barHeight / 2;
const radius = barWidth / 2;
context.beginPath();
if (context.roundRect) context.roundRect(x, y, barWidth, barHeight, radius);
else context.rect(x, y, barWidth, barHeight);
context.fill();
});
},
[backgroundColor, barColor, barWidth, gap, height, width]
);
useEffect(() => {
const stream = mediaRecorder.stream;
if (!stream) return;
const AudioContextClass =
window.AudioContext ||
(window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextClass) return;
const audioContext = new AudioContextClass();
const analyser = audioContext.createAnalyser();
const source = audioContext.createMediaStreamSource(stream);
analyser.fftSize = fftSize;
analyser.minDecibels = minDecibels;
analyser.maxDecibels = maxDecibels;
analyser.smoothingTimeConstant = smoothingTimeConstant;
const frequencyData = new Uint8Array(analyser.frequencyBinCount);
source.connect(analyser);
void audioContext.resume();
const renderFrame = () => {
if (mediaRecorder.state === "recording") {
analyser.getByteFrequencyData(frequencyData);
draw(averageVoiceFrequencyBins(frequencyData, width, barWidth, gap));
animationFrameRef.current = window.requestAnimationFrame(renderFrame);
return;
}
draw(averageVoiceFrequencyBins(new Uint8Array(analyser.frequencyBinCount), width, barWidth, gap));
};
renderFrame();
return () => {
if (animationFrameRef.current) {
window.cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
source.disconnect();
analyser.disconnect();
if (audioContext.state !== "closed") void audioContext.close();
};
}, [barWidth, draw, fftSize, gap, maxDecibels, mediaRecorder, minDecibels, smoothingTimeConstant, width]);
return <canvas ref={canvasRef} aria-hidden="true" />;
}

View File

@ -55,7 +55,6 @@
"next-themes": "0.4.6", "next-themes": "0.4.6",
"pdfjs-dist": "5.4.296", "pdfjs-dist": "5.4.296",
"react": "catalog:", "react": "catalog:",
"react-audio-visualize": "1.2.0",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-dom": "catalog:", "react-dom": "catalog:",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",

View File

@ -3937,15 +3937,23 @@
color: var(--text-color-primary) !important; color: var(--text-color-primary) !important;
} }
.nodedc-processing-loader,
.nodedc-voice-task-processing-loader { .nodedc-voice-task-processing-loader {
--nodedc-processing-loader-rgb: var(--nodedc-accent-rgb);
width: 5rem; width: 5rem;
aspect-ratio: 1; aspect-ratio: 1;
box-sizing: border-box; box-sizing: border-box;
position: relative; position: relative;
display: block; display: block;
color: rgb(var(--nodedc-accent-rgb)); color: rgb(var(--nodedc-processing-loader-rgb));
} }
.nodedc-processing-loader-white {
--nodedc-processing-loader-rgb: 255 255 255;
}
.nodedc-processing-loader::before,
.nodedc-processing-loader::after,
.nodedc-voice-task-processing-loader::before, .nodedc-voice-task-processing-loader::before,
.nodedc-voice-task-processing-loader::after { .nodedc-voice-task-processing-loader::after {
content: ""; content: "";
@ -3954,13 +3962,15 @@
display: block; display: block;
} }
.nodedc-processing-loader::before,
.nodedc-voice-task-processing-loader::before { .nodedc-voice-task-processing-loader::before {
inset: 1.125rem; inset: 1.125rem;
border: 0.5rem solid currentColor; border: 0.5rem solid currentColor;
border-radius: 0.625rem; border-radius: 0.625rem;
box-shadow: 0 0 1.25rem rgba(var(--nodedc-accent-rgb), 0.18); box-shadow: 0 0 1.25rem rgba(var(--nodedc-processing-loader-rgb), 0.18);
} }
.nodedc-processing-loader::after,
.nodedc-voice-task-processing-loader::after { .nodedc-voice-task-processing-loader::after {
width: 1rem; width: 1rem;
aspect-ratio: 1; aspect-ratio: 1;
@ -3968,7 +3978,7 @@
left: 0; left: 0;
border-radius: 9999px; border-radius: 9999px;
background: currentColor; background: currentColor;
box-shadow: 0 0 1.125rem rgba(var(--nodedc-accent-rgb), 0.28); box-shadow: 0 0 1.125rem rgba(var(--nodedc-processing-loader-rgb), 0.28);
offset-anchor: center; offset-anchor: center;
offset-path: path("M 22 22 H 58 V 58 H 22 V 22"); offset-path: path("M 22 22 H 58 V 58 H 22 V 22");
animation: nodedc-voice-task-processing-loader 1.8s cubic-bezier(0.65, 0, 0.35, 1) infinite; animation: nodedc-voice-task-processing-loader 1.8s cubic-bezier(0.65, 0, 0.35, 1) infinite;
@ -3995,4 +4005,93 @@
offset-distance: 100%; offset-distance: 100%;
} }
} }
.nodedc-processing-loader-fluid {
width: 5rem;
aspect-ratio: 1;
padding: 0.625rem;
box-sizing: border-box;
display: grid;
background: transparent;
filter: blur(5px) contrast(15);
mix-blend-mode: screen;
}
.nodedc-processing-loader-fluid::before,
.nodedc-processing-loader-fluid::after {
content: "";
position: static;
grid-area: 1 / 1;
display: block;
width: auto;
height: auto;
margin: 0.3125rem;
border: 0;
border-radius: 9999px;
background: currentColor;
box-shadow: none;
offset-path: none;
-webkit-mask-size: 100% 20px, 100% 100%;
mask-size: 100% 20px, 100% 100%;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-composite: destination-out;
mask-composite: exclude;
}
.nodedc-processing-loader-fluid::before {
-webkit-mask-image:
linear-gradient(#000 0 0),
linear-gradient(#000 0 0);
mask-image:
linear-gradient(#000 0 0),
linear-gradient(#000 0 0);
animation: nodedc-processing-fluid-shape 2s infinite;
}
.nodedc-processing-loader-fluid::after {
-webkit-mask-image: linear-gradient(#000 0 0);
mask-image: linear-gradient(#000 0 0);
animation:
nodedc-processing-fluid-shape 2s infinite,
nodedc-processing-fluid-jitter 0.5s infinite cubic-bezier(0.5, 200, 0.5, -200);
}
@keyframes nodedc-processing-fluid-shape {
0% {
-webkit-mask-position: 0 20%, 0 0;
mask-position: 0 20%, 0 0;
}
20% {
-webkit-mask-position: 0 80%, 0 0;
mask-position: 0 80%, 0 0;
}
40% {
-webkit-mask-position: 0 100%, 0 0;
mask-position: 0 100%, 0 0;
}
60% {
-webkit-mask-position: 0 0%, 0 0;
mask-position: 0 0%, 0 0;
}
80% {
-webkit-mask-position: 0 35%, 0 0;
mask-position: 0 35%, 0 0;
}
100% {
-webkit-mask-position: 0 0, 0 0;
mask-position: 0 0, 0 0;
}
}
@keyframes nodedc-processing-fluid-jitter {
100% {
transform: translate(0.1px);
}
}
} }

View File

@ -668,9 +668,6 @@ importers:
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 18.3.1 version: 18.3.1
react-audio-visualize:
specifier: 1.2.0
version: 1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-color: react-color:
specifier: ^2.19.3 specifier: ^2.19.3
version: 2.19.3(react@18.3.1) version: 2.19.3(react@18.3.1)
@ -7467,12 +7464,6 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true hasBin: true
react-audio-visualize@1.2.0:
resolution: {integrity: sha512-rfO5nmT0fp23gjU0y2WQT6+ZOq2ZsuPTMphchwX1PCz1Di4oaIr6x7JZII8MLrbHdG7UB0OHfGONTIsWdh67kQ==}
peerDependencies:
react: '>=16.2.0'
react-dom: '>=16.2.0'
react-color@2.19.3: react-color@2.19.3:
resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==} resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==}
peerDependencies: peerDependencies:
@ -15083,11 +15074,6 @@ snapshots:
minimist: 1.2.8 minimist: 1.2.8
strip-json-comments: 2.0.1 strip-json-comments: 2.0.1
react-audio-visualize@1.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-color@2.19.3(react@18.3.1): react-color@2.19.3(react@18.3.1):
dependencies: dependencies:
'@icons/material': 0.2.4(react@18.3.1) '@icons/material': 0.2.4(react@18.3.1)